From 1329d4eef4e70110e0b56e045e83deedffc404dd Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:36:12 -0400 Subject: [PATCH 001/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20handi?= =?UTF-8?q?ng=20a=20task=20to=20an=20outside=20harness=20(draft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A draft for discussion: an outside program such as swe-pro becomes one more worker kind behind the run supervisor rather than a slash command of its own. Names the seams it rides, the contract it asks of a program, what swe-pro would have to change, and the waves. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 287 +++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 docs/design/delegate/DESIGN.md diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md new file mode 100644 index 000000000..279353fa3 --- /dev/null +++ b/docs/design/delegate/DESIGN.md @@ -0,0 +1,287 @@ +# Delegates — handing a task to an outside harness — DESIGN (draft) + +*2026-09-21, written against `dev @ 17ae56d34` and `swe-pro-go @ 4c3084f` +(branch `zeropoint95/improvements`). Status: a draft for discussion. Nothing +here is built.* + +## The one sentence + +A **delegate** is an outside program that can do a whole coding task on its +own; codeaf hands it a task the way it hands one to its own worker — in a +working copy of its own, under the conversation's dollar and time limits, +drawn on the rail while it runs, landed on the branch when it ends — and the +program is one more **worker kind** behind the run supervisor, not a new +engine and not a slash command of its own. + +`swe-pro` is the first delegate. `codeaf do` on another machine is the second, +and it costs nothing extra, which is the test that the mechanism is general. + +## Why not the name "sub-harness" + +The word is taken, in code and in the manual. `internal/subharness`, +`/subharness`, `/harness`, `docs/SUBHARNESS.md` and the *Saved shapes of work* +pages all mean **a saved program built out of this binary's own node kinds** +(`agent.loop`, `tool.call`, `verify`, `human.gate`…), designed in the +conversation, stored under `~/.codeaf/harnesses//vN.json`, reached by +cue detection rather than by command. An outside binary that runs its own +agent loop is the opposite thing: codeaf designs nothing about it and cannot +see inside it. Calling both "sub-harness" would put two objects behind one +word in the manual, and the manual is what the chat answers from. + +So: **delegate**. A person "delegates the auth rewrite to swe-pro". The manual +page is *Delegates — programs codeaf can hand a task to*. + +## Why not `/swe-pro ` + +Three reasons, each already a law somewhere in this repository. + +1. **The turn must not wait.** A swe-pro run is thirty to ninety minutes. A + turn that blocks on it holds the conversation, the status line and the + person hostage, and the engine's own thirty-minute idle retirement (#1291) + is written on the assumption that long work is *work in the tree*, not a + turn. codeaf already has the right shape: work leaves the conversation as a + task, the turn ends, and the landing **wakes** a turn that reads the result. + A delegate ends the same way — the model never watches the stream; it + reads the terminal record and the landing note when it is woken. +2. **One door for work you walk away from.** `/task` is that door, and the + tasks page, the rail, `stop`, the working copy, the landing card and the + spend folding all hang off it. A `/swe-pro` command would have to rebuild + every one of those or ship without them. The delegate rides `/task`. +3. **The model should be able to choose it.** A person who types + `/swe-pro` has decided; the more useful case is the model proposing "this + one is big enough for swe-pro" from inside an ordinary turn, which means + the choice has to be a field on the task proposal, not a command. + +What a person types, then, is one of: + +``` +/task via swe-pro rewrite the auth middleware to use the new session store +/task rewrite the auth middleware … ← the model may propose a delegate itself +``` + +and `/delegate` (bare) lists the delegates this machine has, the way +`/subharness` lists programs. `/delegate ` is an alias for the +first form, kept because a one-word command is what a hand reaches for. + +## What already exists, and where this plugs in + +The run engine's 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 } +type WorkerFactory func(task plandb.Task) Worker +``` + +Everything the person sees and every limit they set already reaches a worker +through the supervisor: the cost ceiling and the elapsed ceiling +(`run.Limits`), the spend bank a 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`), the +working copy cut per run (`task_run_copy.go`), landing and merge +(`run.Land`, `landBeltRun`), the stop road (`session.Cancel` with a kind, and +`stoplaw_test.go` proving every running row can be stopped), and the fold of +the run's dollars into the conversation's total (`driveBeltRun`'s +`foldSpend`, #1280). + +**A delegate is a second implementation of `run.Worker`.** `CrewFactory` +already chooses a worker per task by role; it grows one more branch: a task +whose row names a delegate gets a `delegate.Worker` instead of a +`BashWorker`. Nothing above the factory changes. + +The floor to measure against is what works today with no change at all: the +model runs `swe-pro run …` through the `bash` tool with `background: true`. +That gives a job with a ring-buffer log and an exit notice on the owed lane — +and no working copy, no landing, no dollar limit, no rail row, no cost in +`/cost`, and a model that has to poll the job to find out. That gap is the +whole of what this design pays for. + +## The delegate contract + +codeaf asks five things of a program before it will hand it a task. They are +written as a manifest, one per delegate, and the manifest is the whole of +what codeaf knows about the program. + +| the program must | swe-pro today | `codeaf do` today | +| --- | --- | --- | +| **launch** from argv with the task as text, a working directory, a dollar ceiling and a wall ceiling | `swe-pro run --dir D --max-cost X --max-hours H -- "goal"` | `codeaf do --workspace D --max-cost X -timeout H --json "brief"` | +| **stream** its progress as one JSON object per line on stdout and nothing else | yes (EVENTS-CONTRACT.md) | no — stdout is the result only; progress is prose on stderr | +| **end** with exactly one terminal record carrying a status word, a reason and `cost_usd` | yes, `{"type":"terminal",…}` | the `--json` envelope, one object, on exit | +| **stop** cleanly on SIGTERM, still writing its terminal record | yes; SIGKILL is the only way to lose it | yes, the exit ladder | +| **leave its work in the tree** it was given, as commits or a dirty tree, and nothing that is not its work | eager `wip(edit)` commits; `.swe-pro/` git-excluded; `refs/swe-pro/*` | commits on a branch it names in the envelope | + +Two things codeaf does **not** ask, and says so on the page: + +- **Questions.** A delegate cannot ask the person anything. swe-pro's + `question` tool is auto-rejected inside the binary and there is no stdin + road; codeaf's own headless door exits `4 needed an answer` for the same + reason. The task brief has to be self-sufficient, and the page says so in + those words. +- **A step cap.** swe-pro has cost and hours and nothing per step. + `Limits.StepsPerTask` is not handed down, and the task page's step count is + whatever the adapter can read off the stream (tool parts for swe-pro, + nothing for `codeaf do`). + +### The manifest + +```jsonc +// ~/.codeaf/delegates/swe-pro.json — read at launch; absent binary = absent delegate +{ + "name": "swe-pro", + "description": "an autonomous coding agent for one large, well-specified change", + "bin": "swe-pro", // 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}}", "SWE_PRO_CP_URL": "off" }, + "reader": "swe-pro", // which stream reader (below) + "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } +} +``` + +`{{key:openrouter}}` is resolved through `config.APIKeyAt`, the same door +every lane and the e2e suite resolve a key through, so a key pasted into +first-run setup reaches the delegate (#576 is the lesson). A manifest whose +`bin` is not on PATH means the delegate is not offered — A CAPABILITY THAT +CANNOT WORK IS ABSENT, NOT BROKEN — and `/delegate` draws one dim line naming +the binary it looked for. + +### The readers + +A reader turns the program's stream into the three things the supervisor +wants while the program runs — a rising dollar figure, a live step sentence, +a trajectory line — and, at the end, a `Report` and an outcome word. Readers +are Go, in the binary, one per stream shape; a manifest names one. Two ship: + +**`swe-pro`** reads EVENTS-CONTRACT.md: + +| stream | becomes | +| --- | --- | +| `stage`/`status` compact records | the live step (`implement · running`, `verification · pass`) and one trajectory line each | +| `message.part.updated` with `part.type == "tool"` reaching `completed`/`error` | a trajectory step: the tool and its command, the observation head; `Steps` counts these | +| `message.updated` for an assistant message with `cost` | banked spend: the sum over completed assistant messages, monotonic | +| `terminal` | the `Report`: `Result` from `message` plus `data.reason` and `data.submission_reason`; `USD` from `data.cost_usd`; the outcome word from `status` | +| process exit with no terminal read | `ran and did not finish`, with the last stage seen in the result | + +The outcome mapping, written once beside the reader: + +| swe-pro `terminal.status` | run outcome | rail word | +| --- | --- | --- | +| `pass` (`data.status` pass or pass-unverified) | done | done | +| `fail` (`data.status` fail or unsubmitted) | 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 | + +The model's claim and the harness's own observation stay separate fields in +swe-pro's record and they stay separate in the landing note: *swe-pro says it +submitted; its verification failed 2 of 5 commands* is two sentences, never +one. + +**`codeaf`** reads the `--json` envelope on exit and the stderr quiet line +while it runs. It exists so that `codeaf do` on a second machine, over the +ssh road `--host` already has, is a delegate with no new machinery — and so +the contract is proven against a program whose stream discipline we control. + +### Money + +The delegate spends the person's key outside codeaf's provider ledger. The +supervisor's bank (`bankLive`, `countLiveSpend`) sees every rising figure the +reader hands it and cuts the run at the ceiling the way it cuts a bash worker +— by cancelling the context, which sends SIGTERM, which lets swe-pro write its +terminal record. The conversation's total, `/cost` and the status line move +through `foldSpend` as they do for any run (#1280). The machine's usage ledger +on disk does **not** get swe-pro's lines, because those calls were not made +through a codeaf lane; the spending page says `via swe-pro` on the row rather +than pretending otherwise. + +The dollar ceiling handed to the program is what is left of the smaller of +the conversation's limits (`runCostLeft`, #1281), passed on the command line +so the program cuts itself before codeaf has to. + +### Stopping + +`stop` on the row is `session.Cancel` with a new kind, `delegate`, and it goes +on the ledger in `stoplaw_test.go` with its proving test, like every other +kind. The worker terminates the process group (`internal/processgroup`), waits +the job grace (`jobTermGrace`), then kills. A terminal record that arrives +inside the grace is read and folded; one that does not leaves the row +`stopped` with the last stage seen. + +### Landing + +The working copy is the run's, cut as it is today. swe-pro is given it as +`--dir`. When the program ends, `run.Land` commits what is in the tree and +`landBeltRun` merges it home exactly as for a bash worker. Two swe-pro +particulars the landing has to know: + +- swe-pro's eager `wip(edit): path` commits are history in the copy. Landing + keeps them (one merge, honest history) rather than squashing — a person + who wants one commit has the branch. This is a decision to take, not a + fact; the other answer is one squash commit whose message is the terminal + record's reason. +- `.swe-pro/` is in `.git/info/exclude` of the copy, so it never lands, and + `refs/swe-pro/start` and `refs/swe-pro/submitted` die with the copy. + +### What the model is told + +`propose_task` grows an optional `via` field naming a delegate, and the +system prompt's `HANDOFF_FACTS` names the delegates this launch has, in the +same conditional way it names everything else (`beltfacts.go`): a build with +no manifest and no binary says nothing about delegates at all. The fact says +when to choose one — *a change big enough to want its own agent for an hour, +specified well enough that nobody will be asked anything* — and the model +proposes it on the same card `/task` shows, with `via swe-pro` on the card, +so the person still answers before money moves. + +## What has to change in swe-pro + +These are on the swe-pro side, and none of them is codeaf's to work around. + +1. **A standalone mode.** `swe-pro run` refuses to start without an AgentField + control plane answering `/health`; the message says "cannot be used + standalone". A codeaf user has no plane. The seam already exists — the + `injected` backend path tolerates a failed probe and continues with the + plane disabled — so this is `SWE_PRO_CP_URL=off` (or `--no-control-plane`) + taking that same branch, and a `run-contract` record that says the plane + is off. +2. **Cost as a compact record.** Live cost is today only recoverable by summing + `message.updated` assistant `cost` fields. A `{"type":"spend","cost_usd":…}` + compact record after each model request, cumulative, would make every + consumer's live limit exact and free the reader from the bus schema. +3. **A question road, later.** `question.replied` is defined "for embedders + that answer". If codeaf ever answers a delegate's question from the rail + (the way it answers a worker's note), swe-pro needs a stdin or socket road + to deliver it and to stop auto-rejecting when one is attached. Not v1. + +## What has to change in codeaf + +| # | lands | proof | +| --- | --- | --- | +| **1** | `internal/delegate`: the manifest and its loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM-then-kill, `Report`); the `swe-pro` reader; the `codeaf` reader | unit tests against a fake binary that emits scripted NDJSON and honours SIGTERM; the outcome table pinned | +| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; `/task via `, `/delegate`, `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the landing note's two sentences; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the manual gates | +| **3** | the manual: *Delegates* page (what one is, how to ask, what it cannot do — no questions, no step cap — what it costs, where the work lands, the refusals verbatim); the `commands.md` rows | `internal/manual/chat_test.go` probes in a person's words: "can you hand this to swe-pro", "delegate this", "why can't the delegate ask me" | +| **4** | hosted: the row crosses `internal/remote` (`PlanTaskRow` already carries `Live` and `TrajectoryPath`, so this is mostly the `via` word); until then a `--host` session refuses with one sentence, the way `/subharness` does | `internal/remote` wire tests | +| later | the model chooses a delegate by seat (`worker` seat → swe-pro for `work` leaves, a crew row); a delegate on another machine; answering a delegate's question | — | + +Wave 1 has no door and spends no money; it is the contract, proven against a +stub. Wave 2 is the first thing a person can type. + +## Open questions + +1. **Squash or keep** swe-pro's eager commits at landing (above). +2. **Who picks the delegate's models.** swe-pro's `--high` pool is its own + default today. The manifest could pass the conversation's work seat + (`{{model:work}}`) so `/crew` governs the delegate too — but swe-pro speaks + OpenRouter slugs and codeaf's seat may be on another lane. First cut: the + manifest's own argv, no seat. +3. **Is `via` on the task or on the run?** A run is one store; a delegate is + one process that owns the whole tree for the hour. First cut: a delegated + task is a run of one task, and the supervisor never splits it. Splitting + a run between bash workers and a delegate is a later question. +4. **Trajectory from a foreign stream.** The task page assumes a step is a + command and an observation. swe-pro's tool parts fit; its `stage` records + do not. Either the page learns a "stage" row or the reader folds stages + into the live step only and never into the trajectory. From d215975fb4d666a1217c7cc805a4f09bd3b0a53b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:54:10 -0400 Subject: [PATCH 002/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20one?= =?UTF-8?q?=20word=20per=20delegate=20starts=20a=20task;=20harness=20comma?= =?UTF-8?q?nds=20drawn=20beside=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner feedback 2026-09-21: the UX is /swe-pro , not /task via. The row is generated from the manifest and rides the task door. The control-plane change and the spend-record question are handed to the swe-pro side. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 127 +++++++++++++++++++++------------ 1 file changed, 80 insertions(+), 47 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 279353fa3..c09fb942c 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -9,9 +9,10 @@ here is built.* A **delegate** is an outside program that can do a whole coding task on its own; codeaf hands it a task the way it hands one to its own worker — in a working copy of its own, under the conversation's dollar and time limits, -drawn on the rail while it runs, landed on the branch when it ends — and the -program is one more **worker kind** behind the run supervisor, not a new -engine and not a slash command of its own. +drawn on the rail while it runs, landed on the branch when it ends. A person +starts one with the program's own name as a command, `/swe-pro `, and +what that starts is a task; the program is one more **worker kind** behind +the run supervisor, never a second engine. `swe-pro` is the first delegate. `codeaf do` on another machine is the second, and it costs nothing extra, which is the test that the mechanism is general. @@ -31,37 +32,60 @@ word in the manual, and the manual is what the chat answers from. So: **delegate**. A person "delegates the auth rewrite to swe-pro". The manual page is *Delegates — programs codeaf can hand a task to*. -## Why not `/swe-pro ` - -Three reasons, each already a law somewhere in this repository. - -1. **The turn must not wait.** A swe-pro run is thirty to ninety minutes. A - turn that blocks on it holds the conversation, the status line and the - person hostage, and the engine's own thirty-minute idle retirement (#1291) - is written on the assumption that long work is *work in the tree*, not a - turn. codeaf already has the right shape: work leaves the conversation as a - task, the turn ends, and the landing **wakes** a turn that reads the result. - A delegate ends the same way — the model never watches the stream; it - reads the terminal record and the landing note when it is woken. -2. **One door for work you walk away from.** `/task` is that door, and the - tasks page, the rail, `stop`, the working copy, the landing card and the - spend folding all hang off it. A `/swe-pro` command would have to rebuild - every one of those or ship without them. The delegate rides `/task`. -3. **The model should be able to choose it.** A person who types - `/swe-pro` has decided; the more useful case is the model proposing "this - one is big enough for swe-pro" from inside an ordinary turn, which means - the choice has to be a field on the task proposal, not a command. - -What a person types, then, is one of: +## The command: one word per delegate, and it starts a task + +A person types the delegate's name as a command and the words after it are +the brief: ``` -/task via swe-pro rewrite the auth middleware to use the new session store -/task rewrite the auth middleware … ← the model may propose a delegate itself +/swe-pro rewrite the auth middleware to use the new session store ``` -and `/delegate` (bare) lists the delegates this machine has, the way -`/subharness` lists programs. `/delegate ` is an alias for the -first form, kept because a one-word command is what a hand reaches for. +That row is `/task` with the worker chosen. It goes through the same door +(`startTaskRun`), on the same card the person answers before money moves, +and what it starts is the same object — a run in the tasks store, in a working +copy of its own, on the rail with a live step, stoppable, landed when it ends. +The turn ends when the card is answered; the person is not held for the hour. +The only difference from `/task` is the worker seated on the root task, and +that is the one word the row carries. + +**The command table is generated from the delegates this launch has.** A +manifest at `~/.codeaf/delegates/.json` whose binary resolves on PATH +puts one row `/ ` in `internal/tui3`'s command list, described +with the manifest's own sentence, so `/help` and the command picker list it +beside `/task` — and a machine with no swe-pro has no `/swe-pro`, rather than +one that says no. A delegate's name may not collide with a built-in command; +the loader refuses the manifest and says which row it collided with. + +Two things this deliberately is not: + +- **Not a turn that waits.** A swe-pro run is thirty to ninety minutes. A + turn that blocked on it would hold the conversation and the status line + hostage, and the engine's thirty-minute idle retirement (#1291) is written + on the assumption that long work is *work in the tree*, not a turn. The + model never watches the stream. When the run ends, the landing wakes a turn + — as it does for every task today — and that woken turn reads the terminal + record and the landing note, and answers. +- **Not only a command.** `propose_task` grows an optional `via` naming a + delegate, so the model can propose "this one is big enough for swe-pro" + from an ordinary turn. The person still answers the card. The system prompt + names the delegates this launch has, conditionally, the way it names + everything else (`HANDOFF_FACTS` in `beltfacts.go`). + +`/delegate` (bare) lists the delegates this machine has, with the binary each +resolved to and the last run's two words, the way `/subharness` lists +programs. It is the answer to "which of these do I have here". + +### Beside the two commands that already exist + +The manual gate will make the chat explain all three, so the line between +them is drawn here once: + +| command | what it starts | who wrote the program | where it runs | +| --- | --- | --- | --- | +| `/harness` | a **saved shape of work**: a small program of this binary's own node kinds (`agent.loop`, `tool.call`, `verify`, `human.gate`…) that a designer model built in a conversation and saved to `~/.codeaf/harnesses//vN.json` | codeaf, at a person's request | inside this process, on this conversation's own belt | +| `/subharness` | the same list plus the bundles on disk and the built-ins, opened through an **intake card** with typed fields | codeaf, or a bundle author | inside this process | +| `/swe-pro` (a delegate) | an **outside binary** doing a whole task on its own | someone else, and codeaf cannot see inside it | a child process in a working copy, under the run supervisor | ## What already exists, and where this plugs in @@ -130,7 +154,7 @@ Two things codeaf does **not** ask, and says so on the page: ```jsonc // ~/.codeaf/delegates/swe-pro.json — read at launch; absent binary = absent delegate { - "name": "swe-pro", + "name": "swe-pro", // also the command: /swe-pro "description": "an autonomous coding agent for one large, well-specified change", "bin": "swe-pro", // resolved on PATH; a path is allowed "argv": ["run", "--dir", "{{workspace}}", @@ -233,36 +257,37 @@ same conditional way it names everything else (`beltfacts.go`): a build with no manifest and no binary says nothing about delegates at all. The fact says when to choose one — *a change big enough to want its own agent for an hour, specified well enough that nobody will be asked anything* — and the model -proposes it on the same card `/task` shows, with `via swe-pro` on the card, +proposes it on the same card `/task` shows, with `swe-pro` named on the card, so the person still answers before money moves. ## What has to change in swe-pro These are on the swe-pro side, and none of them is codeaf's to work around. -1. **A standalone mode.** `swe-pro run` refuses to start without an AgentField - control plane answering `/health`; the message says "cannot be used - standalone". A codeaf user has no plane. The seam already exists — the - `injected` backend path tolerates a failed probe and continues with the - plane disabled — so this is `SWE_PRO_CP_URL=off` (or `--no-control-plane`) - taking that same branch, and a `run-contract` record that says the plane - is off. +1. **The control plane becomes optional.** `swe-pro run` refuses to start + without an AgentField control plane answering `/health`; the message says + "cannot be used standalone". A codeaf user has no plane. The owner's + direction (2026-09-21): mirror onto a plane when one answers, run without + one when none does, one stderr note either way. The seam already exists — + the `injected` backend path tolerates a failed probe and continues with the + plane disabled. Asked of the `swe-pro finalize` session on 2026-09-21. 2. **Cost as a compact record.** Live cost is today only recoverable by summing `message.updated` assistant `cost` fields. A `{"type":"spend","cost_usd":…}` compact record after each model request, cumulative, would make every - consumer's live limit exact and free the reader from the bus schema. -3. **A question road, later.** `question.replied` is defined "for embedders - that answer". If codeaf ever answers a delegate's question from the rail - (the way it answers a worker's note), swe-pro needs a stdin or socket road - to deliver it and to stop auto-rejecting when one is attached. Not v1. + consumer's live limit exact and free the reader from the bus schema. Put + to the `swe-pro finalize` session as a question; the reader is written + against whichever answer comes back. +3. **No question road.** Decided 2026-09-21: a delegate does not ask. swe-pro + keeps auto-rejecting `question`, and the manual page says the brief has to + be self-sufficient. `question.replied` stays a seam nobody uses. ## What has to change in codeaf | # | lands | proof | | --- | --- | --- | | **1** | `internal/delegate`: the manifest and its loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM-then-kill, `Report`); the `swe-pro` reader; the `codeaf` reader | unit tests against a fake binary that emits scripted NDJSON and honours SIGTERM; the outcome table pinned | -| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; `/task via `, `/delegate`, `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the landing note's two sentences; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the manual gates | -| **3** | the manual: *Delegates* page (what one is, how to ask, what it cannot do — no questions, no step cap — what it costs, where the work lands, the refusals verbatim); the `commands.md` rows | `internal/manual/chat_test.go` probes in a person's words: "can you hand this to swe-pro", "delegate this", "why can't the delegate ask me" | +| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; the generated `/ ` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the landing note's two sentences; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the manual gates, which must learn that a generated row is spelled in the manual by its family (`/`) rather than by name | +| **3** | the manual: *Delegates* page (what one is, how to ask, what it cannot do — no questions, no step cap — what it costs, where the work lands, the refusals verbatim); the `commands.md` rows | `internal/manual/chat_test.go` probes in a person's words: "can you hand this to swe-pro", "what does /swe-pro do", "delegate this", "why can't the delegate ask me", "what is the difference between /harness and /swe-pro" | | **4** | hosted: the row crosses `internal/remote` (`PlanTaskRow` already carries `Live` and `TrajectoryPath`, so this is mostly the `via` word); until then a `--host` session refuses with one sentence, the way `/subharness` does | `internal/remote` wire tests | | later | the model chooses a delegate by seat (`worker` seat → swe-pro for `work` leaves, a crew row); a delegate on another machine; answering a delegate's question | — | @@ -281,7 +306,15 @@ stub. Wave 2 is the first thing a person can type. one process that owns the whole tree for the hour. First cut: a delegated task is a run of one task, and the supervisor never splits it. Splitting a run between bash workers and a delegate is a later question. -4. **Trajectory from a foreign stream.** The task page assumes a step is a +4. **A generated command and the manual law.** `manual_test.go` demands every + row in the command table be spelled in the corpus. A row that exists only + on machines with a manifest cannot be spelled by name in a page compiled + into every binary. Either the gate learns a family row (`/`), or + the built-in delegates (swe-pro, codeaf) are also built-in rows that are + *shelved* when their binary is absent, and only those may be commands. + The second is simpler and keeps the table static; a manifest with an + unknown name would then be reachable by `/delegate ` only. +5. **Trajectory from a foreign stream.** The task page assumes a step is a command and an observation. swe-pro's tool parts fit; its `stage` records do not. Either the page learns a "stage" row or the reader folds stages into the live step only and never into the trajectory. From 8a70b38a8681889b21c2c978f657f4d39b70c322 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:04:44 -0400 Subject: [PATCH 003/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20swe-p?= =?UTF-8?q?ro's=20control=20plane=20is=20optional=20and=20it=20streams=20s?= =?UTF-8?q?pend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what landed on the swe-pro side (f3b9716): no plane needed, a cumulative `stage: spend` record the live limit reads, and the rule never to sum message.updated costs. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 54 ++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index c09fb942c..2a935ce7e 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -1,8 +1,9 @@ # Delegates — handing a task to an outside harness — DESIGN (draft) -*2026-09-21, written against `dev @ 17ae56d34` and `swe-pro-go @ 4c3084f` -(branch `zeropoint95/improvements`). Status: a draft for discussion. Nothing -here is built.* +*2026-09-21, written against `dev @ 17ae56d34` and `swe-pro-go @ f3b9716` +(branch `zeropoint95/improvements`, PR #30). Status: a draft for discussion. +Nothing here is built on the codeaf side; the two swe-pro changes it asked for +have landed.* ## The one sentence @@ -160,7 +161,7 @@ Two things codeaf does **not** ask, and says so on the page: "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], - "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}", "SWE_PRO_CP_URL": "off" }, + "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" }, // no plane needed: swe-pro runs standalone "reader": "swe-pro", // which stream reader (below) "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } } @@ -186,7 +187,7 @@ are Go, in the binary, one per stream shape; a manifest names one. Two ship: | --- | --- | | `stage`/`status` compact records | the live step (`implement · running`, `verification · pass`) and one trajectory line each | | `message.part.updated` with `part.type == "tool"` reaching `completed`/`error` | a trajectory step: the tool and its command, the observation head; `Steps` counts these | -| `message.updated` for an assistant message with `cost` | banked spend: the sum over completed assistant messages, monotonic | +| `stage == "spend"`, `status == "recorded"` | banked spend: `data.cost_usd`, cumulative for the whole run and non-decreasing, one per completed assistant message (coder and compaction). NEVER sum `cost` off `message.updated`: an assistant message is written more than once and a naive sum double-counts, which is why this record exists | | `terminal` | the `Report`: `Result` from `message` plus `data.reason` and `data.submission_reason`; `USD` from `data.cost_usd`; the outcome word from `status` | | process exit with no terminal read | `ran and did not finish`, with the last stage seen in the result | @@ -260,26 +261,29 @@ specified well enough that nobody will be asked anything* — and the model proposes it on the same card `/task` shows, with `swe-pro` named on the card, so the person still answers before money moves. -## What has to change in swe-pro - -These are on the swe-pro side, and none of them is codeaf's to work around. - -1. **The control plane becomes optional.** `swe-pro run` refuses to start - without an AgentField control plane answering `/health`; the message says - "cannot be used standalone". A codeaf user has no plane. The owner's - direction (2026-09-21): mirror onto a plane when one answers, run without - one when none does, one stderr note either way. The seam already exists — - the `injected` backend path tolerates a failed probe and continues with the - plane disabled. Asked of the `swe-pro finalize` session on 2026-09-21. -2. **Cost as a compact record.** Live cost is today only recoverable by summing - `message.updated` assistant `cost` fields. A `{"type":"spend","cost_usd":…}` - compact record after each model request, cumulative, would make every - consumer's live limit exact and free the reader from the bus schema. Put - to the `swe-pro finalize` session as a question; the reader is written - against whichever answer comes back. -3. **No question road.** Decided 2026-09-21: a delegate does not ask. swe-pro - keeps auto-rejecting `question`, and the manual page says the brief has to - be self-sufficient. `question.replied` stays a seam nobody uses. +## What swe-pro changed for this (landed 2026-09-21, `f3b9716`, PR #30) + +1. **The control plane is optional.** A reachable plane is mirrored onto as + before; an unreachable one costs one stderr line — + `[swe-pro] no AgentField control plane at (…); running standalone, + events go to stdout only` — and the run proceeds. The `run-contract` record + carries `"control_plane": {"enabled": false, "url": ""}`. + `swe-pro serve` still requires a plane, which is right: serve is a node. + The manifest therefore sets no `SWE_PRO_CP_*` variable at all. +2. **A live spend record.** `{"type":"stage","stage":"spend","status":"recorded","data":{"cost_usd":0.0213},…}`, + one per completed assistant message, cumulative and non-decreasing, + compaction included. `agent-summary` and `terminal` still carry the + authoritative end-of-run totals; `spend` is the one the live limit reads. +3. **No question road.** Decided: a delegate does not ask. swe-pro keeps + auto-rejecting `question`, and the manual page says the brief has to be + self-sufficient. + +Checked by the swe-pro side against its code: the outcome table above holds, +SIGTERM unwinds through the normal path and still writes the terminal record, +and `--` before the goal parses. The model's claim (`submission_reason`, +`submission_evidence`, `checklist_satisfied`) and swe-pro's own observation +(`status`, `verification_failing`, `patch_bytes`) are separate fields and are +never reconciled; "did it actually work" is the latter. ## What has to change in codeaf From 7ae4151d46e2aa3fe136f345a3070774805c8364 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:10:46 -0400 Subject: [PATCH 004/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20gener?= =?UTF-8?q?ated=20command=20rows,=20the=20manual=20law=20moves=20to=20load?= =?UTF-8?q?=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner's call 2026-09-21: delegates are added at a person's discretion, so the / row cannot be a build-time list. The manifest ships its own manual page, the corpus layers installed pages over the packed one, and a page that does not spell its command refuses the manifest. codeaf-as-delegate moves to later. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 70 ++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 2a935ce7e..744fefb13 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -15,8 +15,10 @@ starts one with the program's own name as a command, `/swe-pro `, and what that starts is a task; the program is one more **worker kind** behind the run supervisor, never a second engine. -`swe-pro` is the first delegate. `codeaf do` on another machine is the second, -and it costs nothing extra, which is the test that the mechanism is general. +`swe-pro` is the first delegate and the one this design ships. The +mechanism is meant to hold any number of them, added one manifest at a time +at a person's discretion; `codeaf do` itself would be one later, which is a +useful test that nothing here is swe-pro-shaped. ## Why not the name "sub-harness" @@ -179,7 +181,7 @@ the binary it looked for. A reader turns the program's stream into the three things the supervisor wants while the program runs — a rising dollar figure, a live step sentence, a trajectory line — and, at the end, a `Report` and an outcome word. Readers -are Go, in the binary, one per stream shape; a manifest names one. Two ship: +are Go, in the binary, one per stream shape; a manifest names one. One ships: **`swe-pro`** reads EVENTS-CONTRACT.md: @@ -205,10 +207,9 @@ swe-pro's record and they stay separate in the landing note: *swe-pro says it submitted; its verification failed 2 of 5 commands* is two sentences, never one. -**`codeaf`** reads the `--json` envelope on exit and the stderr quiet line -while it runs. It exists so that `codeaf do` on a second machine, over the -ssh road `--host` already has, is a delegate with no new machinery — and so -the contract is proven against a program whose stream discipline we control. +**`codeaf`** (later, not this wave) would read the `--json` envelope on exit +and the stderr quiet line while it runs, so that `codeaf do` on a second +machine is a delegate with no new machinery. ### Money @@ -285,15 +286,52 @@ and `--` before the goal parses. The model's claim (`submission_reason`, (`status`, `verification_failing`, `patch_bytes`) are separate fields and are never reconciled; "did it actually work" is the latter. +## Generated commands and the manual law + +Delegates are added at a person's discretion, one manifest each, and the +command row exists only while its delegate does. So the command table cannot +be a closed list the binary knows at build time, and the manual law — every +command the chat offers is explained in the corpus the chat answers from — +cannot be met by a page compiled into every binary. The law stays; where it +is enforced moves. + +- **The static table keeps its static gate.** `internal/tui3`'s `commands` + is unchanged and `TestTheManualMentionsEveryCommandTheTableOffers` still + walks it. Delegate rows are not in it: the surface reads them off the + delegate registry at launch and appends them to the live list (`/help`, + the picker, the fuzzy matcher), so nothing generated is ever tested by a + law that reads a Go literal. +- **The manifest carries its own page.** A delegate ships `manual.md` beside + its manifest: what it does, how to ask it, what it cannot do, what a run + costs, where the work lands — the same rules every page in + `internal/manual/chat/` follows, `## ` headings and all. At launch the + chat's corpus is the packed corpus **plus an overlay** of the installed + delegates' pages (`manual.Corpus` gains one constructor that layers pages + over another corpus; search, `Mentions` and `Page` read both). The + `manual` tool then answers "what does /swe-pro do" from swe-pro's own page. +- **The law is checked at load.** A manifest whose page does not mention + `/` is refused, and the refusal is drawn where `/delegate` lists the + rest: `swe-pro: its manual page does not say /swe-pro — not added`. That is + the compile-time gate, moved to the moment the command comes into + existence, with the same sentence shape the gate prints. +- **One built-in page explains the family.** *Delegates — programs codeaf + can hand a task to* is in the packed corpus, mentions `/delegate`, and is + where "what is a delegate", "how do I add one", "why is there no /swe-pro + on this machine" are answered. It never names a delegate the build cannot + promise exists. + +A delegate's name may not collide with a built-in row or an alias; the loader +refuses it and names the row. + ## What has to change in codeaf | # | lands | proof | | --- | --- | --- | -| **1** | `internal/delegate`: the manifest and its loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM-then-kill, `Report`); the `swe-pro` reader; the `codeaf` reader | unit tests against a fake binary that emits scripted NDJSON and honours SIGTERM; the outcome table pinned | -| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; the generated `/ ` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the landing note's two sentences; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the manual gates, which must learn that a generated row is spelled in the manual by its family (`/`) rather than by name | -| **3** | the manual: *Delegates* page (what one is, how to ask, what it cannot do — no questions, no step cap — what it costs, where the work lands, the refusals verbatim); the `commands.md` rows | `internal/manual/chat_test.go` probes in a person's words: "can you hand this to swe-pro", "what does /swe-pro do", "delegate this", "why can't the delegate ask me", "what is the difference between /harness and /swe-pro" | +| **1** | `internal/delegate`: the manifest and its loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM-then-kill, `Report`); the `swe-pro` reader | unit tests against a fake binary that emits scripted NDJSON and honours SIGTERM; the outcome table pinned | +| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; the generated `/ ` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the landing note's two sentences; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the static manual gates untouched | +| **3** | the manual: the built-in *Delegates* page (what one is, how to add one, what it cannot do — no questions, no step cap — the refusals verbatim); the corpus overlay and the load-time page check; swe-pro's own `manual.md` shipped beside its manifest | `internal/manual/chat_test.go` probes in a person's words: "can you hand this to swe-pro", "what does /swe-pro do", "delegate this", "why can't the delegate ask me", "what is the difference between /harness and /swe-pro" | | **4** | hosted: the row crosses `internal/remote` (`PlanTaskRow` already carries `Live` and `TrajectoryPath`, so this is mostly the `via` word); until then a `--host` session refuses with one sentence, the way `/subharness` does | `internal/remote` wire tests | -| later | the model chooses a delegate by seat (`worker` seat → swe-pro for `work` leaves, a crew row); a delegate on another machine; answering a delegate's question | — | +| later | a `codeaf` reader so `codeaf do` on another machine is itself a delegate; the model chooses a delegate by seat (`worker` seat → swe-pro for `work` leaves, a crew row); answering a delegate's question | — | Wave 1 has no door and spends no money; it is the contract, proven against a stub. Wave 2 is the first thing a person can type. @@ -310,15 +348,7 @@ stub. Wave 2 is the first thing a person can type. one process that owns the whole tree for the hour. First cut: a delegated task is a run of one task, and the supervisor never splits it. Splitting a run between bash workers and a delegate is a later question. -4. **A generated command and the manual law.** `manual_test.go` demands every - row in the command table be spelled in the corpus. A row that exists only - on machines with a manifest cannot be spelled by name in a page compiled - into every binary. Either the gate learns a family row (`/`), or - the built-in delegates (swe-pro, codeaf) are also built-in rows that are - *shelved* when their binary is absent, and only those may be commands. - The second is simpler and keeps the table static; a manifest with an - unknown name would then be reachable by `/delegate ` only. -5. **Trajectory from a foreign stream.** The task page assumes a step is a +4. **Trajectory from a foreign stream.** The task page assumes a step is a command and an observation. swe-pro's tool parts fit; its `stage` records do not. Either the page learns a "stage" row or the reader folds stages into the live step only and never into the trajectory. From 9eaf4ab52444441fadc057ab143c62a101aaa9b1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:56:43 -0400 Subject: [PATCH 005/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20swe-p?= =?UTF-8?q?ro's=20eager=20commits=20are=20squashed=20at=20landing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner's call 2026-09-21: one commit per delegated task, subject the task's title, body the terminal record's claim and observation, then the ordinary merge home. Eager commits stay on inside the copy. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 744fefb13..3ddab0b68 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -243,11 +243,18 @@ The working copy is the run's, cut as it is today. swe-pro is given it as `landBeltRun` merges it home exactly as for a bash worker. Two swe-pro particulars the landing has to know: -- swe-pro's eager `wip(edit): path` commits are history in the copy. Landing - keeps them (one merge, honest history) rather than squashing — a person - who wants one commit has the branch. This is a decision to take, not a - fact; the other answer is one squash commit whose message is the terminal - record's reason. +- **swe-pro's commits are squashed at landing.** swe-pro commits every + `edit` and `write` as it happens (`wip(edit): `, hooks bypassed), so + an hour's run leaves dozens of bookkeeping commits in the copy. Decided + 2026-09-21: the delegate landing folds everything the copy's branch holds + past the cut point into **one commit**, then merges that home the way every + task lands. The commit's subject is the task's title; its body is swe-pro's + terminal record in two sentences — what the model claimed + (`submission_reason`) and what swe-pro observed (`status`, + `verification_failing`). The eager commits are not turned off + (`SWE_PRO_EAGER_COMMIT=0`), because swe-pro's own crash recovery and its + restore-after-ship read those file-level checkpoints; they are swe-pro's + business inside the copy and nobody's outside it. - `.swe-pro/` is in `.git/info/exclude` of the copy, so it never lands, and `refs/swe-pro/start` and `refs/swe-pro/submitted` die with the copy. @@ -328,7 +335,7 @@ refuses it and names the row. | # | lands | proof | | --- | --- | --- | | **1** | `internal/delegate`: the manifest and its loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM-then-kill, `Report`); the `swe-pro` reader | unit tests against a fake binary that emits scripted NDJSON and honours SIGTERM; the outcome table pinned | -| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; the generated `/ ` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the landing note's two sentences; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the static manual gates untouched | +| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; the generated `/ ` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the squash-then-merge landing and its two-sentence note; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the static manual gates untouched | | **3** | the manual: the built-in *Delegates* page (what one is, how to add one, what it cannot do — no questions, no step cap — the refusals verbatim); the corpus overlay and the load-time page check; swe-pro's own `manual.md` shipped beside its manifest | `internal/manual/chat_test.go` probes in a person's words: "can you hand this to swe-pro", "what does /swe-pro do", "delegate this", "why can't the delegate ask me", "what is the difference between /harness and /swe-pro" | | **4** | hosted: the row crosses `internal/remote` (`PlanTaskRow` already carries `Live` and `TrajectoryPath`, so this is mostly the `via` word); until then a `--host` session refuses with one sentence, the way `/subharness` does | `internal/remote` wire tests | | later | a `codeaf` reader so `codeaf do` on another machine is itself a delegate; the model chooses a delegate by seat (`worker` seat → swe-pro for `work` leaves, a crew row); answering a delegate's question | — | @@ -338,17 +345,16 @@ stub. Wave 2 is the first thing a person can type. ## Open questions -1. **Squash or keep** swe-pro's eager commits at landing (above). -2. **Who picks the delegate's models.** swe-pro's `--high` pool is its own +1. **Who picks the delegate's models.** swe-pro's `--high` pool is its own default today. The manifest could pass the conversation's work seat (`{{model:work}}`) so `/crew` governs the delegate too — but swe-pro speaks OpenRouter slugs and codeaf's seat may be on another lane. First cut: the manifest's own argv, no seat. -3. **Is `via` on the task or on the run?** A run is one store; a delegate is +2. **Is `via` on the task or on the run?** A run is one store; a delegate is one process that owns the whole tree for the hour. First cut: a delegated task is a run of one task, and the supervisor never splits it. Splitting a run between bash workers and a delegate is a later question. -4. **Trajectory from a foreign stream.** The task page assumes a step is a +3. **Trajectory from a foreign stream.** The task page assumes a step is a command and an observation. swe-pro's tool parts fit; its `stage` records do not. Either the page learns a "stage" row or the reader folds stages into the live step only and never into the trajectory. From 21e6d771ddaeab70c5923674ec3a8a9230eb8974 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:42:58 -0400 Subject: [PATCH 006/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20rewri?= =?UTF-8?q?tten=20for=20readability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisions up front in one table, short numbered sections, every "already exists" seam in a table, no content removed. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 513 ++++++++++++++------------------- 1 file changed, 221 insertions(+), 292 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 3ddab0b68..ebdfd3376 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -1,98 +1,93 @@ -# Delegates — handing a task to an outside harness — DESIGN (draft) +# Delegates — handing a task to an outside program — DESIGN (draft) -*2026-09-21, written against `dev @ 17ae56d34` and `swe-pro-go @ f3b9716` -(branch `zeropoint95/improvements`, PR #30). Status: a draft for discussion. -Nothing here is built on the codeaf side; the two swe-pro changes it asked for -have landed.* +*2026-09-21. Written against `dev @ 17ae56d34` and `swe-pro-go @ f3b9716` +(branch `zeropoint95/improvements`, PR #30). Nothing is built on the codeaf +side yet. The two swe-pro changes this asked for have landed.* -## The one sentence +## In one paragraph -A **delegate** is an outside program that can do a whole coding task on its -own; codeaf hands it a task the way it hands one to its own worker — in a -working copy of its own, under the conversation's dollar and time limits, -drawn on the rail while it runs, landed on the branch when it ends. A person -starts one with the program's own name as a command, `/swe-pro `, and -what that starts is a task; the program is one more **worker kind** behind -the run supervisor, never a second engine. +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: -`swe-pro` is the first delegate and the one this design ships. The -mechanism is meant to hold any number of them, added one manifest at a time -at a person's discretion; `codeaf do` itself would be one later, which is a -useful test that nothing here is swe-pro-shaped. +``` +/swe-pro rewrite the auth middleware to use the new session store +``` -## Why not the name "sub-harness" +That starts an ordinary **task**. It runs in its own working copy, under your +dollar and time limits, shows on the rail, can be stopped, and lands on your +branch when it ends. The chat is not blocked while it runs. Inside codeaf, a +delegate is one more **worker kind** behind the existing run supervisor. It is +not a second engine. -The word is taken, in code and in the manual. `internal/subharness`, -`/subharness`, `/harness`, `docs/SUBHARNESS.md` and the *Saved shapes of work* -pages all mean **a saved program built out of this binary's own node kinds** -(`agent.loop`, `tool.call`, `verify`, `human.gate`…), designed in the -conversation, stored under `~/.codeaf/harnesses//vN.json`, reached by -cue detection rather than by command. An outside binary that runs its own -agent loop is the opposite thing: codeaf designs nothing about it and cannot -see inside it. Calling both "sub-harness" would put two objects behind one -word in the manual, and the manual is what the chat answers from. +`swe-pro` is the first delegate. Others are added later, one manifest each, +at the person's discretion. -So: **delegate**. A person "delegates the auth rewrite to swe-pro". The manual -page is *Delegates — programs codeaf can hand a task to*. +## Decisions already taken -## The command: one word per delegate, and it starts a task +| 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 | +| swe-pro's `wip(edit)` commits | squashed into one commit at landing | 2026-09-21 | +| swe-pro control plane | optional. Landed in swe-pro `f3b9716` | 2026-09-21 | +| Live cost from swe-pro | a `stage: spend` record. Landed in swe-pro `f3b9716` | 2026-09-21 | +| 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 | + +## 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 | +| `/swe-pro` | an outside program | someone else | a child process in a working copy | -A person types the delegate's name as a command and the words after it are -the brief: +## The command -``` -/swe-pro rewrite the auth middleware to use the new session store -``` +`/swe-pro ` is `/task ` with the worker already chosen. -That row is `/task` with the worker chosen. It goes through the same door -(`startTaskRun`), on the same card the person answers before money moves, -and what it starts is the same object — a run in the tasks store, in a working -copy of its own, on the rail with a live step, stoppable, landed when it ends. -The turn ends when the card is answered; the person is not held for the hour. -The only difference from `/task` is the worker seated on the root task, and -that is the one word the row carries. - -**The command table is generated from the delegates this launch has.** A -manifest at `~/.codeaf/delegates/.json` whose binary resolves on PATH -puts one row `/ ` in `internal/tui3`'s command list, described -with the manifest's own sentence, so `/help` and the command picker list it -beside `/task` — and a machine with no swe-pro has no `/swe-pro`, rather than -one that says no. A delegate's name may not collide with a built-in command; -the loader refuses the manifest and says which row it collided with. - -Two things this deliberately is not: - -- **Not a turn that waits.** A swe-pro run is thirty to ninety minutes. A - turn that blocked on it would hold the conversation and the status line - hostage, and the engine's thirty-minute idle retirement (#1291) is written - on the assumption that long work is *work in the tree*, not a turn. The - model never watches the stream. When the run ends, the landing wakes a turn - — as it does for every task today — and that woken turn reads the terminal - record and the landing note, and answers. -- **Not only a command.** `propose_task` grows an optional `via` naming a - delegate, so the model can propose "this one is big enough for swe-pro" - from an ordinary turn. The person still answers the card. The system prompt - names the delegates this launch has, conditionally, the way it names - everything else (`HANDOFF_FACTS` in `beltfacts.go`). - -`/delegate` (bare) lists the delegates this machine has, with the binary each -resolved to and the last run's two words, the way `/subharness` lists -programs. It is the answer to "which of these do I have here". - -### Beside the two commands that already exist - -The manual gate will make the chat explain all three, so the line between -them is drawn here once: - -| command | what it starts | who wrote the program | where it runs | -| --- | --- | --- | --- | -| `/harness` | a **saved shape of work**: a small program of this binary's own node kinds (`agent.loop`, `tool.call`, `verify`, `human.gate`…) that a designer model built in a conversation and saved to `~/.codeaf/harnesses//vN.json` | codeaf, at a person's request | inside this process, on this conversation's own belt | -| `/subharness` | the same list plus the bundles on disk and the built-ins, opened through an **intake card** with typed fields | codeaf, or a bundle author | inside this process | -| `/swe-pro` (a delegate) | an **outside binary** doing a whole task on its own | someone else, and codeaf cannot see inside it | a child process in a working copy, under the run supervisor | +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 swe-pro'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 swe-pro on the machine means +no `/swe-pro` row. A name that collides with a built-in command or alias is +refused, naming the row. -## What already exists, and where this plugs in +**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. -The run engine's worker contract is one method: +**`/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 @@ -100,62 +95,49 @@ type Worker interface { Run(ctx context.Context, task plandb.Task) (Report, error) } type Report struct { Result string; Steps int; USD float64; Waiting bool } -type WorkerFactory func(task plandb.Task) Worker ``` -Everything the person sees and every limit they set already reaches a worker -through the supervisor: the cost ceiling and the elapsed ceiling -(`run.Limits`), the spend bank a 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`), the -working copy cut per run (`task_run_copy.go`), landing and merge -(`run.Land`, `landBeltRun`), the stop road (`session.Cancel` with a kind, and -`stoplaw_test.go` proving every running row can be stopped), and the fold of -the run's dollars into the conversation's total (`driveBeltRun`'s -`foldSpend`, #1280). - -**A delegate is a second implementation of `run.Worker`.** `CrewFactory` -already chooses a worker per task by role; it grows one more branch: a task -whose row names a delegate gets a `delegate.Worker` instead of a -`BashWorker`. Nothing above the factory changes. - -The floor to measure against is what works today with no change at all: the -model runs `swe-pro run …` through the `bash` tool with `background: true`. -That gives a job with a ring-buffer log and an exit notice on the owed lane — -and no working copy, no landing, no dollar limit, no rail row, no cost in -`/cost`, and a model that has to poll the job to find out. That gap is the -whole of what this design pays for. - -## The delegate contract - -codeaf asks five things of a program before it will hand it a task. They are -written as a manifest, one per delegate, and the manifest is the whole of -what codeaf knows about the program. - -| the program must | swe-pro today | `codeaf do` today | -| --- | --- | --- | -| **launch** from argv with the task as text, a working directory, a dollar ceiling and a wall ceiling | `swe-pro run --dir D --max-cost X --max-hours H -- "goal"` | `codeaf do --workspace D --max-cost X -timeout H --json "brief"` | -| **stream** its progress as one JSON object per line on stdout and nothing else | yes (EVENTS-CONTRACT.md) | no — stdout is the result only; progress is prose on stderr | -| **end** with exactly one terminal record carrying a status word, a reason and `cost_usd` | yes, `{"type":"terminal",…}` | the `--json` envelope, one object, on exit | -| **stop** cleanly on SIGTERM, still writing its terminal record | yes; SIGKILL is the only way to lose it | yes, the exit ladder | -| **leave its work in the tree** it was given, as commits or a dirty tree, and nothing that is not its work | eager `wip(edit)` commits; `.swe-pro/` git-excluded; `refs/swe-pro/*` | commits on a branch it names in the envelope | - -Two things codeaf does **not** ask, and says so on the page: - -- **Questions.** A delegate cannot ask the person anything. swe-pro's - `question` tool is auto-rejected inside the binary and there is no stdin - road; codeaf's own headless door exits `4 needed an answer` for the same - reason. The task brief has to be self-sufficient, and the page says so in - those words. -- **A step cap.** swe-pro has cost and hours and nothing per step. - `Limits.StepsPerTask` is not handed down, and the task page's step count is - whatever the adapter can read off the stream (tool parts for swe-pro, - nothing for `codeaf do`). +| 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 `swe-pro 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 | swe-pro today | +| --- | --- | +| **launch** from argv with brief, directory, dollar ceiling, wall ceiling | `swe-pro 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. `.swe-pro/` is git-excluded | + +Two things codeaf does **not** ask, and the manual page says so: + +- **No questions.** swe-pro auto-rejects its own `question` tool and has no + stdin road. Write the brief so nobody needs to be asked. +- **No step cap.** swe-pro 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/swe-pro.json — read at launch; absent binary = absent delegate +// ~/.codeaf/delegates/swe-pro.json { "name": "swe-pro", // also the command: /swe-pro "description": "an autonomous coding agent for one large, well-specified change", @@ -163,198 +145,145 @@ Two things codeaf does **not** ask, and says so on the page: "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], - "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" }, // no plane needed: swe-pro runs standalone - "reader": "swe-pro", // which stream reader (below) + "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" }, + "reader": "swe-pro", "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } } ``` -`{{key:openrouter}}` is resolved through `config.APIKeyAt`, the same door -every lane and the e2e suite resolve a key through, so a key pasted into -first-run setup reaches the delegate (#576 is the lesson). A manifest whose -`bin` is not on PATH means the delegate is not offered — A CAPABILITY THAT -CANNOT WORK IS ABSENT, NOT BROKEN — and `/delegate` draws one dim line naming -the binary it looked for. +- `{{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 readers +### The reader -A reader turns the program's stream into the three things the supervisor -wants while the program runs — a rising dollar figure, a live step sentence, -a trajectory line — and, at the end, a `Report` and an outcome word. Readers -are Go, in the binary, one per stream shape; a manifest names one. One ships: +A reader turns the program's stream into what the supervisor wants: a rising +dollar figure, a live step sentence, trajectory lines, and at the end a +`Report` and an outcome word. Readers are Go, in the binary, one per stream +shape. One ships now. -**`swe-pro`** reads EVENTS-CONTRACT.md: +**The swe-pro reader:** -| stream | becomes | +| stream record | becomes | | --- | --- | -| `stage`/`status` compact records | the live step (`implement · running`, `verification · pass`) and one trajectory line each | -| `message.part.updated` with `part.type == "tool"` reaching `completed`/`error` | a trajectory step: the tool and its command, the observation head; `Steps` counts these | -| `stage == "spend"`, `status == "recorded"` | banked spend: `data.cost_usd`, cumulative for the whole run and non-decreasing, one per completed assistant message (coder and compaction). NEVER sum `cost` off `message.updated`: an assistant message is written more than once and a naive sum double-counts, which is why this record exists | -| `terminal` | the `Report`: `Result` from `message` plus `data.reason` and `data.submission_reason`; `USD` from `data.cost_usd`; the outcome word from `status` | -| process exit with no terminal read | `ran and did not finish`, with the last stage seen in the result | +| `stage` records | the live step, e.g. `implement · running`, and one trajectory line each | +| `message.part.updated`, `part.type == "tool"`, state `completed` or `error` | one trajectory step: tool, command, observation head. `Steps` counts these | +| `stage == "spend"`, `status == "recorded"` | banked spend from `data.cost_usd`, cumulative and non-decreasing | +| `terminal` | the `Report`: result from `message`, `data.reason`, `data.submission_reason`; `USD` from `data.cost_usd`; outcome from `status` | +| process exit with no terminal seen | `ran and did not finish`, naming the last stage seen | + +**Never sum `cost` off `message.updated`.** An assistant message is written +more than once, so a naive sum double-counts. The `spend` record exists for +exactly this reason. -The outcome mapping, written once beside the reader: +**Outcome mapping:** | swe-pro `terminal.status` | run outcome | rail word | | --- | --- | --- | -| `pass` (`data.status` pass or pass-unverified) | done | done | -| `fail` (`data.status` fail or unsubmitted) | ran and did not finish | incomplete | +| `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 | -The model's claim and the harness's own observation stay separate fields in -swe-pro's record and they stay separate in the landing note: *swe-pro says it -submitted; its verification failed 2 of 5 commands* is two sentences, never -one. - -**`codeaf`** (later, not this wave) would read the `--json` envelope on exit -and the stderr quiet line while it runs, so that `codeaf do` on a second -machine is a delegate with no new machinery. +swe-pro keeps the model's claim and its own observation as separate fields. +The landing note keeps them separate too: *swe-pro says it submitted; its +verification failed 2 of 5 commands* is two sentences. ### Money -The delegate spends the person's key outside codeaf's provider ledger. The -supervisor's bank (`bankLive`, `countLiveSpend`) sees every rising figure the -reader hands it and cuts the run at the ceiling the way it cuts a bash worker -— by cancelling the context, which sends SIGTERM, which lets swe-pro write its -terminal record. The conversation's total, `/cost` and the status line move -through `foldSpend` as they do for any run (#1280). The machine's usage ledger -on disk does **not** get swe-pro's lines, because those calls were not made -through a codeaf lane; the spending page says `via swe-pro` on the row rather -than pretending otherwise. +1. swe-pro 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 swe-pro 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 swe-pro's calls, because they + did not go through a codeaf lane. The spending page says `via swe-pro`. -The dollar ceiling handed to the program is what is left of the smaller of -the conversation's limits (`runCostLeft`, #1281), passed on the command line -so the program cuts itself before codeaf has to. +The ceiling passed on the command line is what is left of the smaller of the +conversation's limits (`runCostLeft`, #1281), so swe-pro cuts itself first. ### Stopping -`stop` on the row is `session.Cancel` with a new kind, `delegate`, and it goes -on the ledger in `stoplaw_test.go` with its proving test, like every other -kind. The worker terminates the process group (`internal/processgroup`), waits -the job grace (`jobTermGrace`), then kills. A terminal record that arrives -inside the grace is read and folded; one that does not leaves the row -`stopped` with the last stage seen. +`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 -The working copy is the run's, cut as it is today. swe-pro is given it as -`--dir`. When the program ends, `run.Land` commits what is in the tree and -`landBeltRun` merges it home exactly as for a bash worker. Two swe-pro -particulars the landing has to know: - -- **swe-pro's commits are squashed at landing.** swe-pro commits every - `edit` and `write` as it happens (`wip(edit): `, hooks bypassed), so - an hour's run leaves dozens of bookkeeping commits in the copy. Decided - 2026-09-21: the delegate landing folds everything the copy's branch holds - past the cut point into **one commit**, then merges that home the way every - task lands. The commit's subject is the task's title; its body is swe-pro's - terminal record in two sentences — what the model claimed - (`submission_reason`) and what swe-pro observed (`status`, - `verification_failing`). The eager commits are not turned off - (`SWE_PRO_EAGER_COMMIT=0`), because swe-pro's own crash recovery and its - restore-after-ship read those file-level checkpoints; they are swe-pro's - business inside the copy and nobody's outside it. -- `.swe-pro/` is in `.git/info/exclude` of the copy, so it never lands, and - `refs/swe-pro/start` and `refs/swe-pro/submitted` die with the copy. - -### What the model is told - -`propose_task` grows an optional `via` field naming a delegate, and the -system prompt's `HANDOFF_FACTS` names the delegates this launch has, in the -same conditional way it names everything else (`beltfacts.go`): a build with -no manifest and no binary says nothing about delegates at all. The fact says -when to choose one — *a change big enough to want its own agent for an hour, -specified well enough that nobody will be asked anything* — and the model -proposes it on the same card `/task` shows, with `swe-pro` named on the card, -so the person still answers before money moves. - -## What swe-pro changed for this (landed 2026-09-21, `f3b9716`, PR #30) - -1. **The control plane is optional.** A reachable plane is mirrored onto as - before; an unreachable one costs one stderr line — - `[swe-pro] no AgentField control plane at (…); running standalone, - events go to stdout only` — and the run proceeds. The `run-contract` record - carries `"control_plane": {"enabled": false, "url": ""}`. - `swe-pro serve` still requires a plane, which is right: serve is a node. - The manifest therefore sets no `SWE_PRO_CP_*` variable at all. -2. **A live spend record.** `{"type":"stage","stage":"spend","status":"recorded","data":{"cost_usd":0.0213},…}`, - one per completed assistant message, cumulative and non-decreasing, - compaction included. `agent-summary` and `terminal` still carry the - authoritative end-of-run totals; `spend` is the one the live limit reads. -3. **No question road.** Decided: a delegate does not ask. swe-pro keeps - auto-rejecting `question`, and the manual page says the brief has to be - self-sufficient. +1. swe-pro works in the run's own copy, passed as `--dir`. +2. swe-pro commits every edit as it goes: `wip(edit): `, dozens per run. + These stay on inside the copy, because swe-pro'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 swe-pro observed. +4. That one commit merges home the way every task lands. +5. `.swe-pro/` is git-excluded in the copy and never lands. `refs/swe-pro/*` + 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 /swe-pro do" from swe-pro's own page. +3. **The check runs at load.** A page that does not mention `/` refuses + the manifest. `/delegate` shows why: `swe-pro: its manual page does not say + /swe-pro — 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 /swe-pro here". It never names a delegate + the build cannot promise exists. + +## What swe-pro changed for this + +Landed 2026-09-21 as `f3b9716` on `zeropoint95/improvements`, PR #30. + +1. **Control plane optional.** Reachable: mirrored as before. Unreachable: one + stderr line, and the run proceeds. The `run-contract` record carries + `"control_plane": {"enabled": false, "url": ""}`. `swe-pro + serve` still requires a plane. The manifest sets no `SWE_PRO_CP_*` variable. +2. **Live spend record.** `{"type":"stage","stage":"spend","status":"recorded","data":{"cost_usd":0.0213}}`, + one per completed assistant message, cumulative, compaction included. +3. **No question road**, by decision. Auto-reject stays. Checked by the swe-pro side against its code: the outcome table above holds, -SIGTERM unwinds through the normal path and still writes the terminal record, -and `--` before the goal parses. The model's claim (`submission_reason`, -`submission_evidence`, `checklist_satisfied`) and swe-pro's own observation -(`status`, `verification_failing`, `patch_bytes`) are separate fields and are -never reconciled; "did it actually work" is the latter. - -## Generated commands and the manual law - -Delegates are added at a person's discretion, one manifest each, and the -command row exists only while its delegate does. So the command table cannot -be a closed list the binary knows at build time, and the manual law — every -command the chat offers is explained in the corpus the chat answers from — -cannot be met by a page compiled into every binary. The law stays; where it -is enforced moves. - -- **The static table keeps its static gate.** `internal/tui3`'s `commands` - is unchanged and `TestTheManualMentionsEveryCommandTheTableOffers` still - walks it. Delegate rows are not in it: the surface reads them off the - delegate registry at launch and appends them to the live list (`/help`, - the picker, the fuzzy matcher), so nothing generated is ever tested by a - law that reads a Go literal. -- **The manifest carries its own page.** A delegate ships `manual.md` beside - its manifest: what it does, how to ask it, what it cannot do, what a run - costs, where the work lands — the same rules every page in - `internal/manual/chat/` follows, `## ` headings and all. At launch the - chat's corpus is the packed corpus **plus an overlay** of the installed - delegates' pages (`manual.Corpus` gains one constructor that layers pages - over another corpus; search, `Mentions` and `Page` read both). The - `manual` tool then answers "what does /swe-pro do" from swe-pro's own page. -- **The law is checked at load.** A manifest whose page does not mention - `/` is refused, and the refusal is drawn where `/delegate` lists the - rest: `swe-pro: its manual page does not say /swe-pro — not added`. That is - the compile-time gate, moved to the moment the command comes into - existence, with the same sentence shape the gate prints. -- **One built-in page explains the family.** *Delegates — programs codeaf - can hand a task to* is in the packed corpus, mentions `/delegate`, and is - where "what is a delegate", "how do I add one", "why is there no /swe-pro - on this machine" are answered. It never names a delegate the build cannot - promise exists. - -A delegate's name may not collide with a built-in row or an alias; the loader -refuses it and names the row. - -## What has to change in codeaf +SIGTERM still writes the terminal record, and `--` before the goal parses. + +## Waves | # | lands | proof | | --- | --- | --- | -| **1** | `internal/delegate`: the manifest and its loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM-then-kill, `Report`); the `swe-pro` reader | unit tests against a fake binary that emits scripted NDJSON and honours SIGTERM; the outcome table pinned | -| **2** | the door: `plandb` task row carries `via`; `CrewFactory` branches on it; the generated `/ ` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the cancel kind and its ledger line; the squash-then-merge landing and its two-sentence note; the spend row's `via` | focused `internal/session` and `internal/tui3` tests; the static manual gates untouched | -| **3** | the manual: the built-in *Delegates* page (what one is, how to add one, what it cannot do — no questions, no step cap — the refusals verbatim); the corpus overlay and the load-time page check; swe-pro's own `manual.md` shipped beside its manifest | `internal/manual/chat_test.go` probes in a person's words: "can you hand this to swe-pro", "what does /swe-pro do", "delegate this", "why can't the delegate ask me", "what is the difference between /harness and /swe-pro" | -| **4** | hosted: the row crosses `internal/remote` (`PlanTaskRow` already carries `Live` and `TrajectoryPath`, so this is mostly the `via` word); until then a `--host` session refuses with one sentence, the way `/subharness` does | `internal/remote` wire tests | -| later | a `codeaf` reader so `codeaf do` on another machine is itself a delegate; the model chooses a delegate by seat (`worker` seat → swe-pro for `work` leaves, a crew row); answering a delegate's question | — | +| **1** | `internal/delegate`: manifest and loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM then kill, `Report`); the swe-pro reader | unit tests against a fake binary emitting scripted NDJSON and honouring SIGTERM; the outcome table pinned | +| **2** | the door: task row carries `via`; `CrewFactory` branches on it; generated `/` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the `delegate` cancel kind; squash-then-merge landing; `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; swe-pro's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to swe-pro", "what does /swe-pro do", "why can't the delegate ask me", "difference between /harness and /swe-pro" | +| **4** | hosted: the row crosses `internal/remote`; until then `--host` refuses with one sentence | `internal/remote` wire tests | +| later | a `codeaf` reader so `codeaf do` on another machine is a delegate; delegates chosen by crew seat; answering a delegate's question | — | -Wave 1 has no door and spends no money; it is the contract, proven against a -stub. Wave 2 is the first thing a person can type. +Wave 1 has no door and spends no money. Wave 2 is the first thing a person +can type. ## Open questions -1. **Who picks the delegate's models.** swe-pro's `--high` pool is its own - default today. The manifest could pass the conversation's work seat - (`{{model:work}}`) so `/crew` governs the delegate too — but swe-pro speaks - OpenRouter slugs and codeaf's seat may be on another lane. First cut: the - manifest's own argv, no seat. -2. **Is `via` on the task or on the run?** A run is one store; a delegate is - one process that owns the whole tree for the hour. First cut: a delegated - task is a run of one task, and the supervisor never splits it. Splitting - a run between bash workers and a delegate is a later question. -3. **Trajectory from a foreign stream.** The task page assumes a step is a - command and an observation. swe-pro's tool parts fit; its `stage` records - do not. Either the page learns a "stage" row or the reader folds stages - into the live step only and never into the trajectory. +1. **Who picks swe-pro's models.** Today its own `--high` default. The manifest + could pass codeaf's work seat, but swe-pro speaks OpenRouter slugs and the + seat may be on another lane. First cut: the manifest's argv, no seat. +2. **Is `via` on the task or the run.** First cut: a delegated task is a run of + one task and is never split. Mixing bash workers and a delegate in one run + is a later question. +3. **Stage records on the task page.** The page expects a step to be a command + and an observation. swe-pro's tool parts fit; its `stage` records do not. + Either the page learns a stage row, or stages feed the live step only. From 83829f4a16ecb91cfa3d13dee2d04bccc2e63220 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:16:57 -0400 Subject: [PATCH 007/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20one?= =?UTF-8?q?=20generic=20reader=20over=20a=20four-record=20protocol;=20text?= =?UTF-8?q?-landing=20delegates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner's call 2026-09-21: no per-program readers. stage, spend, step and terminal on stdout, everything else ignored; swe-pro already conforms bar two optional asks, and a delegate may land text instead of a tree. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 72 ++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index ebdfd3376..d9b876b8e 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -34,6 +34,8 @@ at the person's discretion. | swe-pro control plane | optional. Landed in swe-pro `f3b9716` | 2026-09-21 | | Live cost from swe-pro | a `stage: spend` record. Landed in swe-pro `f3b9716` | 2026-09-21 | | 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 | ## Why not "sub-harness" @@ -146,7 +148,7 @@ Two things codeaf does **not** ask, and the manual page says so: "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" }, - "reader": "swe-pro", + "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 } } ``` @@ -157,40 +159,60 @@ Two things codeaf does **not** ask, and the manual page says so: draws one dim line naming the binary it looked for. - A `manual.md` ships beside the manifest. See *The manual law* below. -### The reader +### The protocol, and the one reader -A reader turns the program's stream into what the supervisor wants: a rising -dollar figure, a live step sentence, trajectory lines, and at the end a -`Report` and an outcome word. Readers are Go, in the binary, one per stream -shape. One ships now. +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: swe-pro's bus +payloads pass straight through it. -**The swe-pro reader:** - -| stream record | becomes | -| --- | --- | -| `stage` records | the live step, e.g. `implement · running`, and one trajectory line each | -| `message.part.updated`, `part.type == "tool"`, state `completed` or `error` | one trajectory step: tool, command, observation head. `Steps` counts these | -| `stage == "spend"`, `status == "recorded"` | banked spend from `data.cost_usd`, cumulative and non-decreasing | -| `terminal` | the `Report`: result from `message`, `data.reason`, `data.submission_reason`; `USD` from `data.cost_usd`; outcome from `status` | -| process exit with no terminal seen | `ran and did not finish`, naming the last stage seen | - -**Never sum `cost` off `message.updated`.** An assistant message is written -more than once, so a naive sum double-counts. The `spend` record exists for -exactly this reason. +| 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 | -**Outcome mapping:** +`terminal.status` is a closed set, and it is swe-pro's: -| swe-pro `terminal.status` | run outcome | rail word | +| `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:** + +- **swe-pro** already emits `stage` and `terminal` in this shape, and its + status set is the protocol's. Two small asks, both optional: move `spend` + from `{"type":"stage","stage":"spend"}` to `{"type":"spend"}` (until then the + reader accepts both spellings, one line), and emit a `step` record per + tool call so the task page shows steps rather than stages. +- **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 swe-pro'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. + swe-pro keeps the model's claim and its own observation as separate fields. The landing note keeps them separate too: *swe-pro 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` (swe-pro) | 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. swe-pro spends the person's key outside codeaf's provider ledger. @@ -212,7 +234,7 @@ conversation's limits (`runCostLeft`, #1281), so swe-pro cuts itself first. 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 +### Landing a `tree` delegate 1. swe-pro works in the run's own copy, passed as `--dir`. 2. swe-pro commits every edit as it goes: `wip(edit): `, dozens per run. @@ -267,11 +289,11 @@ SIGTERM still writes the terminal record, and `--` before the goal parses. | # | lands | proof | | --- | --- | --- | -| **1** | `internal/delegate`: manifest and loader; `Worker` (spawn under `processgroup`, stream to reader, SIGTERM then kill, `Report`); the swe-pro reader | unit tests against a fake binary emitting scripted NDJSON and honouring SIGTERM; the outcome table pinned | -| **2** | the door: task row carries `via`; `CrewFactory` branches on it; generated `/` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the `delegate` cancel kind; squash-then-merge landing; `via` on the spend row | focused `internal/session` and `internal/tui3` tests | +| **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, 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 swe-pro stream replayed through the reader | +| **2** | the door: task row carries `via`; `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; swe-pro's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to swe-pro", "what does /swe-pro do", "why can't the delegate ask me", "difference between /harness and /swe-pro" | | **4** | hosted: the row crosses `internal/remote`; until then `--host` refuses with one sentence | `internal/remote` wire tests | -| later | a `codeaf` reader so `codeaf do` on another machine is a delegate; delegates chosen by crew seat; answering a delegate's question | — | +| 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. From dc7ff2fdc4d63f73fefd68d04ab2eea915cb623d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:47:11 -0400 Subject: [PATCH 008/195] =?UTF-8?q?design:=20delegates=20=E2=80=94=20swe-p?= =?UTF-8?q?ro=20speaks=20the=20protocol=20whole:=20top-level=20spend=20and?= =?UTF-8?q?=20a=20step=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swe-pro 5793499 landed both optional asks, output-only. The reader needs no compatibility spelling and the task page gets steps. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 45 ++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index d9b876b8e..aeffa013a 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -1,8 +1,8 @@ # Delegates — handing a task to an outside program — DESIGN (draft) -*2026-09-21. Written against `dev @ 17ae56d34` and `swe-pro-go @ f3b9716` -(branch `zeropoint95/improvements`, PR #30). Nothing is built on the codeaf -side yet. The two swe-pro changes this asked for have landed.* +*2026-09-21, revised 2026-09-22. Written against `dev @ 17ae56d34` and +`swe-pro-go @ 5793499` (branch `zeropoint95/improvements`, PR #30). Nothing is +built on the codeaf side yet. Every swe-pro change this asked for has landed.* ## In one paragraph @@ -32,7 +32,8 @@ at the person's discretion. | Questions from the delegate | none. The brief must be self-sufficient | 2026-09-21 | | swe-pro's `wip(edit)` commits | squashed into one commit at landing | 2026-09-21 | | swe-pro control plane | optional. Landed in swe-pro `f3b9716` | 2026-09-21 | -| Live cost from swe-pro | a `stage: spend` record. Landed in swe-pro `f3b9716` | 2026-09-21 | +| Live cost from swe-pro | a top-level `spend` record. Landed in swe-pro `5793499` | 2026-09-22 | +| Steps from swe-pro | a `step` record per finished tool call. Landed in swe-pro `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 | @@ -189,11 +190,11 @@ program itself saw), `deliverable` (the answer text, for `"lands": "text"`). **What this costs each program:** -- **swe-pro** already emits `stage` and `terminal` in this shape, and its - status set is the protocol's. Two small asks, both optional: move `spend` - from `{"type":"stage","stage":"spend"}` to `{"type":"spend"}` (until then the - reader accepts both spellings, one line), and emit a `step` record per - tool call so the task page shows steps rather than stages. +- **swe-pro** 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. @@ -272,15 +273,23 @@ it is enforced moves. ## What swe-pro changed for this -Landed 2026-09-21 as `f3b9716` on `zeropoint95/improvements`, PR #30. - -1. **Control plane optional.** Reachable: mirrored as before. Unreachable: one - stderr line, and the run proceeds. The `run-contract` record carries - `"control_plane": {"enabled": false, "url": ""}`. `swe-pro - serve` still requires a plane. The manifest sets no `SWE_PRO_CP_*` variable. -2. **Live spend record.** `{"type":"stage","stage":"spend","status":"recorded","data":{"cost_usd":0.0213}}`, - one per completed assistant message, cumulative, compaction included. -3. **No question road**, by decision. Auto-reject stays. +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": ""}`. + `swe-pro serve` still requires a plane. The manifest sets no `SWE_PRO_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 swe-pro 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 swe-pro side against its code: the outcome table above holds, SIGTERM still writes the terminal record, and `--` before the goal parses. From 9621d37f32cc8dea170123ee2a81fc9c0d9c8808 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:53:32 -0400 Subject: [PATCH 009/195] docs: the delegate protocol, version 1, with swe-pro's conformance table The interface a program meets to be a codeaf delegate, standalone: launch, the four stdout records, the terminal, SIGTERM, tree or text, the manifest and its manual page. Co-Authored-By: Claude Fable 5.1 --- docs/DELEGATE-PROTOCOL.md | 161 +++++++++++++++++++++++++++++++++ docs/design/delegate/DESIGN.md | 2 +- 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 docs/DELEGATE-PROTOCOL.md diff --git a/docs/DELEGATE-PROTOCOL.md b/docs/DELEGATE-PROTOCOL.md new file mode 100644 index 000000000..e410d47f0 --- /dev/null +++ b/docs/DELEGATE-PROTOCOL.md @@ -0,0 +1,161 @@ +# The delegate protocol + +*Version 1, 2026-09-22. What a program must do to be a codeaf delegate. The +design behind it is `docs/design/delegate/DESIGN.md`. Conforming today: +`swe-pro` (`zeropoint95/improvements @ 5793499`).* + +A delegate is an outside program codeaf hands one task to. codeaf starts it, +reads its stdout, stops it when a limit is hit, and takes its result. This page +is the whole interface. If a program does what is written here, a manifest +and a manual page beside it are all codeaf needs. + +## 1. Launch + +codeaf runs the program once per task, as a child process, with: + +| handed over | how | +| --- | --- | +| the brief, as text | an argv slot, `{{brief}}` | +| a directory to work in | an argv slot, `{{workspace}}` (absolute) | +| a dollar ceiling | an argv slot, `{{cost_usd}}` | +| a wall-clock ceiling in hours | an argv slot, `{{hours}}` | +| API keys | environment, resolved by codeaf from the person's profile | + +The program must: + +1. Take all of these on the command line or in the environment. There is no + stdin. Nothing is written to it and nothing is read from it. +2. Treat the ceilings as its own limits and stop itself when it reaches one. + codeaf also enforces them from outside, but a program that cuts itself + first ends cleanly and keeps its result. +3. Start with no interactive step. It cannot ask anything. + +## 2. Stream + +stdout carries **one JSON object per line and nothing else**. stderr is the +program's own; codeaf keeps it for a person to read and never parses it. + +Four record types are read. **Any other line is ignored**, so a program may +put whatever else it likes on stdout as long as every line is a JSON object. + +| record | fields | meaning | +| --- | --- | --- | +| `{"type":"stage","stage":S,"status":T}` | `stage`, `status`: short strings | the live step shown on the rail, `S · T`. Emit on every phase change | +| `{"type":"spend","cost_usd":C}` | `cost_usd`: number, **cumulative for the whole run, never decreasing** | what the run has cost so far. Emit after every model call. Emit even at zero | +| `{"type":"step","command":X,"observation":Y}` | `command`: one line, ≤ 200 bytes; `observation`: optional, ≤ 2048 bytes, valid UTF-8 | one row on the task page. Emit once per finished tool call or action. Optional: a program with none is drawn by its stages | +| `{"type":"terminal","status":U,"message":M,"data":{…}}` | see §3 | the result. **Exactly one, and the last record** | + +Every record may carry `"ts"`: unix milliseconds. Extra fields are ignored. + +## 3. Terminal + +The terminal record is the result. codeaf reads it and nothing else for the +verdict, and it does not read the exit code for the verdict. + +```json +{"type":"terminal","status":"pass","message":"submitted and verified", + "data":{"cost_usd":0.42,"reason":"…","claim":"…","observed":"…","deliverable":"…"}} +``` + +| field | required | values | +| --- | --- | --- | +| `status` | yes | `pass`, `fail`, `budget-exhausted`, `crashed` | +| `message` | yes | one sentence saying why | +| `data.cost_usd` | yes | the final total. Must be ≥ the last `spend` | +| `data.reason` | no | a longer reason | +| `data.claim` | no | what the program's model said it did | +| `data.observed` | no | what the program itself verified. Kept separate from `claim`, never merged | +| `data.deliverable` | for `lands: text` | the answer text | + +What codeaf makes of `status`: + +| `status` | rail word | meaning | +| --- | --- | --- | +| `pass` | done | the work stands | +| `fail` | incomplete | it ran and the work does not stand | +| `budget-exhausted` | stopped, naming the limit | a ceiling was reached before it passed | +| `crashed` | incomplete | the program itself failed | + +A process that exits with no terminal record is read as `incomplete`, with +the last `stage` seen as the reason. **Emit the terminal on every path**, +including error and signal. + +## 4. Stop + +codeaf sends **SIGTERM** to the process group when a limit is hit or a person +presses stop, then waits a grace period, then SIGKILL. + +The program must, on SIGTERM: + +1. Stop starting new work. +2. Write its terminal record, with the true `status` and `cost_usd`. +3. Exit. + +A terminal record inside the grace is kept. After SIGKILL nothing is read. + +## 5. Result on disk + +The manifest says which of two things the program produces. + +| `lands` | the program must | codeaf then | +| --- | --- | --- | +| `tree` | leave its changes in `{{workspace}}`, as commits on the current branch or as a dirty tree, and **nothing that is not its work** (its own state files git-excluded or outside the tree) | squashes everything past the cut point into one commit and merges it home | +| `text` | change nothing in `{{workspace}}`; put the answer in `data.deliverable` | folds the text into the conversation | + +## 6. What the program may not do + +- Ask a question and wait for an answer. There is nobody there. +- Read stdin. +- Write anything to stdout that is not a JSON object on its own line. +- Exit before writing the terminal record, except when killed. +- For `lands: tree`, touch files outside `{{workspace}}`. + +## 7. The manifest and the manual page + +Two files in `~/.codeaf/delegates/`: + +```jsonc +// swe-pro.json +{ + "name": "swe-pro", // also the command: /swe-pro + "description": "an autonomous coding agent for one large, well-specified change", + "bin": "swe-pro", // on PATH, or a path + "argv": ["run", "--dir", "{{workspace}}", + "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", + "--", "{{brief}}"], + "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" }, + "lands": "tree", + "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } +} +``` + +- `name` is one lowercase word and becomes the command. It may not collide + with a built-in command or alias. +- `{{key:}}` is filled from the person's profile. +- A `bin` not found means the delegate is not offered. Nothing fails. + +`swe-pro.md` beside it is the delegate's manual page: what it does, how to +ask it, what it cannot do, what a run costs, where the work lands. It follows +the rules of `internal/manual/chat/` pages and **must mention `/`**. A +page that does not refuses the manifest. + +## 8. Conformance: swe-pro + +| requirement | swe-pro | +| --- | --- | +| launch from argv | `swe-pro run --dir D --max-cost X --max-hours H -- "goal"` | +| no stdin, no questions | nothing reads stdin; `question` is auto-rejected | +| stdout is JSON lines only | yes, EVENTS-CONTRACT.md | +| `stage` | yes, thirteen stages | +| `spend`, cumulative, top-level | yes, since `5793499` | +| `step` per tool call | yes, since `5793499` | +| exactly one `terminal`, last, on every path | yes, including crash and signal | +| `status` set | `pass`, `fail`, `budget-exhausted`, `crashed`, exactly | +| SIGTERM writes the terminal | yes | +| `lands: tree`, own state excluded | `.swe-pro/` is in `.git/info/exclude`; `refs/swe-pro/*` stay in the copy | +| runs without a control plane | yes, since `f3b9716` | + +Not yet mapped on swe-pro's side: `data.claim` and `data.observed` are spelled +`submission_reason` / `submission_evidence` and `status` / +`verification_failing` in its `data`. The reader accepts swe-pro's spellings +for these two optional fields. diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index aeffa013a..00cb24dbf 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -298,7 +298,7 @@ SIGTERM still writes the terminal record, and `--` before the goal parses. | # | 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, 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 swe-pro stream replayed through the reader | +| **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 swe-pro stream replayed through the reader | | **2** | the door: task row carries `via`; `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; swe-pro's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to swe-pro", "what does /swe-pro do", "why can't the delegate ask me", "difference between /harness and /swe-pro" | | **4** | hosted: the row crosses `internal/remote`; until then `--host` refuses with one sentence | `internal/remote` wire tests | From d3b990ed56e412d1e65a905e948a978ebf6b9c3d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:47:18 -0400 Subject: [PATCH 010/195] delegate: the manifest, the loader, the one protocol reader, the launch, and the run's delegate worker Wave 1 of docs/design/delegate/DESIGN.md. internal/delegate is a leaf: a manifest beside its manual page, loaded from ~/.codeaf/delegates with the manual law checked at load; a reader over the four stdout records that ignores everything else; and a launch under its own process group that SIGTERMs on the context, waits a grace, then kills, keeping a terminal written on the way out. internal/run seats it as one more worker kind: stages feed the live step, step records enter the trajectory, spend reaches the run's bank, and the terminal record is the ending. Proven against a scripted program and a recorded swe-pro stream. No door reaches it yet. Co-Authored-By: Claude Fable 5.1 --- internal/delegate/delegate.go | 167 +++++++++++ internal/delegate/launch.go | 268 ++++++++++++++++++ internal/delegate/launch_test.go | 157 ++++++++++ internal/delegate/load.go | 219 ++++++++++++++ internal/delegate/load_test.go | 157 ++++++++++ internal/delegate/protocol.go | 264 +++++++++++++++++ internal/delegate/protocol_test.go | 155 ++++++++++ .../delegate/testdata/swe-pro-stream.ndjson | 19 ++ internal/run/delegateworker.go | 252 ++++++++++++++++ internal/run/delegateworker_test.go | 224 +++++++++++++++ 10 files changed, 1882 insertions(+) create mode 100644 internal/delegate/delegate.go create mode 100644 internal/delegate/launch.go create mode 100644 internal/delegate/launch_test.go create mode 100644 internal/delegate/load.go create mode 100644 internal/delegate/load_test.go create mode 100644 internal/delegate/protocol.go create mode 100644 internal/delegate/protocol_test.go create mode 100644 internal/delegate/testdata/swe-pro-stream.ndjson create mode 100644 internal/run/delegateworker.go create mode 100644 internal/run/delegateworker_test.go diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go new file mode 100644 index 000000000..b8fd7f4e6 --- /dev/null +++ b/internal/delegate/delegate.go @@ -0,0 +1,167 @@ +// Package delegate is the half of a delegate that everything else in the +// binary needs: what one IS (a manifest beside its manual page), how the +// installed ones are found, the stdout protocol every delegate speaks and the +// one reader over it, and the launch of the program as a child process that +// streams, stops on SIGTERM and ends with one terminal record. +// +// A DELEGATE IS AN OUTSIDE PROGRAM CODEAF HANDS A WHOLE TASK TO. codeaf designs +// nothing about it and cannot see inside it; it starts it in a working copy, +// reads its stdout, stops it when a limit is reached and takes its result. The +// contract is docs/DELEGATE-PROTOCOL.md, and this package is its +// implementation. What runs a delegate AS A WORKER of a run — the live step, +// the trajectory, the spend bank — is internal/run's, which builds on this +// package; nothing here knows what a task is. +// +// THIS PACKAGE IS A LEAF ON PURPOSE. The session door lists delegates and +// checks a name; the run engine seats one; neither may import the other, so +// what they share lives here and imports neither. +package delegate + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +// The two things a delegate can leave behind, named on its manifest. +const ( + // LandsTree is a delegate that works in the working copy it is given and + // leaves its changes there: codeaf squashes them into one commit and merges + // that home the way every task lands. + LandsTree = "tree" + // LandsText is a delegate that changes nothing in the copy 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" +) + +// The placeholders a manifest's argv and env may carry, filled at launch. They +// are spelled here once so the loader can refuse one this build does not know +// rather than hand a program a literal `{{typo}}`. +const ( + FillBrief = "{{brief}}" + FillWorkspace = "{{workspace}}" + FillCostUSD = "{{cost_usd}}" + FillHours = "{{hours}}" + // FillKey is the person's API key, resolved by the caller through the same + // door every lane resolves one (config.APIKeyAt). `{{key:openrouter}}` is + // accepted as the same thing, because that is how the protocol page spells + // it and a manifest copied from there must load. + FillKey = "{{key}}" +) + +// Manifest is one delegate as its manifest file states it. Every field a +// person writes is here; nothing is inferred from the binary. +type Manifest struct { + // Name is one lowercase word: the file's name, the command a person types + // (`/ `) and the word every row says out loud. + Name string `json:"name"` + // Description is one sentence saying what the delegate does, in a person's + // words. It is the command row's tail and the offer's second line. + Description string `json:"description"` + // Bin is the program: a bare name resolved on PATH or a path. + Bin string `json:"bin"` + // Argv is the argument list, with placeholders. It never includes the + // program itself. + Argv []string `json:"argv"` + // Env is what is added to the child's environment, with placeholders. The + // child also inherits the parent's environment. + Env map[string]string `json:"env,omitempty"` + // Lands is LandsTree or LandsText. Empty reads as LandsTree, because a + // delegate that edits a tree is the one this was built for. + Lands string `json:"lands,omitempty"` + // Limits says which bounds the program honours itself. They are recorded + // for the manual page and the offer; codeaf enforces cost and time from + // outside whatever they say. + Limits Limits `json:"limits,omitempty"` + + // Path is the manifest file this was read from, and ManualPath the page + // beside it. Both are the loader's, never the file's. + Path string `json:"-"` + ManualPath string `json:"-"` + // BinPath is the program as it resolved at load time. The loader fills it; + // a manifest whose Bin is not found is not in the registry at all. + BinPath string `json:"-"` +} + +// Limits is the manifest's own account of which bounds the program keeps. +type Limits struct { + Cost bool `json:"cost"` + Elapsed bool `json:"elapsed"` + Steps bool `json:"steps"` + Questions bool `json:"questions"` +} + +// nameShape is the one shape a name may have: lowercase letters, digits and +// single hyphens, starting with a letter. It is a command word, so it has to be +// something a person can type after a slash without quoting. +var nameShape = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +// knownFills is every placeholder the launch fills. A manifest naming any +// other `{{…}}` is refused at load, so a typo is a sentence to the person and +// not a literal handed to the program. +var knownFills = map[string]bool{ + FillBrief: true, FillWorkspace: true, FillCostUSD: true, FillHours: true, FillKey: true, "{{key:openrouter}}": true, +} + +var fillShape = regexp.MustCompile(`\{\{[^}]*\}\}`) + +// Validate says whether a manifest is one the launch can run, naming the first +// thing wrong with it in a sentence a person can act on. It does not touch the +// disk: whether the binary exists and whether the page is there are the +// loader's readings, made beside this one. +func (m Manifest) Validate() error { + if strings.TrimSpace(m.Name) == "" { + return errors.New("the manifest names no delegate: `name` is empty") + } + if !nameShape.MatchString(m.Name) { + return fmt.Errorf("%q is not a delegate name: one lowercase word, letters, digits and hyphens", m.Name) + } + if strings.TrimSpace(m.Description) == "" { + return fmt.Errorf("%s: `description` is empty, and it is what the command row says", m.Name) + } + if strings.TrimSpace(m.Bin) == "" { + return fmt.Errorf("%s: `bin` is empty, so there is nothing to run", m.Name) + } + if len(m.Argv) == 0 { + return fmt.Errorf("%s: `argv` is empty; it must at least carry %s", m.Name, FillBrief) + } + if !strings.Contains(strings.Join(m.Argv, "\x00"), FillBrief) { + return fmt.Errorf("%s: `argv` never says %s, so the task would never reach the program", m.Name, FillBrief) + } + switch m.Lands { + case "", LandsTree, LandsText: + default: + return fmt.Errorf("%s: `lands` is %q; it is %q or %q", m.Name, m.Lands, LandsTree, LandsText) + } + for _, arg := range m.Argv { + if err := checkFills(m.Name, arg); err != nil { + return err + } + } + for key, value := range m.Env { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("%s: `env` carries an empty variable name", m.Name) + } + if err := checkFills(m.Name, value); err != nil { + return err + } + } + return nil +} + +// checkFills refuses a placeholder the launch does not fill. +func checkFills(name, text string) error { + for _, fill := range fillShape.FindAllString(text, -1) { + if !knownFills[fill] { + return fmt.Errorf("%s: %s is not a placeholder this build fills (they are %s, %s, %s, %s and %s)", + name, fill, FillBrief, FillWorkspace, FillCostUSD, FillHours, FillKey) + } + } + return nil +} + +// LandsTree answers whether this delegate's work is a tree to land, which is +// the reading of an empty Lands too. +func (m Manifest) LandsTree() bool { return m.Lands == "" || m.Lands == LandsTree } diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go new file mode 100644 index 000000000..17bf4d509 --- /dev/null +++ b/internal/delegate/launch.go @@ -0,0 +1,268 @@ +package delegate + +// The launch: one delegate as a child process, in its own process group, its +// stdout read as the protocol 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/DELEGATE-PROTOCOL.md §1 and §4). + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "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: swe-pro 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 + +// Fills is what the launch puts in the manifest's placeholders. +type Fills struct { + Brief string + Workspace string + // CostUSD and Hours are the ceilings handed to the program. Zero means + // none, and a placeholder with no value is DROPPED together with the flag + // before it (see fill), because a program handed `--max-cost 0` may read + // that as a ceiling of nothing. + CostUSD float64 + Hours float64 + // Key is the person's API key, resolved by the caller. + Key string +} + +// Launch is one run of one delegate. +type Launch struct { + Manifest Manifest + Fills Fills + // 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 +} + +// 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) { + m := launch.Manifest + bin := m.BinPath + if bin == "" { + bin = m.Bin + } + argv, err := fill(m.Argv, launch.Fills, true) + if err != nil { + return Result{ExitCode: -1}, err + } + env := os.Environ() + for key, value := range m.Env { + filled, err := fill([]string{value}, launch.Fills, false) + if err != nil { + return Result{ExitCode: -1}, err + } + if len(filled) == 0 { + // A variable whose whole value was an empty fill is not set at all, + // so a program that reads "is it set" reads the truth. + continue + } + env = append(env, key+"="+filled[0]) + } + + cmd := exec.Command(bin, argv...) + cmd.Env = env + cmd.Dir = launch.Fills.Workspace + 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", m.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", m.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 } + +// fill replaces placeholders in argv. AN EMPTY CEILING DROPS ITS FLAG: an +// element that is exactly a ceiling placeholder with no value is removed, and +// so is the element before it when that element is a flag (`--max-cost`), so +// a program with no ceiling set is handed no `--max-cost` at all rather than a +// zero it might read as "spend nothing". dropFlags is off for env values, where +// there is no flag to drop and an empty fill leaves the variable unset. +func fill(argv []string, fills Fills, dropFlags bool) ([]string, error) { + values := map[string]string{ + FillBrief: fills.Brief, + FillWorkspace: fills.Workspace, + FillKey: fills.Key, + "{{key:openrouter}}": fills.Key, + } + if fills.CostUSD > 0 { + values[FillCostUSD] = strconv.FormatFloat(fills.CostUSD, 'f', -1, 64) + } else { + values[FillCostUSD] = "" + } + if fills.Hours > 0 { + values[FillHours] = strconv.FormatFloat(fills.Hours, 'f', -1, 64) + } else { + values[FillHours] = "" + } + out := make([]string, 0, len(argv)) + for _, arg := range argv { + if value, whole := values[arg]; whole && value == "" && (arg == FillCostUSD || arg == FillHours || arg == FillKey || arg == "{{key:openrouter}}") { + if dropFlags && len(out) > 0 && strings.HasPrefix(out[len(out)-1], "-") { + out = out[:len(out)-1] + } + continue + } + filled := arg + for fill, value := range values { + filled = strings.ReplaceAll(filled, fill, value) + } + if rest := fillShape.FindString(filled); rest != "" { + return nil, fmt.Errorf("%s is not a placeholder this build fills", rest) + } + out = append(out, filled) + } + return out, nil +} diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go new file mode 100644 index 000000000..26009ddff --- /dev/null +++ b/internal/delegate/launch_test.go @@ -0,0 +1,157 @@ +//go:build !windows + +package delegate + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeProgram is a shell script that behaves as a delegate: it writes its argv +// to the file FAKE_ARGS names, emits a stage, a spend and a step, then runs +// the body it was given. +func fakeProgram(t *testing.T, body string) Manifest { + 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`, + `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 Manifest{ + Name: "fake", + Description: "a fake delegate", + Bin: script, + BinPath: script, + Argv: []string{"run", "--dir", FillWorkspace, "--max-cost", FillCostUSD, "--max-hours", FillHours, "--", FillBrief}, + Env: map[string]string{"FAKE_KEY": FillKey}, + } +} + +func terminalLine(status, message string) string { + return `echo '{"type":"terminal","status":"` + status + `","message":"` + message + `","data":{"cost_usd":0.02}}'` +} + +func TestRunFillsTheArgvAndReadsTheTerminal(t *testing.T) { + m := 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{} + result, err := Run(context.Background(), Launch{ + Manifest: m, + Fills: Fills{Brief: "rewrite the thing", Workspace: workspace, CostUSD: 1.5, Hours: 0.25, Key: "sk-test"}, + StderrPath: stderr, + }, 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 := "run\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.spend[0] != 0.01 || sink.steps[0] != "bash: true→ok" { + t.Fatalf("sink = %+v", sink) + } +} + +func TestRunDropsACeilingFlagWhoseValueIsUnset(t *testing.T) { + m := fakeProgram(t, terminalLine("pass", "done")) + args := filepath.Join(t.TempDir(), "args") + t.Setenv("FAKE_ARGS", args) + workspace := t.TempDir() + if _, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: workspace}}, nil); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(args) + if string(got) != "run\n--dir\n"+workspace+"\n--\nb\n" { + t.Fatalf("argv =\n%s\nwant no --max-cost and no --max-hours at all", got) + } +} + +func TestRunAnswersNoTerminalWhenTheProgramExitsWithoutOne(t *testing.T) { + m := fakeProgram(t, "exit 3") + result, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}}, 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. + m := 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() + }() + result, err := Run(ctx, Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}, Grace: 5 * time.Second}, 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) { + m := fakeProgram(t, strings.Join([]string{ + `trap '' TERM`, + `sleep 30`, + }, "\n")) + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + started := time.Now() + result, err := Run(ctx, Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}, Grace: 200 * time.Millisecond}, 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) { + m := Manifest{Name: "gone", Bin: filepath.Join(t.TempDir(), "gone"), Argv: []string{FillBrief}} + _, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}}, nil) + if err == nil || !strings.Contains(err.Error(), "start gone") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/delegate/load.go b/internal/delegate/load.go new file mode 100644 index 000000000..dada5dad6 --- /dev/null +++ b/internal/delegate/load.go @@ -0,0 +1,219 @@ +package delegate + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/home" +) + +// dirName is the folder under the state root the manifests live in: one +// `.json` and one `.md` per delegate. +const dirName = "delegates" + +// Dir is where this machine's delegates are: `$CODEAF_HOME/delegates`, which +// is `~/.codeaf/delegates` for a person and a throwaway root under test. +func Dir() string { return filepath.Join(home.Dir(), dirName) } + +// Refusal is one manifest the loader would not admit, and why, in the sentence +// `/delegate` draws for it. A refusal is never an error to the caller: a +// registry with a bad file in it is still a registry, and the person is told +// which file and what is wrong rather than losing every delegate to one typo. +type Refusal struct { + // Name is the file's stem, which is what the person will look for. + Name string + Reason string +} + +func (r Refusal) String() string { return r.Name + ": " + r.Reason } + +// Absent is a manifest whose program is not on this machine. It is not a +// refusal — the file is fine — and it is not offered either: A CAPABILITY THAT +// CANNOT WORK IS ABSENT, NOT BROKEN. It is kept so `/delegate` can draw one dim +// line naming the binary it looked for. +type Absent struct { + Name string + Bin string +} + +func (a Absent) String() string { return a.Name + ": " + a.Bin + " is not on this machine" } + +// Registry is what one launch knows about the delegates installed here: the +// ones it can run, the ones whose program is missing, and the files it would +// not admit. It is read once at launch and never watched; a manifest added +// while codeaf runs is seen at the next launch, which the manual page says. +type Registry struct { + entries map[string]Manifest + absent []Absent + refusals []Refusal +} + +// Load reads every `.json` in dir. A missing directory is an empty +// registry and no error: most machines have no delegates. An error is only a +// directory that exists and cannot be read. +// +// THE LAW IS CHECKED HERE, at the moment the command comes into existence: a +// manifest whose manual page is missing or does not spell `/` is refused +// with the same shape of sentence the compile-time gate prints for a built-in +// command. The static command table keeps its static test; this is that test +// moved to load time for rows that cannot be in the table. +func Load(dir string) (*Registry, error) { + registry := &Registry{entries: map[string]Manifest{}} + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return registry, nil + } + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + stem := strings.TrimSuffix(entry.Name(), ".json") + manifest, err := readManifest(filepath.Join(dir, entry.Name())) + if err != nil { + registry.refusals = append(registry.refusals, Refusal{Name: stem, Reason: err.Error()}) + continue + } + if manifest.Name != stem { + registry.refusals = append(registry.refusals, Refusal{Name: stem, + Reason: fmt.Sprintf("the file is %s.json but the manifest says its name is %q; the two must agree", stem, manifest.Name)}) + continue + } + manifest.ManualPath = filepath.Join(dir, stem+".md") + if reason := checkManualPage(manifest); reason != "" { + registry.refusals = append(registry.refusals, Refusal{Name: stem, Reason: reason}) + continue + } + bin, err := resolveBin(manifest.Bin, dir) + if err != nil { + registry.absent = append(registry.absent, Absent{Name: stem, Bin: manifest.Bin}) + continue + } + manifest.BinPath = bin + registry.entries[manifest.Name] = manifest + } + sort.Slice(registry.absent, func(i, j int) bool { return registry.absent[i].Name < registry.absent[j].Name }) + sort.Slice(registry.refusals, func(i, j int) bool { return registry.refusals[i].Name < registry.refusals[j].Name }) + return registry, nil +} + +// readManifest parses and validates one file. +func readManifest(path string) (Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return Manifest{}, err + } + var manifest Manifest + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return Manifest{}, fmt.Errorf("the manifest does not parse: %v", err) + } + manifest.Path = path + if err := manifest.Validate(); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +// checkManualPage is the load-time manual law. The page must exist and must +// say the command, because the chat answers "what does / do" from it and +// nowhere else. +func checkManualPage(m Manifest) string { + page, err := os.ReadFile(m.ManualPath) + if errors.Is(err, os.ErrNotExist) { + return fmt.Sprintf("no manual page beside it — write %s.md saying what /%s does — not added", m.Name, m.Name) + } + if err != nil { + return "its manual page could not be read: " + err.Error() + } + if !strings.Contains(string(page), "/"+m.Name) { + return fmt.Sprintf("its manual page does not say /%s — not added", m.Name) + } + return "" +} + +// resolveBin finds the program. A name with no separator is looked up on +// PATH; a relative path is taken from the manifest's own directory, so a +// delegate can ship its binary beside its manifest; an absolute path is +// itself. Whatever is found must be a regular executable file. +func resolveBin(bin, dir string) (string, error) { + if !strings.ContainsRune(bin, os.PathSeparator) { + return exec.LookPath(bin) + } + if !filepath.IsAbs(bin) { + bin = filepath.Join(dir, bin) + } + info, err := os.Stat(bin) + if err != nil { + return "", err + } + if info.IsDir() || info.Mode()&0o111 == 0 { + return "", fmt.Errorf("%s is not an executable file", bin) + } + return bin, nil +} + +// Find answers the manifest for a name, and false when this machine has none +// by that name (including one that is absent or refused). +func (r *Registry) Find(name string) (Manifest, bool) { + if r == nil { + return Manifest{}, false + } + m, ok := r.entries[name] + return m, ok +} + +// Names is every runnable delegate, sorted, which is the order rows are drawn +// in. +func (r *Registry) Names() []string { + if r == nil { + return nil + } + names := make([]string, 0, len(r.entries)) + for name := range r.entries { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// All is every runnable manifest in Names order. +func (r *Registry) All() []Manifest { + names := r.Names() + all := make([]Manifest, 0, len(names)) + for _, name := range names { + all = append(all, r.entries[name]) + } + return all +} + +// Absent is the manifests whose program is not here, sorted by name. +func (r *Registry) Absent() []Absent { + if r == nil { + return nil + } + return append([]Absent(nil), r.absent...) +} + +// Refusals is the files the loader would not admit, sorted by name. +func (r *Registry) Refusals() []Refusal { + if r == nil { + return nil + } + return append([]Refusal(nil), r.refusals...) +} + +// Empty is a registry with nothing runnable, nothing absent and nothing +// refused: the machine has no delegates at all, which is most machines. +func (r *Registry) Empty() bool { + return r == nil || (len(r.entries) == 0 && len(r.absent) == 0 && len(r.refusals) == 0) +} diff --git a/internal/delegate/load_test.go b/internal/delegate/load_test.go new file mode 100644 index 000000000..d2656a9d0 --- /dev/null +++ b/internal/delegate/load_test.go @@ -0,0 +1,157 @@ +package delegate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeDelegate installs one delegate under dir: its manifest, its page, and a +// program that exists (an empty executable script) unless bin says otherwise. +func writeDelegate(t *testing.T, dir, name, manifest, page string) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name+".json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if page != "" { + if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(page), 0o644); err != nil { + t.Fatal(err) + } + } +} + +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) + } +} + +const goodManifest = `{ + "name": "fake", + "description": "a fake delegate for the tests", + "bin": "./fake.sh", + "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], + "env": {"FAKE_KEY": "{{key}}"}, + "lands": "tree" +}` + +func TestLoadAdmitsAManifestWithItsPageAndItsProgram(t *testing.T) { + dir := t.TempDir() + writeDelegate(t, dir, "fake", goodManifest, "# fake\n\n## /fake — what it does\n") + writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") + registry, err := Load(dir) + if err != nil { + t.Fatal(err) + } + m, ok := registry.Find("fake") + if !ok { + t.Fatalf("fake is not in the registry: refusals %v absent %v", registry.Refusals(), registry.Absent()) + } + if m.BinPath != filepath.Join(dir, "fake.sh") || !m.LandsTree() { + t.Fatalf("manifest = %+v", m) + } + if got := registry.Names(); len(got) != 1 || got[0] != "fake" { + t.Fatalf("names = %v", got) + } +} + +func TestLoadIsEmptyWhenTheFolderDoesNotExist(t *testing.T) { + registry, err := Load(filepath.Join(t.TempDir(), "nowhere")) + if err != nil { + t.Fatal(err) + } + if !registry.Empty() { + t.Fatalf("registry = %+v, want empty", registry) + } +} + +func TestLoadRefusesAManifestWithoutItsPageAndNamesTheCommand(t *testing.T) { + dir := t.TempDir() + writeDelegate(t, dir, "fake", goodManifest, "") + writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") + registry, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if _, ok := registry.Find("fake"); ok { + t.Fatal("a delegate with no manual page was admitted") + } + refusals := registry.Refusals() + if len(refusals) != 1 || !strings.Contains(refusals[0].Reason, "fake.md") || !strings.HasSuffix(refusals[0].Reason, "not added") { + t.Fatalf("refusals = %v", refusals) + } +} + +func TestLoadRefusesAPageThatDoesNotSayTheCommand(t *testing.T) { + dir := t.TempDir() + writeDelegate(t, dir, "fake", goodManifest, "# fake\n\nIt does things.\n") + writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") + registry, _ := Load(dir) + refusals := registry.Refusals() + if len(refusals) != 1 || refusals[0].String() != "fake: its manual page does not say /fake — not added" { + t.Fatalf("refusals = %v", refusals) + } +} + +func TestLoadKeepsAnAbsentProgramApartFromARefusal(t *testing.T) { + dir := t.TempDir() + manifest := strings.Replace(goodManifest, `"./fake.sh"`, `"no-such-program-on-any-path"`, 1) + writeDelegate(t, dir, "fake", manifest, "## /fake\n") + registry, _ := Load(dir) + if len(registry.Refusals()) != 0 { + t.Fatalf("refusals = %v, want none: the file is fine", registry.Refusals()) + } + absent := registry.Absent() + if len(absent) != 1 || absent[0].String() != "fake: no-such-program-on-any-path is not on this machine" { + t.Fatalf("absent = %v", absent) + } + if _, ok := registry.Find("fake"); ok { + t.Fatal("an absent delegate was offered") + } +} + +func TestLoadRefusesTheThingsValidateRefuses(t *testing.T) { + cases := map[string]string{ + "a stray placeholder": strings.Replace(goodManifest, "{{brief}}", "{{prompt}}", 1), + "no brief": strings.Replace(goodManifest, `"--", "{{brief}}"`, `"--"`, 1), + "a bad name": strings.Replace(goodManifest, `"name": "fake"`, `"name": "Fake Thing"`, 1), + "an unknown lands": strings.Replace(goodManifest, `"lands": "tree"`, `"lands": "branch"`, 1), + "an unknown field": strings.Replace(goodManifest, `"lands": "tree"`, `"lands": "tree", "reader": "swe-pro"`, 1), + "not json": "{", + } + for name, manifest := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + writeDelegate(t, dir, "fake", manifest, "## /fake\n") + writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") + registry, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if len(registry.Refusals()) != 1 { + t.Fatalf("refusals = %v, want one", registry.Refusals()) + } + if _, ok := registry.Find("fake"); ok { + t.Fatal("admitted") + } + }) + } +} + +func TestLoadRefusesAFileWhoseNameDisagreesWithItsManifest(t *testing.T) { + dir := t.TempDir() + writeDelegate(t, dir, "other", goodManifest, "## /other\n") + registry, _ := Load(dir) + refusals := registry.Refusals() + if len(refusals) != 1 || !strings.Contains(refusals[0].Reason, `other.json but the manifest says its name is "fake"`) { + t.Fatalf("refusals = %v", refusals) + } +} diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go new file mode 100644 index 000000000..9202eb4e6 --- /dev/null +++ b/internal/delegate/protocol.go @@ -0,0 +1,264 @@ +package delegate + +// The protocol: one JSON object per line on the program's stdout, four record +// types read, everything else ignored (docs/DELEGATE-PROTOCOL.md §2). Ignoring +// the rest is what makes the reader generic — swe-pro's bus payloads and any +// future 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 a delegate. + +import ( + "bufio" + "encoding/json" + "io" + "strconv" + "strings" + "unicode/utf8" +) + +// The record types. +const ( + RecordStage = "stage" + RecordSpend = "spend" + RecordStep = "step" + RecordTerminal = "terminal" +) + +// The terminal statuses. The set is closed and it is swe-pro's, because +// swe-pro'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 +) + +// 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 +// swe-pro'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 swe-pro'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. +// swe-pro 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 +} + +// 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 { + // Stage is a phase change: the live step. + Stage(stage, status string) + // Spend is the cumulative cost so far. The reader guarantees it never + // goes down: a program that sends a lower figure is answered with the + // last high one, because the bank behind this reads deltas. + Spend(usd float64) + // Step is one finished action: command and the observation head, both + // already capped. + Step(command, observation string) + // 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, the high-water spend, how many steps, whether a terminal arrived, and +// how many lines were not the protocol's (dropped, not failed). +type Reading struct { + LastStage string + LastStatus string + SpendUSD float64 + 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 RecordStage: + var rec struct { + Stage string `json:"stage"` + Status string `json:"status"` + } + 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(rec.Stage, rec.Status) + } + case RecordSpend: + var rec struct { + CostUSD *float64 `json:"cost_usd"` + } + if json.Unmarshal([]byte(line), &rec) != nil || rec.CostUSD == nil { + reading.Ignored++ + continue + } + // NEVER DOWN. The bank behind the sink adds deltas, and a figure + // that fell would be a refund nobody issued. + if *rec.CostUSD > reading.SpendUSD { + reading.SpendUSD = *rec.CostUSD + } + if sink != nil { + sink.Spend(reading.SpendUSD) + } + case RecordStep: + var rec struct { + Command string `json:"command"` + Observation string `json:"observation"` + } + if json.Unmarshal([]byte(line), &rec) != nil || strings.TrimSpace(rec.Command) == "" { + reading.Ignored++ + continue + } + reading.Steps++ + if sink != nil { + sink.Step(cut(oneLine(rec.Command), commandCap), cut(rec.Observation, observationCap)) + } + 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() +} + +// 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 000000000..1a4420fcd --- /dev/null +++ b/internal/delegate/protocol_test.go @@ -0,0 +1,155 @@ +package delegate + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// 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{} + stages []string + spend []float64 + steps []string + terminal *Terminal +} + +func newRecorder() *recorder { return &recorder{spoke: make(chan struct{})} } + +func (r *recorder) Stage(stage, status string) { + r.mu.Lock() + defer r.mu.Unlock() + r.stages = append(r.stages, stage+"·"+status) + if r.spoke != nil { + r.once.Do(func() { close(r.spoke) }) + } +} +func (r *recorder) Spend(usd float64) { + r.mu.Lock() + defer r.mu.Unlock() + r.spend = append(r.spend, usd) +} +func (r *recorder) Step(command, observation string) { + r.mu.Lock() + defer r.mu.Unlock() + r.steps = append(r.steps, command+"→"+observation) +} +func (r *recorder) Terminal(t Terminal) { + r.mu.Lock() + defer r.mu.Unlock() + r.terminal = &t +} + +// A recorded swe-pro stream, taken from EVENTS-CONTRACT.md's shapes, read +// through the one generic reader: the stages reach the live step, the spend +// reaches the bank, the steps reach the page, the terminal is the result, and +// every bus payload passes through untouched. +func TestTheReaderReplaysASweProStream(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "swe-pro-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 swe-pro 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.SpendUSD != 0.0213 || reading.Steps != 2 { + t.Fatalf("spend %.4f steps %d, want 0.0213 and 2", reading.SpendUSD, reading.Steps) + } + // Three bus payloads are on the stream; they are ignored, not failed. + if reading.Ignored != 3 { + t.Fatalf("ignored = %d, want the three bus payloads", reading.Ignored) + } + if got := strings.Join(sink.stages, " "); !strings.Contains(got, "implement·running") || !strings.Contains(got, "verification·pass") { + t.Fatalf("stages = %q", got) + } + // The spend is told three times and never goes down; the repeat is told + // again at the same figure, which a bank reads as no delta. + if len(sink.spend) != 3 || sink.spend[0] != 0.0101 || sink.spend[2] != 0.0213 { + t.Fatalf("spend told = %v", sink.spend) + } + 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 swe-pro's spelling. + 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 swe-pro's submission_reason", sink.terminal.Claim()) + } + if sink.terminal.Observed() != "pass" { + t.Fatalf("observed = %q, want swe-pro's own inner status", sink.terminal.Observed()) + } +} + +func TestTheReaderKeepsSpendFromFallingAndTakesOneTerminal(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 len(sink.spend) != 2 || sink.spend[1] != 0.5 { + t.Fatalf("spend told = %v, want the second reading held at the first's high water", sink.spend) + } + if sink.terminal == nil || sink.terminal.Message != "first" { + t.Fatalf("terminal = %+v, want the first one only", sink.terminal) + } + // The second terminal, the stray line and the unknown type are the three + // ignored lines; the empty line is nothing. + if reading.Ignored != 3 { + t.Fatalf("ignored = %d", 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 TestObservedReadsSweProsVerificationCount(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) + } +} diff --git a/internal/delegate/testdata/swe-pro-stream.ndjson b/internal/delegate/testdata/swe-pro-stream.ndjson new file mode 100644 index 000000000..b04eb65fc --- /dev/null +++ b/internal/delegate/testdata/swe-pro-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":".swe-pro/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/run/delegateworker.go b/internal/run/delegateworker.go new file mode 100644 index 000000000..98c1421c4 --- /dev/null +++ b/internal/run/delegateworker.go @@ -0,0 +1,252 @@ +package run + +// A DELEGATE IS ONE MORE WORKER KIND. An outside program that does a whole +// task on its own (docs/DELEGATE-PROTOCOL.md, docs/design/delegate/DESIGN.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 is started +// in the run's working copy (internal/delegate.Run), its stdout is the +// protocol, and its terminal record is the ending. Its stages feed the live +// step only; its `step` records are 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. + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// 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 delegate as the worker of one task. +type DelegateWorker struct { + store *plandb.Store + workspace string + manifest delegate.Manifest + key string + // 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. + cost float64 + elapsed time.Duration + // grace overrides the launch's SIGTERM grace, for a test. + grace time.Duration +} + +// NewDelegateWorker builds the worker. key is the person's API key as the door +// resolved it; cost and elapsed are the run's ceilings, zero for none. +func NewDelegateWorker(store *plandb.Store, workspace string, m delegate.Manifest, key string, cost float64, elapsed time.Duration) *DelegateWorker { + return &DelegateWorker{store: store, workspace: workspace, manifest: m, key: key, cost: cost, elapsed: elapsed} +} + +// DelegateFactory is the run's WorkerFactory for a delegated run: the root task +// is the delegate'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, m delegate.Manifest, key string, limits Limits, rest WorkerFactory) WorkerFactory { + return func(task plandb.Task) Worker { + if task.ID == store.RootID() { + return NewDelegateWorker(store, workspace, m, key, 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 run's spend bank. 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 + ctx context.Context + taskID string + storeDir string + name string + steps int + usd float64 + lastErr error + terminal *delegate.Terminal +} + +func (s *delegateSink) Stage(stage, status string) { + // THE LIVE STEP IS THE PROGRAM'S PHASE, numbered after the last step + // recorded, so the row reads "swe-pro: implement · running" while the + // program is inside that phase and the count on the row stays the steps'. + label := s.name + ": " + stage + if status != "" { + label += " · " + status + } + _ = s.worker.store.SetLive(s.taskID, s.steps+1, label) +} + +func (s *delegateSink) Spend(usd float64) { + if usd > s.usd { + s.usd = usd + } + bankSpend(s.ctx, s.usd) +} + +func (s *delegateSink) Step(command, observation string) { + s.steps++ + if err := appendTrajectory(s.storeDir, s.taskID, Step{ + Kind: trajectoryStepKind, + Step: s.steps, + Command: command, + Observation: observationHead(observation), + }); err != nil && s.lastErr == nil { + s.lastErr = err + } +} + +func (s *delegateSink) Terminal(t delegate.Terminal) { s.terminal = &t } + +// 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 the higher of what it streamed and what its terminal record said. +func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, error) { + storeDir := filepath.Dir(w.store.Path()) + if err := appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryBeginKind, ExitsRecorded: true}); err != nil { + return Report{}, fmt.Errorf("stamp the trajectory opening line: %w", err) + } + sink := &delegateSink{worker: w, ctx: ctx, taskID: task.ID, storeDir: storeDir, name: w.manifest.Name} + brief := strings.TrimSpace(task.Description) + if brief == "" { + brief = strings.TrimSpace(task.Title) + } + result, err := delegate.Run(ctx, delegate.Launch{ + Manifest: w.manifest, + Fills: delegate.Fills{ + Brief: brief, + Workspace: w.workspace, + CostUSD: w.cost, + Hours: w.elapsed.Hours(), + Key: w.key, + }, + StderrPath: filepath.Join(plandb.TaskDir(storeDir, task.ID), delegateStderrName), + Grace: w.grace, + }, sink) + // 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) + + usd := sink.usd + if t := result.Reading.Terminal; t != nil { + if total, ok := t.CostUSD(); ok && total > usd { + usd = total + } + } + if usd > 0 { + // The spend row is the task page's own figure. The role is read off the + // store as the bash worker reads it; the "model" column carries the + // delegate's name, because that is what spent the money. + role, err := w.store.RoleOf(task.ID) + if err != nil { + role = plandb.RoleWork + } + _ = w.store.AddSpend(task.ID, "delegate/"+w.manifest.Name, role, usd, 0, 0) + } + report := Report{Steps: sink.steps, USD: usd} + + end := func(reason, result string) { + _ = appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryEndKind, ExitsRecorded: true, Steps: sink.steps, Result: result, Reason: reason}) + } + if sink.lastErr != nil { + end("the record failed: "+sink.lastErr.Error(), "") + return report, sink.lastErr + } + 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.manifest.Name + " said " + t.Message + } + end(reason, "") + return report, err + } + if errors.Is(err, delegate.ErrNoTerminal) { + reason := fmt.Sprintf("%s exited %d without a terminal record", w.manifest.Name, result.ExitCode) + if result.Reading.LastStage != "" { + reason += "; its last stage was " + result.Reading.LastStage + } + end(reason, "") + return report, errors.New(reason) + } + if err != nil { + end(err.Error(), "") + return report, err + } + t := *result.Reading.Terminal + report.Result = delegateResult(w.manifest, t) + switch t.Status { + case delegate.StatusPass: + end("finished: "+t.Message, report.Result) + return report, nil + case delegate.StatusBudget: + reason := w.manifest.Name + " stopped on its own ceiling: " + t.Message + end(reason, report.Result) + return report, errors.New(reason) + case delegate.StatusCrashed: + reason := w.manifest.Name + " crashed: " + t.Message + end(reason, report.Result) + return report, errors.New(reason) + default: + // `fail`, and any word this build does not know, is work that does not + // stand: the run reads it as incomplete. + reason := w.manifest.Name + " did not finish: " + t.Message + end(reason, report.Result) + return report, errors.New(reason) + } +} + +// delegateResult is the ending in words: the deliverable for a delegate 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.Manifest, 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_test.go b/internal/run/delegateworker_test.go new file mode 100644 index 000000000..7037f5e5e --- /dev/null +++ b/internal/run/delegateworker_test.go @@ -0,0 +1,224 @@ +//go:build !windows + +package run_test + +import ( + "context" + "errors" + "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" +) + +// fakeDelegate writes a shell program that speaks the protocol — a stage, a +// spend, two steps, then body — and answers its manifest. +func fakeDelegate(t *testing.T, body string) delegate.Manifest { + 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":"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"}'`, + `echo '{"type":"spend","cost_usd":0.11}'`, + body, + }, "\n") + "\n" + if err := os.WriteFile(script, []byte(program), 0o755); err != nil { + t.Fatal(err) + } + return delegate.Manifest{ + Name: "fake", + Description: "a fake delegate", + Bin: script, + BinPath: script, + Argv: []string{"run", "--dir", delegate.FillWorkspace, "--max-cost", delegate.FillCostUSD, "--", delegate.FillBrief}, + } +} + +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"}}'` +} + +func TestDelegateWorkerRecordsStepsBanksSpendAndReportsTheEnding(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 := fakeDelegate(t, passLine("tests are green")) + worker := run.NewDelegateWorker(store, workspace, m, "sk-test", 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) + } + // The report's dollars are the terminal's total, which is higher than the + // last streamed figure; the bank saw the streamed figures as they rose. + if report.USD != 0.12 { + t.Fatalf("usd = %v, want the terminal's 0.12", report.USD) + } + if len(banked) != 2 || banked[0] != 0.05 || banked[1] != 0.11 { + t.Fatalf("banked = %v, want the two rising spend records", banked) + } + 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 := "run\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, the spend row names the + // delegate, and stderr went to the task's folder. + if live := store.LiveSteps(); len(live) != 0 { + t.Fatalf("live steps = %+v, want none after the program ended", live) + } + if _, err := os.Stat(filepath.Join(plandb.TaskDir(storeDir, store.RootID()), "delegate-stderr.log")); err != nil { + t.Fatalf("no stderr file beside the trajectory: %v", err) + } + spend := store.SpendSummary() + if got := spend.ByModel["delegate/fake"]; got.USD != 0.12 || got.Calls != 1 { + t.Fatalf("spend by model = %+v, want one row of 0.12 under the delegate's name", spend.ByModel) + } +} + +func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { + store := runOpenStore(t) + m := fakeDelegate(t, `echo '{"type":"terminal","status":"fail","message":"unsubmitted","data":{"cost_usd":0.2,"status":"unsubmitted"}}'`) + worker := run.NewDelegateWorker(store, t.TempDir(), m, "", 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) + } + if report.USD != 0.2 || report.Steps != 2 { + t.Fatalf("report = %+v, want the money and the steps kept on a failed ending", report) + } +} + +func TestDelegateWorkerNamesAnExitWithoutATerminal(t *testing.T) { + store := runOpenStore(t) + m := fakeDelegate(t, "exit 7") + worker := run.NewDelegateWorker(store, t.TempDir(), m, "", 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 := 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, "", 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.11 { + t.Fatalf("usd = %v, want what was spent before the stop", 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, driven by the +// supervisor to done, with the delegate's words as the run's result. +func TestARunSeatsTheDelegateOnItsRootAndEndsDone(t *testing.T) { + store := runOpenStore(t) + m := fakeDelegate(t, passLine("all green")) + factory := run.DelegateFactory(store, t.TempDir(), m, "", 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 streamed spend crosses is ended by +// the run on the limit word, with the program terminated and its own terminal +// kept. +func TestARunEndsADelegateThatCrossesTheCostCeiling(t *testing.T) { + store := runOpenStore(t) + m := fakeDelegate(t, strings.Join([]string{ + `trap 'echo "{\"type\":\"terminal\",\"status\":\"budget-exhausted\",\"message\":\"stopped\",\"data\":{\"cost_usd\":0.11}}"; exit 0' TERM`, + `sleep 30 &`, + `wait $!`, + }, "\n")) + factory := run.DelegateFactory(store, t.TempDir(), m, "", 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) + } +} From 7a6ad9e67d8e09c1aaa42dca48f18964cb70b446 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:06:30 -0400 Subject: [PATCH 011/195] delegate: / starts a task the program does on its own; /delegate lists them; via on propose_task Wave 2 of docs/design/delegate/DESIGN.md. The conversation's door: a delegated run rides the run road whatever the belt switch says, the program is seated on the root task, nothing joins it, and its commits are squashed into one task commit that comes home the way every run's copy does. The surface generates one command row per installed delegate at launch, appended to the live table and never to the literal; /delegate lists what is here, what is absent and what was not added. The model is told the delegates this launch has, by name, and names one with `via`. The manual gets its Delegates page and the probes that reach it. Co-Authored-By: Claude Fable 5.1 --- cmd/codeaf/chatv3.go | 3 + cmd/codeaf/chatv3_delegate.go | 24 +++ internal/manual/chat/commands.md | 25 +++ internal/manual/chat/delegates.md | 102 +++++++++++ internal/manual/chat_test.go | 7 + internal/run/enginewire.go | 44 +++-- internal/session/beltfacts.go | 9 +- internal/session/delegate_door.go | 237 +++++++++++++++++++++++++ internal/session/delegate_door_test.go | 196 ++++++++++++++++++++ internal/session/session.go | 9 + internal/session/task.go | 44 ++++- internal/session/task_run_belt.go | 58 +++++- internal/tui3/app.go | 18 ++ internal/tui3/commands.go | 6 + internal/tui3/delegate.go | 198 +++++++++++++++++++++ internal/tui3/delegate_test.go | 152 ++++++++++++++++ internal/tui3/homeslash.go | 7 +- internal/tui3/taskcommand.go | 12 +- 18 files changed, 1126 insertions(+), 25 deletions(-) create mode 100644 cmd/codeaf/chatv3_delegate.go create mode 100644 internal/manual/chat/delegates.md create mode 100644 internal/session/delegate_door.go create mode 100644 internal/session/delegate_door_test.go create mode 100644 internal/tui3/delegate.go create mode 100644 internal/tui3/delegate_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index a438bf1b8..c4e6ba327 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1048,6 +1048,9 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { SubharnessMemory: subharnesses.Memory, SubharnessLastRun: subharnesses.LastRun, SubharnessRecordRun: subharnesses.Record, + // AND THE DELEGATES, the outside programs a task can be handed to + // whole (chatv3_delegate.go). Nil is delegates off, 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 000000000..3c6537545 --- /dev/null +++ b/cmd/codeaf/chatv3_delegate.go @@ -0,0 +1,24 @@ +package main + +import ( + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// THE DELEGATE SIDE OF ONE CONVERSATION: the outside programs a task can be +// handed to whole (internal/delegate, docs/DELEGATE-PROTOCOL.md). The registry +// is read here, at the door, for the reason every other registry on this path +// is (chatv3_subharness.go): where the manifests live is the SURFACE'S decision, +// and internal/session is handed the registry and nothing about a directory. +// +// IT IS SILENT ON FAILURE, in the same posture: a folder that cannot be read +// means DELEGATES OFF — the door lists nothing, `via` refuses every name, the +// prompt says nothing — and a registry is not worth failing a launch over. A +// manifest the loader would not admit is not a failure of the launch either; it +// is a line on `/delegate`, which is where the person who wrote it will look. +func v3Delegates() *delegate.Registry { + registry, err := delegate.Load(delegate.Dir()) + if err != nil { + return nil + } + return registry +} diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index c0eebf1ce..6f27cc48d 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -178,6 +178,9 @@ 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 | +| `/delegate` | `/delegates` | — | lists the outside programs a task can be handed to whole, and what each leaves behind | +| `/delegate` | `/delegates` | ` ` | hands that brief to the named delegate; `/ ` is the same door | +| `/` | — | `` | one row per installed delegate, e.g. `/swe-pro `: starts a task that program does on its own | | `/memory` | — | — | opens the memory panel | | `/memory` | `/memories` | `` | prints matching memories into the conversation | | `/memories` | — | — | prints every memory into the conversation | @@ -1260,6 +1263,28 @@ 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. +## /delegate — the outside programs a task can be handed to, and /swe-pro + +`/delegate` (or `/delegates`) lists the delegates on this machine, one line each: the +command to type, what it does, whether it lands its work on your branch or answers in the +conversation, and the program it resolved to. Under those, dimly, any manifest whose program +is not here and any that was not added, with the reason. + +Every installed delegate is also a command of its own: `/swe-pro ` hands the brief +to swe-pro and starts a task at once, exactly as `/task ` does with codeaf's own +worker. `/delegate ` is the same door written long. The rows come from the +manifests under `~/.codeaf/delegates/` and exist only where the program does; a machine with +no swe-pro has no `/swe-pro`. + +With nothing installed it says, exactly: + +``` +no delegates here — a delegate is an outside program codeaf can hand a whole task to; a manifest under ~/.codeaf/delegates adds one +``` + +Over `--host` it says ` owns delegates · change it on that machine`. The *Delegates* +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 000000000..434a1601d --- /dev/null +++ b/internal/manual/chat/delegates.md @@ -0,0 +1,102 @@ +# Delegates + +## What a delegate is — programs codeaf can hand a task to, outside agents, another coding agent, swe-pro + +A **delegate** is an outside program on this machine that can do a whole coding task on +its own. codeaf hands it a task the way it hands one to its own worker: in a working copy +of your folder, under this conversation's dollar and time limits, shown on the rail while +it runs, stoppable, and landed on your branch when it ends. codeaf designs nothing about +the program and cannot see inside it; it starts it, reads what it says, stops it when a +limit is reached, and takes its result. + +A delegate is not a harness and not a subharness. Those are programs built out of +codeaf's own parts and run inside this process; a delegate is somebody else's binary +running as a child process. `/harness` and `/subharness` list the first kind; `/delegate` +lists the second. + +Each delegate is one manifest and one page under `~/.codeaf/delegates/`: `.json` +says how to run the program, `.md` says what it does. A delegate is found at launch, +so one added while codeaf is running appears the next time codeaf starts. + +## How do I hand work to a delegate — /swe-pro, / , /delegate, via, "delegate this to swe-pro" + +Type the delegate's name as a command and the brief after it: + +``` +/swe-pro rewrite the auth middleware to use the new session store +``` + +That is `/task` with the worker chosen. A run starts at once in a copy of your folder, +the turn goes on, and the row appears on the rail with the program's current phase as its +live step. `/delegate ` is the same door in long form. + +The model can choose a delegate too: `propose_task` takes `via` naming one, and the card +you answer says which program the work is going to. It is told the names this machine has +and nothing else, so it cannot propose a delegate that is not here. + +`/delegate` on its own lists every delegate on this machine, one line each: the command, +what it does, whether it lands its work on your branch or answers in the conversation, and +the program it resolved to. Under those, dimly, the manifests whose program is not on this +machine, and any manifest that was not added and why. + +## What a delegate cannot do — why it did not ask me, no questions, no step cap, why a delegate was refused + +**A delegate cannot ask you anything.** There is nobody at its keyboard: it runs +unattended, and a question it tried to ask is turned down inside the program. 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. + +**A delegate has no step cap.** It is held to this conversation's dollar and time limits, +which are handed to it on its command line and enforced by codeaf from outside as well. The +step count on its task page is what the program reported, not a limit. + +**A delegate runs alone.** While a delegated run is going, no other task can join its copy, +and no delegate can be added under another run. Both are refused with the folder that is +busy: `work is already underway in a copy of ; a delegate runs alone, so propose it +again when that work has ended`. + +**A delegated run has no review round.** codeaf's checker does not read the program's work +afterwards; what the program itself checked is reported in its result, kept apart from +what its model claimed. + +A name that is no delegate here is refused with the ones that are: +`no delegate is called ; the delegates here are swe-pro, …`. On a machine with none: +`no delegate is called : this machine has no delegates (a manifest under +~/.codeaf/delegates adds one)`. + +## Where a delegate's work goes — squashed into one commit, landed on my branch, the wip commits, what it costs + +A delegate that lands a **tree** works in a copy cut from your folder. When it ends, every +commit it made in that copy is squashed into **one commit** whose subject is the task's +title and whose body is the program's own account of the ending, and that commit is merged +into your folder the way every task's work comes home. A program that commits after every +edit, as swe-pro does, leaves no trail of bookkeeping commits on your branch. Nothing to +land is said as `nothing to land: the run's working copy holds no change`. + +A delegate that lands **text** 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, folded in +as the program reports it. The spending page shows it under the delegate's name rather +than a model's, because the program's own calls did not go through codeaf. + +## Why is there no /swe-pro here — the delegate is missing, not on this machine, adding a delegate, the manifest was not added + +A delegate's row exists only where its program does. `/delegate` says which of the +manifests under `~/.codeaf/delegates/` could not be added and why: + +- `: is not on this machine` — the manifest is fine and the program is + not on PATH. Install it and start codeaf again. +- `: no manual page beside it — write .md saying what / does — not + added` — every delegate ships the page the chat answers from. +- `: its manual page does not say / — not added` — the page exists and + never names the command. +- `: its name is already a command here — not added` — the name collides with a + built-in command or alias. + +Over `--host`, delegates are the far machine's: `/delegate` answers +` owns delegates · change it on that machine`. + +The contract a program has to meet to be a delegate is one page, `docs/DELEGATE-PROTOCOL.md` +in the codeaf repository: four kinds of line on its stdout, one terminal record, a clean stop +on SIGTERM, and its work left in the tree it was given. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 26acd310d..315baa9f2 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -909,6 +909,13 @@ 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 to swe-pro", "delegates"}, + {"what does /swe-pro do", "delegates"}, + {"delegate this to another coding agent", "delegates"}, + {"why can't the delegate ask me anything", "delegates"}, + {"why is there no /swe-pro here", "delegates"}, + {"where does a delegate's work go, does it squash the commits", "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 diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 59c248f87..eaa185fb8 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -22,29 +22,39 @@ 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. + factory := CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ + Work: spec.WorkModel, + Plan: spec.PlanModel, + }, 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 + factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, spec.APIKey, 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. - Factory: CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ - Work: spec.WorkModel, - Plan: spec.PlanModel, - }, spec.CompleterFor), - OnSpend: spec.OnSpend, + Limits: limits, + Factory: factory, + OnSpend: spec.OnSpend, }) return session.RunSummary{ Outcome: string(outcome), diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index 7f43dd34b..0267ee650 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -254,6 +254,10 @@ type beltFact struct { // because that is the only verb such a belt carries. Empty falls back to // [beltFact.present]. oneRoad string + // fill, when set, is applied to the chosen text before it is placed: it is + // how a fact writes a fact of THIS launch into itself — the delegates this + // machine has (delegate_door.go) — where every other fact is a constant. + fill func(Config, string) string } // beltFacts is the whole of it, in the order the section reads. @@ -504,7 +508,7 @@ var handoffFacts = []beltFact{{ "stand, so a sweep across many files, research across many sources or the same\n" + "change over many items is work you open and carry yourself, in the order that\n" + "finishes it.", -}, { +}, delegateFact, { tools: []string{"build_harness", loadCapabilityToolName}, holds: Config.mayDesignHarness, present: "AND A SHAPE OF WORK THAT WILL RECUR is neither of them: `build_harness` designs it once and saves it.", @@ -676,6 +680,9 @@ func renderBeltFacts(config Config, facts []beltFact, join string) string { text = fact.shelved } } + if text != "" && fact.fill != nil { + text = fact.fill(config, text) + } if text != "" { // THE ASSISTED-BY LINE IS FILLED HERE because this is the one point // that holds both the belt's bytes and the session's configured diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go new file mode 100644 index 000000000..aaf9b756b --- /dev/null +++ b/internal/session/delegate_door.go @@ -0,0 +1,237 @@ +package session + +// THE DELEGATE DOOR: how a conversation hands a task to an outside program +// (docs/design/delegate/DESIGN.md, docs/DELEGATE-PROTOCOL.md). A delegate is +// one more worker kind behind the run engine, and this file is the half a +// conversation needs of it — which delegates this launch has, the door +// `/ ` and `propose_task`'s `via` both open, and the landing of a +// run whose worker was a program rather than a bash worker. +// +// IT RIDES THE RUN ROAD WHATEVER THE BELT SAYS. `/task` takes the run road only +// under CODEAF_TASK_BELT=bash, because that road's WORKER is the bash belt. A +// delegate's worker is the program, so the road is asked for outright here: the +// store, the copy, the supervisor and the landing are the run's, and nothing in +// them reads the belt switch. What a delegated run does not have is the review +// round, because a check seat is a bash-belt worker and the belt may be off; the +// program's own verification is what the terminal record reports. + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// DelegateRow is one delegate as a surface lists it: the command word, the +// sentence under it, and what it leaves behind. +type DelegateRow struct { + Name string + Description string + // Lands is delegate.LandsTree or delegate.LandsText. + Lands string + // Bin is the program as it resolved on this machine. + Bin string +} + +// DelegateReport is everything `/delegate` says: the delegates that can run, +// the ones whose program is not here (one dim line each), and the files the +// loader would not admit (one line each, with the reason). +type DelegateReport struct { + Rows []DelegateRow + Absent []string + Refused []string +} + +// Delegates is the report for this conversation. A build with no registry +// answers the zero report, which a surface draws as one sentence. +func (a *Agent) Delegates() DelegateReport { return a.config.delegateReport() } + +func (c Config) delegateReport() DelegateReport { + var report DelegateReport + if c.Delegates == nil { + return report + } + for _, m := range c.Delegates.All() { + lands := m.Lands + if lands == "" { + lands = delegate.LandsTree + } + report.Rows = append(report.Rows, DelegateRow{Name: m.Name, Description: m.Description, Lands: lands, Bin: m.BinPath}) + } + for _, absent := range c.Delegates.Absent() { + report.Absent = append(report.Absent, absent.String()) + } + for _, refusal := range c.Delegates.Refusals() { + report.Refused = append(report.Refused, refusal.String()) + } + return report +} + +// delegateNames is the runnable names, sorted, for the prompt and the refusal. +func (c Config) delegateNames() []string { + if c.Delegates == nil { + return nil + } + return c.Delegates.Names() +} + +// mayDelegate says whether this belt may hand work to a delegate: it is the +// conversation's own hand-off predicate with one more condition, that this +// launch has at least one delegate that can run. A task node never delegates, +// for the reason it never proposes: there is nowhere for the work to go from +// there. +func (c Config) mayDelegate() bool { + return c.mayProposeTask() && !c.InTask && len(c.delegateNames()) > 0 +} + +// delegateFact is the hand-off page's one paragraph about delegates. It is +// rendered only where [Config.mayDelegate] holds, and its `fill` writes the +// installed names in, so the model is told the words it can put in `via` and +// never a name this machine does not have. +var delegateFact = beltFact{ + tools: []string{"propose_task"}, + holds: Config.mayDelegate, + present: "AND WORK BIG ENOUGH TO WANT ITS OWN AGENT FOR AN HOUR — one large change, specified\n" + + "well enough that nobody will be asked anything — can go to a DELEGATE: an outside\n" + + "program on this machine that does the whole task on its own, in a copy of the folder,\n" + + "under the same dollar and time limits, landed when it ends. Name it in `propose_task`'s\n" + + "`via`. The delegates here are: %s. A delegate cannot ask the person anything, so its\n" + + "brief has to settle everything; a change you would do in a few steps is never worth one.", + fill: func(config Config, text string) string { + return fmt.Sprintf(text, strings.Join(config.delegateNames(), ", ")) + }, +} + +// DelegateUnknownError is the refusal for a `via` naming no delegate this +// machine can run. It names the ones it can, sorted, so the next attempt has +// the words in front of it. +type DelegateUnknownError struct { + Named string + Have []string +} + +func (e DelegateUnknownError) Error() string { + if len(e.Have) == 0 { + return "no delegate is called " + e.Named + ": this machine has no delegates (a manifest under ~/.codeaf/delegates adds one)" + } + have := append([]string(nil), e.Have...) + sort.Strings(have) + return "no delegate is called " + e.Named + "; the delegates here are " + strings.Join(have, ", ") +} + +// delegateFor resolves a `via` word to its manifest, or the refusal. +func (a *Agent) delegateFor(name string) (delegate.Manifest, error) { + name = strings.TrimSpace(name) + if name == "" { + return delegate.Manifest{}, errors.New("a delegate needs a name") + } + if a.config.Delegates != nil { + if m, ok := a.config.Delegates.Find(name); ok { + return m, nil + } + } + return delegate.Manifest{}, DelegateUnknownError{Named: name, Have: a.config.delegateNames()} +} + +// StartDelegate hands one person-authored brief to the named delegate. It is +// `/ `'s door and it answers what StartTask answers: the id the +// row wears, the title, a note about where the work stands (always empty here) +// and the error. Nothing is waited for: the run starts and the turn goes on. +// +// The refusals a person can meet, in their own words: a name this machine has +// no delegate for, an empty brief, and a build whose run road is not linked. +func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, string, string, error) { + brief = strings.TrimSpace(brief) + if brief == "" { + return 0, "", "", errors.New("a delegate needs a brief") + } + m, err := a.delegateFor(name) + if err != nil { + return 0, "", "", err + } + if a.config.InTask { + return 0, "", "", errors.New("a task cannot hand its work to a delegate; only the conversation can") + } + g := a.graph() + if chatRunEngine == nil || g == nil || g.planPath() == "" { + return 0, "", "", errors.New("delegates need the run road, and this build has none") + } + id := g.reserve() + title := taskPersonTitle(brief) + if err := a.startKnownTaskRunVia(ctx, id, title, brief, nil, delegateStand(a.config.Workspace, m), "", &m); err != nil { + return 0, "", "", err + } + return id, title, "", nil +} + +// delegateStand is where a delegate works. A program that lands a tree gets a +// working copy of the folder, as every task does; one that lands text reads the +// person's folder in place and changes nothing, which is what its manifest +// promised. +func delegateStand(workspace string, m delegate.Manifest) taskStand { + if m.LandsTree() { + return taskStand{dir: workspace, mode: TaskModeWorktree} + } + return taskStand{dir: workspace, mode: TaskModeInPlace} +} + +// landDelegateRun is a delegated run's landing, in place of the engine's own. +// +// A TREE DELEGATE'S COMMITS ARE SQUASHED. swe-pro commits every edit as it goes +// (`wip(edit): `, dozens a run), so the copy's branch holds bookkeeping +// history that is the program's own and nobody else's; the engine's landing +// would also find nothing to commit, because everything is already committed, +// and answer "nothing to land" over a tree full of work. So the copy is taken +// back to the commit it stood on when the program started — recorded on the run +// at that moment, so the point is exact whatever the ground ladder put under it +// — with the tree and index kept, and committed once through the same road every +// task commits through. The subject is the task's title; the body is the +// terminal record's two sentences. Then the copy comes home the way every run's +// copy does. +// +// A TEXT DELEGATE LANDS NOTHING: it worked in place and promised to change +// nothing, and its answer is the run's result, which the outcome note carries. +func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { + m := run.delegate + if m == nil || !m.LandsTree() || run.tree.dir == "" { + return RunLanding{Home: mergeInPlace} + } + dir := run.workspace + if run.startSha != "" { + head, err := git(dir, "rev-parse", "HEAD") + if err == nil && strings.TrimSpace(head) != run.startSha { + if out, err := git(dir, "reset", "--soft", run.startSha); err != nil { + if g := a.graph(); g != nil { + g.planNote("the delegate's commits could not be squashed: " + firstLine(out)) + } + } + } + } + message := "task: " + clip(firstLine(run.title), 72) + if result := strings.TrimSpace(summary.Result); result != "" { + message += "\n\n" + result + } + saved, _, _, err := commitTaskWorkAs(dir, message, nil, a.signsGitWork(), true) + landing := RunLanding{} + switch { + case err != nil: + landing.Refused = firstLine(err.Error()) + case len(saved) == 0: + landing.Refused = runNothingToLand + default: + landing.Branch, landing.Changed = currentBranch(dir), saved + } + note := landing.Refused + if note == "" { + note = fmt.Sprintf("landed on %s: %d files", landing.Branch, len(landing.Changed)) + } + if _, err := run.store.AddNote(run.root, run.root, note); err != nil { + if g := a.graph(); g != nil { + g.planNote("the run's landing note failed: " + err.Error()) + } + } + return a.bringBeltRunHome(run, landing) +} diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go new file mode 100644 index 000000000..cf0f1f4a6 --- /dev/null +++ b/internal/session/delegate_door_test.go @@ -0,0 +1,196 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// installTestDelegate writes one delegate — manifest, page and a program that +// exists — under dir and loads the registry from it. +func installTestDelegate(t *testing.T, name string) *delegate.Registry { + t.Helper() + dir := t.TempDir() + program := filepath.Join(dir, name+".sh") + if err := os.WriteFile(program, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"` + name + `","description":"a fake delegate","bin":"./` + name + `.sh",` + + `"argv":["run","--dir","{{workspace}}","--","{{brief}}"],"lands":"tree"}` + if err := os.WriteFile(filepath.Join(dir, name+".json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte("# "+name+"\n\n## /"+name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + registry, err := delegate.Load(dir) + if err != nil { + t.Fatal(err) + } + if _, ok := registry.Find(name); !ok { + t.Fatalf("the test delegate did not load: %v %v", registry.Refusals(), registry.Absent()) + } + return registry +} + +// The whole road from the door to the branch: `/fake ` starts a run +// whose spec names the delegate, the program's own commits in the copy are +// squashed into ONE commit whose subject is the task's title and whose body is +// the run's result, and that commit comes home to the folder the copy was cut +// from. The engine is a double whose `work` hook plays the program: two files, +// two commits, the way swe-pro commits every edit. +func TestADelegatedRunSquashesTheProgramsCommitsIntoOneAndLandsIt(t *testing.T) { + // The double answers the run's result off the completer it is handed, so + // the result is scripted there: the sentence the landing commit must carry. + const result = "submitted and verified. fake's model said: tests pass" + double := newBeltRunDouble(result) + double.work = func(workspace string) { + for _, name := range []string{"one.txt", "two.txt"} { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o644); err != nil { + t.Error(err) + return + } + mustGit(t, workspace, "add", name) + mustGit(t, workspace, "-c", "user.name=p", "-c", "user.email=p@p", "commit", "-q", "-m", "wip(edit): "+name) + } + } + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + base := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "HEAD")) + sessionDir := t.TempDir() + registry := installTestDelegate(t, "fake") + agent, _ := newTestAgent(t, beltRunCompleter{text: result}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: sessionDir} + config.AskConsent = false + config.Delegates = registry + }) + + id, title, note, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + if id == 0 || title == "" || note != "" { + t.Fatalf("StartDelegate answered id %d title %q note %q", id, title, note) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if spec.Delegate == nil || spec.Delegate.Name != "fake" { + t.Fatalf("the engine was handed no delegate: %+v", spec.Delegate) + } + if spec.Brief != "add two files to the project" { + t.Fatalf("brief = %q", spec.Brief) + } + endBeltRun(t, agent, double) + + // ONE COMMIT ABOVE THE BASE, and it is codeaf's landing commit, not the + // program's two. + log := gitOut(t, conversation, "log", "--format=%s%n%b", base+"..HEAD") + if strings.Contains(log, "wip(edit)") { + t.Fatalf("the program's own commits reached the branch:\n%s", log) + } + subjects := strings.TrimSpace(gitOut(t, conversation, "log", "--format=%s", base+"..HEAD")) + lines := strings.Split(subjects, "\n") + // A merge may add its own commit above the squash; the squash itself is + // exactly one, and it is the task's title. + found := 0 + for _, line := range lines { + if strings.HasPrefix(line, "task: ") { + found++ + } + } + if found != 1 { + t.Fatalf("want exactly one `task:` commit above the base, got %d in:\n%s", found, subjects) + } + if !strings.Contains(log, "fake's model said: tests pass") { + t.Fatalf("the landing commit's body does not carry the run's result:\n%s", log) + } + for _, name := range []string{"one.txt", "two.txt"} { + if _, err := os.Stat(filepath.Join(conversation, name)); err != nil { + t.Fatalf("%s did not come home: %v", name, err) + } + } +} + +func TestStartDelegateRefusesANameThisMachineDoesNotHave(t *testing.T) { + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + registry := installTestDelegate(t, "fake") + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.Delegates = registry + }) + _, _, _, err := agent.StartDelegate(context.Background(), "other", "do a thing") + if err == nil || err.Error() != "no delegate is called other; the delegates here are fake" { + t.Fatalf("err = %v", err) + } + if double.didRun() { + t.Fatal("a refused delegate started a run") + } + // And on a machine with none at all, the sentence says how to get one. + none, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + }) + _, _, _, err = none.StartDelegate(context.Background(), "fake", "do a thing") + if err == nil || !strings.Contains(err.Error(), "this machine has no delegates") { + t.Fatalf("err = %v", err) + } +} + +// A DELEGATE RUNS ALONE. A second hand-off while a delegated run is going is +// refused with the folder that is busy, and a delegate proposed while an +// ordinary run is going is refused the same way. +func TestNothingJoinsADelegatedRunAndADelegateJoinsNothing(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + double.honoursStop = true + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + registry := installTestDelegate(t, "fake") + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = registry + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "the delegated work"); err != nil { + t.Fatal(err) + } + <-double.entered + stand := taskStand{dir: conversation, mode: TaskModeWorktree} + err := agent.startKnownTaskRun(context.Background(), 99, "a second piece", "brief", nil, stand, "") + if err == nil || !strings.Contains(err.Error(), "a delegate runs alone") { + t.Fatalf("a task joined a delegated run: %v", err) + } + endBeltRun(t, agent, double) +} + +// The prompt names the delegates this launch has, and only where there are +// some: a conversation with a registry reads their names under the hand-off +// facts, and one without reads nothing about delegates at all. +func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { + with := Config{Workspace: t.TempDir(), Delegates: installTestDelegate(t, "fake")} + page := promptWithBeltFacts(with) + if !strings.Contains(page, "The delegates here are: fake.") { + t.Fatalf("the page does not name the delegate:\n%s", page) + } + if !strings.Contains(page, "`via`") { + t.Fatal("the page does not say how a delegate is named on a proposal") + } + without := Config{Workspace: t.TempDir()} + if page := promptWithBeltFacts(without); strings.Contains(page, "The delegates here are") || strings.Contains(page, "can go to a DELEGATE") { + t.Fatalf("a launch with no delegates still speaks of them:\n%s", page) + } + inTask := Config{Workspace: t.TempDir(), Delegates: with.Delegates, InTask: true} + if page := promptWithBeltFacts(inTask); strings.Contains(page, "The delegates here are") { + t.Fatal("a task node is told it may delegate") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 5dfcaa8d7..70ad33dca 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -28,6 +28,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/approval" "github.com/Agent-Field/codeaf/internal/connect" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/exec/bare" @@ -1570,6 +1571,14 @@ type Config struct { // the "absent, not broken" law arriving at a door that was never wired. Subharnesses *exec.Registry + // Delegates is this machine's delegate registry: the outside programs a + // task can be handed to whole (delegate_door.go, docs/DELEGATE-PROTOCOL.md). + // The surface reads it at launch from ~/.codeaf/delegates and hands it in, + // for the reason Subharnesses is a registry and not a path. NIL IS DELEGATES + // OFF: the door lists nothing, `via` refuses every name, and the prompt says + // nothing about them. + Delegates *delegate.Registry + // SubharnessMemory is where a running subharness keeps what it has learned // about its OWN domain — its file in its own bundle, never this // conversation's memory (subharness_env.go's [SubharnessMemory] says why the diff --git a/internal/session/task.go b/internal/session/task.go index 457392cc0..def7baeee 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -83,6 +83,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec/bare" ) @@ -191,6 +192,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + + `"via":{"type":"string","description":"Optional: the name of a delegate — an outside program on this machine that does the whole task on its own — for one large, well-specified change. Only a name your instructions list; it cannot ask the person anything"},` + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + `"no_progress":{"type":"integer","description":"Optional. Tool calls in a row that may add nothing before it is stopped as stuck (default ` + strconv.Itoa(taskNoProgress) + `). Raise it for work that must read a great deal first"}` + `},"required":["title","summary","brief","deliverable","acceptance"],"additionalProperties":false}` @@ -215,6 +217,7 @@ type taskArguments struct { DependsOn []uint64 `json:"depends_on"` Wide bool `json:"wide"` Model string `json:"model"` + Via string `json:"via"` MaxSteps int `json:"max_steps"` NoProgress int `json:"no_progress"` } @@ -338,6 +341,10 @@ type taskSpec struct { modelWord string model string modelOptions []string + // via is the delegate this work is proposed for, empty for the conversation's + // own worker (delegate_door.go). It is resolved at staging, so a name this + // machine has no delegate for is a refusal before any card goes up. + via string // effort is the rung this node's workers ask the model for, empty when // nobody has set one and the ladder's next rung down decides // (internal/effort). It travels the same road `model` travels — set at @@ -639,6 +646,17 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged if refusal := a.refuseProposedTask(spec); refusal != "" { return bare.Settled(refusal, true) } + // A DELEGATE IS RESOLVED BEFORE THE CARD, so a name this machine has no + // delegate for is answered with the names it has and nobody is asked to + // approve work that could not start (delegate_door.go). + if spec.via != "" { + if _, err := a.delegateFor(spec.via); err != nil { + return bare.Settled(err.Error(), true) + } + if a.config.InTask || chatRunEngine == nil { + return bare.Settled("a delegate can only be given work from the conversation, and only where the run road is linked", true) + } + } // WHICH HANDS THE WORK LEAVES ON, settled before anybody is asked anything // (taskmodel.go). A word that names no model this install has is a refusal // the model can act on — it names the nearest ids — and one that names @@ -814,10 +832,18 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // already underway is refused here ([standsElsewhereError]); any other // failure of the run road falls through to the shipped engine, exactly as a // typed /task does, and that engine cuts its own copy from the same stand. - if bashBeltAsked() && chatRunEngine != nil && !a.config.InTask { + if (bashBeltAsked() || spec.via != "") && chatRunEngine != nil && !a.config.InTask { a.mu.Lock() question := questionAtTaskHandoff(a.owedAsks) a.mu.Unlock() + var via *delegate.Manifest + if spec.via != "" { + m, err := a.delegateFor(spec.via) + if err != nil { + return err.Error(), true, nil + } + via = &m + } description := composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. // This context is the turn's, and the turn cancels it on its way out @@ -831,17 +857,28 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // life belonging to the PROCESS, and a run whose room had closed went on // spending with nobody able to read it or stop it. joined := a.beltRunStandsOn(p.stand) - err := a.startKnownTaskRun(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, p.stand, question) + stand := p.stand + if via != nil { + stand = delegateStand(stand.dir, *via) + } + err := a.startKnownTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via) if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { return refusal.Error(), true, nil } if err == nil { receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) - if joined { + if via != nil { + receipt = withReport(receipt, "It is "+via.Name+"'s: the program works alone in the copy and lands when it ends.") + } else if joined { receipt = withReport(receipt, "It joined the work already underway and shares its copy.") } return receipt, false, nil } + if via != nil { + // A DELEGATE HAS NO OTHER ROAD. The shipped engine would seat a worker + // of its own on this brief, which is not what was asked for. + return "the delegate could not start: " + err.Error(), true, nil + } } state := graph.admit(p.id, spec) admitted = true @@ -974,6 +1011,7 @@ func parseTaskArguments(args json.RawMessage) (taskSpec, string) { // proposed before it existed. wide: parsed.Wide, modelWord: strings.TrimSpace(parsed.Model), + via: strings.TrimSpace(parsed.Via), maxSteps: parsed.MaxSteps, noProgress: parsed.NoProgress, } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index d2ad1012d..1723ae17f 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -40,6 +40,7 @@ import ( "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/roles" ) @@ -113,6 +114,12 @@ type RunSpec struct { CompleterFor func(model string) Completer // OnSpend observes the reconciled cumulative run spend while work is live. OnSpend func(float64) + // Delegate, when set, is the outside program this run's root task is handed + // to instead of a bash worker (delegate_door.go). APIKey is the person's key + // the program is handed through its manifest's `{{key}}`. Nil is every run + // the conversation's own workers drive. + Delegate *delegate.Manifest + APIKey string } // RunLimit is which bound a person set ended a run. The engine's outcome word @@ -228,6 +235,12 @@ type beltRun struct { // It is the same reading the row published to the surface carries, so the // tree and the row cannot disagree about when the work began. born time.Time + // delegate is the outside program this run's root is handed to, nil for a + // run the conversation's own workers drive; startSha is the commit the copy + // stood on the moment the run began, the point a tree delegate's commits are + // squashed back to at landing (delegate_door.go). + delegate *delegate.Manifest + startSha string } // startTaskRun is StartTask's second road, taken whenever the bash belt is asked @@ -272,6 +285,15 @@ func (e standsElsewhereError) Error() string { // An approved hand-off under the bash belt belongs to the run store and never to the session tree. func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string) error { + return a.startKnownTaskRunVia(ctx, id, title, brief, dependsOn, stand, question, nil) +} + +// startKnownTaskRunVia is [Agent.startKnownTaskRun] with the worker named: nil +// is the conversation's own bash worker, and a manifest is the outside program +// the root task is handed to (delegate_door.go). One body serves both because a +// delegated run IS a run — the store, the copy, the row and the stop road are +// the same — and a second body would be two roads that must stay in step. +func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Manifest) error { engine := chatRunEngine g := a.graph() if engine == nil || g == nil || g.planPath() == "" { @@ -293,6 +315,15 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s // parent — and the supervisor already turning finds it ready on its next // pass. Nothing opens a second store. if live != nil { + // A DELEGATE NEVER JOINS A RUN AND NOTHING JOINS A DELEGATE'S. A delegated + // run is a run of one task whose worker owns the whole copy for the hour; + // a second task beside it would be a bash worker typing in the tree the + // program is editing, and a delegate added under a live run would be a + // second program in the same tree. Both are refused with what is underway. + if via != nil || live.delegate != nil { + return errors.New("work is already underway in a copy of " + live.ground + + "; a delegate runs alone, so propose it again when that work has ended") + } if canonicalPath(stand.dir) != live.ground { return standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} } @@ -335,7 +366,15 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, workspace: tree.dir, ground: canonicalPath(stand.dir), tree: tree, cut: cut, - born: born, + born: born, delegate: via, + } + if via != nil && via.LandsTree() { + // THE SQUASH POINT IS READ NOW, off the copy itself, before the program + // has written a byte: whatever the ground ladder put under this copy is + // under this commit, and everything the program commits is above it. + if head, err := git(tree.dir, "rev-parse", "HEAD"); err == nil { + run.startSha = strings.TrimSpace(head) + } } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because @@ -390,6 +429,8 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { WorkModel: workSeat, PlanModel: planSeat, CompleterFor: func(string) Completer { return a.beltRunCompleter() }, + Delegate: run.delegate, + APIKey: a.config.APIKey, } } @@ -521,7 +562,12 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun _ = run.store.Close() return } - landing := a.landBeltRun(ctx, engine, run) + var landing RunLanding + if run.delegate != nil { + landing = a.landDelegateRun(run, summary) + } else { + landing = a.landBeltRun(ctx, engine, run) + } // A LANDING GETS ONE LAST READING before its digest is composed. The call // owns the short beltRunSummaryDeadline: refusal, malformed output, or a // slow provider leaves the stored reading alone and cannot hold the run @@ -572,6 +618,14 @@ func (a *Agent) landBeltRun(ctx context.Context, engine RunEngine, run *beltRun) } return RunLanding{} } + return a.bringBeltRunHome(run, landing) +} + +// bringBeltRunHome is the second half of a run's landing, shared by the engine's +// landing and a delegate's: the copy's branch merged into the ground it was cut +// from, the person's unfinished work carried across or the branch kept and the +// files named, the copy given back, and the homecoming written on the run's page. +func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { if run.tree.dir == "" { return landing } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 75ac352da..3bdae4823 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3090,6 +3090,9 @@ func newApp(ctx context.Context, opts Options) *app { // and from then on [app.retitle] sends it again only when it moves. a.titleSent = terminalTitle(a) a.refreshCreditWarnings() + // THE DELEGATE ROWS GO ON THE TABLE BEFORE THE FIRST FRAME, so the picker + // and /help list them from the first keystroke (delegate.go). + a.installDelegates() return a } @@ -7225,6 +7228,13 @@ func (a *app) slash(line string) tea.Cmd { a.openHarness() return nil + case "delegate": + // THE OUTSIDE PROGRAMS A TASK CAN BE HANDED TO WHOLE, listed, or one of + // them run on the words after its name (delegate.go). The installed rows + // are also commands in their own right and dispatch below, under the + // default arm, because they are not in this switch's literal table. + return a.openDelegate(rest) + case "subharness": a.noticeEvent(eventSubharnessOpened) // THE PROGRAMS THIS CONVERSATION CAN RUN, as a filterable list, and the @@ -7413,6 +7423,12 @@ func (a *app) slash(line string) tea.Cmd { if a.droppedLine(line) { return a.edited() } + // AN INSTALLED DELEGATE IS A COMMAND OF ITS OWN (delegate.go). It is + // asked for last, after the literal table, so nothing a delegate is + // called can shadow a word this surface already answers to. + if isDelegateCommand(name) { + return a.runDelegateCommand(name, rest) + } a.note(unknownCommandWord(name)) return nil } @@ -7493,6 +7509,8 @@ func (a *app) takeUp(conv Conversation, whole bool) { // ANSWERING ABOUT SOMEWHERE ELSE (offloop.go). This is the one place the // agent in front changes, so it is the one place that counter moves. a.frontGen++ + // The delegate rows are the conversation's, so they follow it (delegate.go). + a.installDelegates() } a.file = conv.SessionFile // AND THE SENDS ARE NOT RE-KEYED HERE. They are held under the drafts lane's diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index cf38cec0e..6a9477d7f 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -231,6 +231,12 @@ var commands = []command{ {name: "subharness", desc: "the programs you can run · type to filter · enter opens its card", alias: []string{"sub"}}, {name: "subharness", args: "", desc: "…straight to that one's card"}, + // THE DELEGATES: outside programs a whole task can be handed to. Each + // installed one is a row of its own — `/swe-pro ` — generated at + // launch from its manifest (delegate.go), so this row is the list and the + // long form, never the only door. + {name: "delegate", desc: "the outside programs a task can be handed to whole", alias: []string{"delegates"}}, + {name: "delegate", args: " ", desc: "…hands that brief to the named one"}, // WHAT IT KNOWS ABOUT YOU, and the two ways to change it. They sit beside // /harness because they answer the neighbouring question — one is what this // conversation has learned to DO, these are what it has been told about YOU diff --git a/internal/tui3/delegate.go b/internal/tui3/delegate.go new file mode 100644 index 000000000..bed815bb8 --- /dev/null +++ b/internal/tui3/delegate.go @@ -0,0 +1,198 @@ +package tui3 + +// DELEGATES ON THE SURFACE: one command row per installed delegate, generated at +// launch from the registry the conversation holds, and `/delegate`, the list of +// them (docs/design/delegate/DESIGN.md). A delegate row runs like `/task`: the +// words after it are the brief, the same door opens, a run starts, the turn +// goes on. +// +// THE ROWS ARE APPENDED TO THE LIVE TABLE AND NEVER TO THE LITERAL. The static +// table keeps its static gate (manual_test.go walks it); the rows here exist +// only while their delegate does, and the manual law for them is checked where +// the row comes into existence, by the loader, against the delegate's own page. + +import ( + "context" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// delegateAgent is what this surface asks a conversation about delegates: the +// list, and the door. +type delegateAgent interface { + Delegates() session.DelegateReport + StartDelegate(context.Context, string, string) (uint64, string, string, error) +} + +func (a *app) delegateSeam() (delegateAgent, bool) { + if a.agent == nil { + return nil, false + } + agent, ok := a.agent.(delegateAgent) + return agent, ok +} + +// The words `/delegate` says when there is nothing to list. +const ( + delegateNothingWord = "no delegates here — a delegate is an outside program codeaf can hand a whole task to; a manifest under ~/.codeaf/delegates adds one" + delegateUsageWordTail = " · hands the whole task to that program" +) + +// baseCommands is the literal table as this file found it, so the live table +// can be rebuilt from it however many times a surface installs rows: a second +// install replaces the first rather than stacking on it. +var ( + baseCommands = append([]command(nil), commands...) + delegateRowsMu sync.Mutex + delegateRows map[string]bool + // delegateCollisions is every delegate the last install left off the table + // for wearing a built-in command's name, in the sentence `/delegate` draws. + delegateCollisions []string +) + +// installDelegateCommands rebuilds the live command table as the literal plus +// one row per delegate. A name that collides with a built-in row or alias is +// left out — the loader refused nothing, so the collision is said here, in the +// note the caller draws — because [checkCommands]'s law holds for generated +// rows too: a word may not mean two things. +func installDelegateCommands(rows []session.DelegateRow) []string { + delegateRowsMu.Lock() + defer delegateRowsMu.Unlock() + table := append([]command(nil), baseCommands...) + installed := map[string]bool{} + var refused []string + for _, row := range rows { + name := strings.TrimSpace(row.Name) + if name == "" { + continue + } + candidate := command{name: name, args: "", desc: row.Description} + if err := checkCommands(append(append([]command(nil), table...), candidate)); err != nil || baseNames()[name] { + refused = append(refused, name+": its name is already a command here — not added") + continue + } + table = append(table, candidate) + installed[name] = true + } + commands = table + delegateRows = installed + delegateCollisions = refused + return refused +} + +// collisions is what the last install would not seat. +func delegateCollisionLines() []string { + delegateRowsMu.Lock() + defer delegateRowsMu.Unlock() + return append([]string(nil), delegateCollisions...) +} + +// baseNames is every word the literal table answers to: names and aliases. +func baseNames() map[string]bool { + names := map[string]bool{} + for _, c := range baseCommands { + names[c.name] = true + for _, word := range c.alias { + names[word] = true + } + } + return names +} + +// isDelegateCommand says whether a typed word is one of the installed rows. +func isDelegateCommand(name string) bool { + delegateRowsMu.Lock() + defer delegateRowsMu.Unlock() + return delegateRows[name] +} + +// installDelegates reads the conversation's delegates and puts their rows on +// the table. It runs when the surface is built and again when the conversation +// in front changes, because the registry is the conversation's. A hosted +// surface installs nothing: the registry lives on the far machine, and a row +// that opened a door there would be a command about somewhere else. +func (a *app) installDelegates() { + if a.hosted() { + installDelegateCommands(nil) + return + } + agent, ok := a.delegateSeam() + if !ok { + installDelegateCommands(nil) + return + } + // A collision is not said here — the surface is still being built and has + // nowhere to draw a line yet — it is said where the person will look for + // the missing row, on `/delegate`. + installDelegateCommands(agent.Delegates().Rows) +} + +// runDelegateCommand is `/ `: the brief goes to that delegate +// through the same door `/task` opens, and the answer lands as a task start. +func (a *app) runDelegateCommand(name, brief string) tea.Cmd { + brief = strings.TrimSpace(brief) + if brief == "" { + a.note("usage: /" + name + delegateUsageWordTail) + return nil + } + agent, ok := a.delegateSeam() + if !ok { + a.note("could not start the task · this session has no delegate door") + return nil + } + return a.startTaskDoorVia(brief, func(ctx context.Context) (uint64, string, string, error) { + return agent.StartDelegate(ctx, name, brief) + }) +} + +// openDelegate is `/delegate`: bare, the list; with a name and words, the +// delegate's own row run on those words. +func (a *app) openDelegate(rest string) tea.Cmd { + if a.hosted() { + a.note(a.remoteProfileWord("delegates")) + return nil + } + if name, brief, _ := strings.Cut(strings.TrimSpace(rest), " "); name != "" { + if !isDelegateCommand(name) { + a.note(delegateUnknownWord(name)) + return nil + } + return a.runDelegateCommand(name, brief) + } + agent, ok := a.delegateSeam() + if !ok { + a.note(delegateNothingWord) + return nil + } + report := agent.Delegates() + report.Refused = append(report.Refused, delegateCollisionLines()...) + if len(report.Rows) == 0 && len(report.Absent) == 0 && len(report.Refused) == 0 { + a.note(delegateNothingWord) + return nil + } + lines := make([]string, 0, len(report.Rows)+len(report.Absent)+len(report.Refused)) + for _, row := range report.Rows { + lands := "lands its work on your branch" + if row.Lands == "text" { + lands = "answers in the conversation" + } + lines = append(lines, "/"+row.Name+" · "+row.Description+" · "+lands+" · "+row.Bin) + } + for _, absent := range report.Absent { + lines = append(lines, "not here: "+absent) + } + for _, refusal := range report.Refused { + lines = append(lines, "not added: "+refusal) + } + a.note(strings.Join(lines, "\n")) + return nil +} + +// delegateUnknownWord answers `/delegate ` for a name no row carries. +func delegateUnknownWord(name string) string { + return "no delegate is called " + name + " · /delegate lists the ones here" +} diff --git a/internal/tui3/delegate_test.go b/internal/tui3/delegate_test.go new file mode 100644 index 000000000..dcc6c1171 --- /dev/null +++ b/internal/tui3/delegate_test.go @@ -0,0 +1,152 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// delegateFake is the scripted session with the delegate door on it: a list of +// rows, and a StartDelegate that records what it was asked. +type delegateFake struct { + *fakeAgent + report session.DelegateReport + started []string + fail error +} + +func (d *delegateFake) Delegates() session.DelegateReport { return d.report } + +func (d *delegateFake) StartDelegate(_ context.Context, name, brief string) (uint64, string, string, error) { + d.started = append(d.started, name+": "+brief) + if d.fail != nil { + return 0, "", "", d.fail + } + return 7, "the title", "", nil +} + +func newDelegateApp(t *testing.T, rows ...session.DelegateRow) (*app, *delegateFake) { + t.Helper() + fake := &delegateFake{fakeAgent: &fakeAgent{}, report: session.DelegateReport{Rows: rows}} + a := newTestApp(fake) + t.Cleanup(func() { installDelegateCommands(nil) }) + return a, fake +} + +func TestAnInstalledDelegateIsACommandRowThatOpensTheDoor(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "tree", Bin: "/usr/local/bin/fake"}) + if !isDelegateCommand("fake") { + t.Fatal("the delegate's row was not installed") + } + named := false + for _, c := range commands { + if c.name == "fake" && c.args == "" && c.desc == "a fake delegate" { + named = true + } + } + if !named { + t.Fatal("the live command table has no /fake row") + } + cmd := a.slash("/fake rewrite the auth middleware") + if cmd == nil { + t.Fatal("/fake opened no door") + } + if msg, ok := cmd().(taskStartedMsg); !ok || msg.id != "7" || msg.title != "the title" || msg.brief != "rewrite the auth middleware" { + t.Fatalf("the door answered %+v", cmd()) + } + if len(fake.started) != 1 || fake.started[0] != "fake: rewrite the auth middleware" { + t.Fatalf("StartDelegate was asked %v", fake.started) + } + // And the long form is the same door. + if cmd := a.slash("/delegate fake do the other thing"); cmd == nil { + t.Fatal("/delegate opened no door") + } else { + cmd() + } + if len(fake.started) != 2 || fake.started[1] != "fake: do the other thing" { + t.Fatalf("StartDelegate was asked %v", fake.started) + } +} + +func TestADelegateRowWithNoBriefSaysItsUsage(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate"}) + if cmd := a.slash("/fake"); cmd != nil { + t.Fatal("a bare delegate command started something") + } + if len(fake.started) != 0 { + t.Fatalf("StartDelegate was asked %v", fake.started) + } + if got := plain(frame(a)); !strings.Contains(got, "usage: /fake ") { + t.Fatalf("no usage line:\n%s", got) + } +} + +func TestSlashDelegateListsTheRowsAndTheOnesNotHere(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "text", Bin: "/opt/fake"}) + fake.report.Absent = []string{"swe-pro: swe-pro is not on this machine"} + fake.report.Refused = []string{"broken: its manual page does not say /broken — not added"} + a.width = 200 + if cmd := a.slash("/delegate"); cmd != nil { + t.Fatal("/delegate started something") + } + got := plain(frame(a)) + for _, want := range []string{"/fake ", "a fake delegate", "answers in the conversation", "not here: swe-pro", "not added: broken"} { + if !strings.Contains(got, want) { + t.Fatalf("/delegate did not say %q:\n%s", want, got) + } + } +} + +func TestSlashDelegateWithNothingInstalledSaysSo(t *testing.T) { + a, _ := newDelegateApp(t) + a.width = 200 + a.slash("/delegates") + if got := plain(frame(a)); !strings.Contains(got, "no delegates here") { + t.Fatalf("no sentence for a machine with none:\n%s", got) + } + if isDelegateCommand("fake") { + t.Fatal("a row exists for a delegate nobody installed") + } + // A session with no delegate door at all says the same sentence. + bare := newTestApp(&fakeAgent{}) + bare.width = 200 + bare.slash("/delegate") + if got := plain(frame(bare)); !strings.Contains(got, "no delegates here") { + t.Fatalf("no sentence for a session without the door:\n%s", got) + } +} + +func TestADelegateNamedLikeABuiltInCommandIsNotInstalled(t *testing.T) { + a, _ := newDelegateApp(t, session.DelegateRow{Name: "task", Description: "an impostor"}) + if isDelegateCommand("task") { + t.Fatal("a delegate shadowed /task") + } + a.width = 200 + a.slash("/delegate") + if got := plain(frame(a)); !strings.Contains(got, "not added: task: its name is already a command here") { + t.Fatalf("the collision was not said:\n%s", got) + } + for _, c := range commands { + if c.name == "task" && c.desc == "an impostor" { + t.Fatal("the impostor row is on the table") + } + } +} + +func TestAHostedSurfaceInstallsNoDelegateRowsAndRefusesTheList(t *testing.T) { + fake := &delegateFake{fakeAgent: &fakeAgent{}, report: session.DelegateReport{Rows: []session.DelegateRow{{Name: "fake", Description: "a fake delegate"}}}} + a := newTestApp(fake) + t.Cleanup(func() { installDelegateCommands(nil) }) + a.host = "spark" + a.installDelegates() + if isDelegateCommand("fake") { + t.Fatal("a hosted surface installed a row for the far machine's delegate") + } + a.width = 200 + a.slash("/delegate") + if got := plain(frame(a)); !strings.Contains(got, "spark owns delegates") { + t.Fatalf("the hosted refusal is missing:\n%s", got) + } +} diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 0f3ae82d5..084e9e540 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -181,6 +181,11 @@ const ( // always did — `there is no command called /x · / lists them`, on home's line. func homeFate(word, rest string) string { rest = strings.TrimSpace(rest) + // AN INSTALLED DELEGATE'S ROW IS `/task` WITH THE WORKER CHOSEN (delegate.go), + // and it needs what a /task with a brief needs: a conversation to start in. + if isDelegateCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { + return fateNeedsChat + } switch canonicalCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { case "model": return fateTargetModel @@ -200,7 +205,7 @@ func homeFate(word, rest string) string { return fateFresh case "land", "workspace": return fateBehind - case "files", "permissions", "connect", "harness", "subharness", "autonomy", + case "files", "permissions", "connect", "harness", "subharness", "delegate", "autonomy", "copy", "select", "rewind", "compact", "export", "drafts", "manual", "folder": // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a // turn of a conversation now (manualcmd.go), and a turn needs one. As diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 4e29b7e00..6491fa836 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -129,6 +129,16 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { // naming a pause that no longer exists would be the surface describing // machinery rather than work. func (a *app) startTaskDoor(door taskCommandAgent, brief string, solo bool) tea.Cmd { + return a.startTaskDoorVia(brief, func(ctx context.Context) (uint64, string, string, error) { + return door.StartTask(ctx, brief, solo) + }) +} + +// startTaskDoorVia is [app.startTaskDoor] with the door itself handed in: the +// notes said before the spend and the start message are the same whichever +// door opens — the conversation's own worker or a delegate (delegate.go) — and +// two copies of the preflight would be two places for one line to drift. +func (a *app) startTaskDoorVia(brief string, start func(context.Context) (uint64, string, string, error)) tea.Cmd { ctx := a.ctx // WHICH CONVERSATION IS SAYING THIS, read HERE rather than when the answer // lands: the door is opened on a goroutine and the window may have moved on @@ -165,7 +175,7 @@ func (a *app) startTaskDoor(door taskCommandAgent, brief string, solo bool) tea. a.note(line) } return func() tea.Msg { - id, title, note, err := door.StartTask(ctx, brief, solo) + id, title, note, err := start(ctx) return taskStartedMsg{ kind: "single", id: strconv.FormatUint(id, 10), title: title, err: err, note: note, brief: brief, conv: conv, From b2fa8543a7eb2baaa0c62cbfc1fc133571cbbaa0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:09:29 -0400 Subject: [PATCH 012/195] delegate: the chat's manual layers each installed delegate's own page over the packed corpus Wave 3 of docs/design/delegate/DESIGN.md. A delegate ships its page beside its manifest; the loader keeps the text and the conversation's manual tool answers from the packed corpus with those pages layered under `delegate-`, so "what does /swe-pro do" is answered from swe-pro's page and nowhere else. The overlay adds and never replaces. A ready-to-copy swe-pro manifest and page sit under docs/delegates. Wave 4 needs no wire: a hosted surface installs no rows and /delegate says whose delegates they are. The design doc records the decisions taken while building. Co-Authored-By: Claude Fable 5.1 --- docs/delegates/swe-pro.json | 9 +++ docs/delegates/swe-pro.md | 40 ++++++++++++ docs/design/delegate/DESIGN.md | 22 +++---- internal/delegate/delegate.go | 5 +- internal/delegate/load.go | 37 ++++++++--- internal/manual/overlay.go | 90 ++++++++++++++++++++++++++ internal/manual/overlay_test.go | 55 ++++++++++++++++ internal/session/delegate_door.go | 13 ++++ internal/session/delegate_door_test.go | 29 +++++++++ internal/session/session.go | 6 ++ internal/session/tools_manual.go | 22 +++---- internal/session/tools_manual_bound.go | 4 +- 12 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 docs/delegates/swe-pro.json create mode 100644 docs/delegates/swe-pro.md create mode 100644 internal/manual/overlay.go create mode 100644 internal/manual/overlay_test.go diff --git a/docs/delegates/swe-pro.json b/docs/delegates/swe-pro.json new file mode 100644 index 000000000..c10baa7b8 --- /dev/null +++ b/docs/delegates/swe-pro.json @@ -0,0 +1,9 @@ +{ + "name": "swe-pro", + "description": "an autonomous coding agent for one large, well-specified change", + "bin": "swe-pro", + "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], + "env": { "OPENROUTER_API_KEY": "{{key}}" }, + "lands": "tree", + "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } +} diff --git a/docs/delegates/swe-pro.md b/docs/delegates/swe-pro.md new file mode 100644 index 000000000..25ca17635 --- /dev/null +++ b/docs/delegates/swe-pro.md @@ -0,0 +1,40 @@ +# swe-pro + +## What /swe-pro does — hand one large change to swe-pro, an autonomous coding agent + +`/swe-pro ` hands the whole brief to **swe-pro**, an autonomous coding agent that +runs on this machine as its own program. codeaf gives it a copy of your folder, this +conversation's dollar and time limits, and your OpenRouter key; swe-pro maps the +repository, pins a test command, edits, runs the tests, and freezes a candidate it has +verified. When it ends, its work is squashed into one commit on your branch. + +Use it for one change that is big enough to want an agent of its own for an hour and is +specified well enough that nobody will be asked anything: a rewrite across a package, a +migration, a feature with tests. A change you would do in a few steps is not worth it. + +## What swe-pro cannot do — it cannot ask, it has no step cap, it reports its own checking + +swe-pro runs unattended. A question its model tries to ask is turned down inside the +program, so put everything it would stop and ask into the brief: the files, the +constraints, the wrong answer to avoid, how to check the result. + +It has no step cap. It is held to the dollar and hour ceilings codeaf hands it on its +command line, and codeaf stops it from outside at the same limits. On its task page the +step count is what it reported. + +Its result keeps two things apart: what its model claimed it did, and what swe-pro itself +observed when it ran the project's tests on the frozen tree. Read the second for "did it +work". + +## Where its work goes and what it costs + +swe-pro commits after every edit inside its copy. At landing those commits are squashed +into one commit whose subject is the task's title and whose body is swe-pro's account of +the ending, and that commit is merged into your folder. Its spending is folded into this +conversation's total as it reports it, under the name `swe-pro` on the spending page. + +## Installing it — why there is no /swe-pro here + +The row exists only where the `swe-pro` binary is on PATH. Put this file and +`swe-pro.json` in `~/.codeaf/delegates/` and start codeaf again. `/delegate` says what it +found. diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 00cb24dbf..443e3eea2 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -1,8 +1,8 @@ # Delegates — handing a task to an outside program — DESIGN (draft) *2026-09-21, revised 2026-09-22. Written against `dev @ 17ae56d34` and -`swe-pro-go @ 5793499` (branch `zeropoint95/improvements`, PR #30). Nothing is -built on the codeaf side yet. Every swe-pro change this asked for has landed.* +`swe-pro-go @ 5793499` (branch `zeropoint95/improvements`, PR #30). Waves 1 to +4 are built on this branch; every swe-pro change this asked for has landed.* ## In one paragraph @@ -37,6 +37,10 @@ at the person's discretion. | 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 | +| 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" @@ -298,10 +302,10 @@ SIGTERM still writes the terminal record, and `--` before the goal parses. | # | 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 swe-pro stream replayed through the reader | -| **2** | the door: task row carries `via`; `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; swe-pro's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to swe-pro", "what does /swe-pro do", "why can't the delegate ask me", "difference between /harness and /swe-pro" | -| **4** | hosted: the row crosses `internal/remote`; until then `--host` refuses with one sentence | `internal/remote` wire tests | +| **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 swe-pro 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; swe-pro's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to swe-pro", "what does /swe-pro do", "why can't the delegate ask me", "difference between /harness and /swe-pro" | +| **4** ✓ | hosted: a `--host` surface installs no rows and `/delegate` answers ` owns delegates · change it on that machine`; nothing crosses `internal/remote`, because the registry and the run are the far machine's | `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 @@ -312,9 +316,3 @@ can type. 1. **Who picks swe-pro's models.** Today its own `--high` default. The manifest could pass codeaf's work seat, but swe-pro speaks OpenRouter slugs and the seat may be on another lane. First cut: the manifest's argv, no seat. -2. **Is `via` on the task or the run.** First cut: a delegated task is a run of - one task and is never split. Mixing bash workers and a delegate in one run - is a later question. -3. **Stage records on the task page.** The page expects a step to be a command - and an observation. swe-pro's tool parts fit; its `stage` records do not. - Either the page learns a stage row, or stages feed the live step only. diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index b8fd7f4e6..57895dfba 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -77,9 +77,12 @@ type Manifest struct { Limits Limits `json:"limits,omitempty"` // Path is the manifest file this was read from, and ManualPath the page - // beside it. Both are the loader's, never the file's. + // beside it; Manual is that page's text, kept so the chat's manual can + // layer it over the packed corpus (internal/manual's overlay). All three + // are the loader's, never the file's. Path string `json:"-"` ManualPath string `json:"-"` + Manual string `json:"-"` // BinPath is the program as it resolved at load time. The loader fills it; // a manifest whose Bin is not found is not in the registry at all. BinPath string `json:"-"` diff --git a/internal/delegate/load.go b/internal/delegate/load.go index dada5dad6..e8264c913 100644 --- a/internal/delegate/load.go +++ b/internal/delegate/load.go @@ -88,10 +88,12 @@ func Load(dir string) (*Registry, error) { continue } manifest.ManualPath = filepath.Join(dir, stem+".md") - if reason := checkManualPage(manifest); reason != "" { + page, reason := readManualPage(manifest) + if reason != "" { registry.refusals = append(registry.refusals, Refusal{Name: stem, Reason: reason}) continue } + manifest.Manual = page bin, err := resolveBin(manifest.Bin, dir) if err != nil { registry.absent = append(registry.absent, Absent{Name: stem, Bin: manifest.Bin}) @@ -124,21 +126,21 @@ func readManifest(path string) (Manifest, error) { return manifest, nil } -// checkManualPage is the load-time manual law. The page must exist and must +// readManualPage is the load-time manual law. The page must exist and must // say the command, because the chat answers "what does / do" from it and -// nowhere else. -func checkManualPage(m Manifest) string { +// nowhere else. It answers the page's text, or the refusal. +func readManualPage(m Manifest) (string, string) { page, err := os.ReadFile(m.ManualPath) if errors.Is(err, os.ErrNotExist) { - return fmt.Sprintf("no manual page beside it — write %s.md saying what /%s does — not added", m.Name, m.Name) + return "", fmt.Sprintf("no manual page beside it — write %s.md saying what /%s does — not added", m.Name, m.Name) } if err != nil { - return "its manual page could not be read: " + err.Error() + return "", "its manual page could not be read: " + err.Error() } if !strings.Contains(string(page), "/"+m.Name) { - return fmt.Sprintf("its manual page does not say /%s — not added", m.Name) + return "", fmt.Sprintf("its manual page does not say /%s — not added", m.Name) } - return "" + return strings.TrimSpace(strings.ReplaceAll(string(page), "\r\n", "\n")), "" } // resolveBin finds the program. A name with no separator is looked up on @@ -212,6 +214,25 @@ func (r *Registry) Refusals() []Refusal { return append([]Refusal(nil), r.refusals...) } +// PagePrefix is what a delegate's manual page is called in the chat's corpus: +// `delegate-`, so a delegate can never wear a packed page's name. +const PagePrefix = "delegate-" + +// Pages is every runnable delegate's manual page, keyed by its corpus name, +// for the chat's manual to layer over its own (internal/manual's overlay). +func (r *Registry) Pages() map[string]string { + if r == nil { + return nil + } + pages := make(map[string]string, len(r.entries)) + for name, m := range r.entries { + if m.Manual != "" { + pages[PagePrefix+name] = m.Manual + } + } + return pages +} + // Empty is a registry with nothing runnable, nothing absent and nothing // refused: the machine has no delegates at all, which is most machines. func (r *Registry) Empty() bool { diff --git a/internal/manual/overlay.go b/internal/manual/overlay.go new file mode 100644 index 000000000..1b705c9c7 --- /dev/null +++ b/internal/manual/overlay.go @@ -0,0 +1,90 @@ +package manual + +// AN OVERLAY IS THE PACKED CORPUS PLUS PAGES THAT EXIST ONLY ON THIS MACHINE. +// The chat's manual is compiled into the binary and never changes at run time, +// which is the right property for every page about codeaf itself. A delegate +// (internal/delegate) is not codeaf: it is an outside program installed by the +// person, with a command row that exists only where it does, and the page that +// explains it ships beside its manifest. So the corpus the chat answers from is +// the packed one with those pages layered over it — searched, listed and read +// with the packed pages and by the same law — and the packed corpus itself is +// untouched. + +import ( + "errors" + "os" + "path" + "sort" + "strings" +) + +// layeredFiles is a corpusFiles over another, with pages of its own that are +// listed and read as though they sat in the same folder. A page whose name is +// already in the base is the base's: the overlay adds and never replaces, so +// nothing installed on a machine can rewrite what the binary says about itself. +type layeredFiles struct { + base corpusFiles + dir string + extra map[string]string +} + +func (f layeredFiles) Glob(pattern string) ([]string, error) { + entries, err := f.base.Glob(pattern) + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, entry := range entries { + seen[entry] = true + } + names := make([]string, 0, len(f.extra)) + for name := range f.extra { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + entry := path.Join(f.dir, name+".md") + if !seen[entry] { + entries = append(entries, entry) + } + } + return entries, nil +} + +func (f layeredFiles) ReadFile(name string) ([]byte, error) { + if data, err := f.base.ReadFile(name); err == nil { + return data, nil + } + if path.Dir(name) == f.dir { + if text, ok := f.extra[strings.TrimSuffix(path.Base(name), ".md")]; ok { + return []byte(text), nil + } + } + return nil, errors.Join(os.ErrNotExist, errors.New("manual: no page "+name)) +} + +// WithPages is this corpus with extra pages layered over it, keyed by page +// name (no folder, no `.md`). It is a new corpus, built lazily on its first +// question like any other, and the receiver is not changed. Extra pages follow +// every rule the packed ones do — a `# ` title, `## ` headings as the search +// index — because they are indexed by the same code. A name the packed corpus +// already has is left to the packed page. +// +// No pages is the receiver itself, so a caller may ask unconditionally. +func (c *Corpus) WithPages(extra map[string]string) *Corpus { + if len(extra) == 0 { + return c + } + pages := make(map[string]string, len(extra)) + for name, text := range extra { + name = strings.TrimSpace(name) + if name == "" || strings.TrimSpace(text) == "" { + continue + } + pages[name] = text + } + if len(pages) == 0 { + return c + } + return newCorpus(layeredFiles{base: c.files, dir: path.Dir(c.glob), extra: pages}, c.glob) +} diff --git a/internal/manual/overlay_test.go b/internal/manual/overlay_test.go new file mode 100644 index 000000000..5413a9954 --- /dev/null +++ b/internal/manual/overlay_test.go @@ -0,0 +1,55 @@ +package manual + +import ( + "strings" + "testing" +) + +// A page layered over the chat corpus is listed, read and searched beside the +// packed pages, and the packed corpus is not changed by it. +func TestAnOverlayPageIsSearchedReadAndListedBesideThePackedOnes(t *testing.T) { + page := "# swe-pro\n\n## What /swe-pro does — hand a large change to swe-pro\n\nswe-pro is an autonomous coding agent. Type `/swe-pro `.\n\n## What swe-pro cannot do\n\nIt cannot ask you anything.\n" + layered := Chat().WithPages(map[string]string{"delegate-swe-pro": page}) + if layered == Chat() { + t.Fatal("WithPages with a page answered the same corpus") + } + text, ok := layered.Page("delegate-swe-pro") + if !ok || !strings.Contains(text, "autonomous coding agent") { + t.Fatalf("the overlay page cannot be read: %v %q", ok, text) + } + if !layered.Mentions("/swe-pro") { + t.Fatal("the layered corpus does not mention the delegate's command") + } + found := false + for _, name := range layered.Pages() { + found = found || name == "delegate-swe-pro" + } + if !found { + t.Fatalf("the overlay page is not listed: %v", layered.Pages()) + } + hits := layered.Search("what does /swe-pro do", 4) + if len(hits) == 0 || hits[0].Page != "delegate-swe-pro" { + t.Fatalf("the question did not reach the overlay page first: %+v", hits) + } + // And a packed page is still there, unchanged. + if _, ok := layered.Page("delegates"); !ok { + t.Fatal("the packed delegates page is gone from the layered corpus") + } + if _, ok := Chat().Page("delegate-swe-pro"); ok { + t.Fatal("the packed corpus learnt the overlay page") + } +} + +func TestAnOverlayNeverReplacesAPackedPageAndNoPagesIsTheSameCorpus(t *testing.T) { + if Chat().WithPages(nil) != Chat() { + t.Fatal("no pages answered a new corpus") + } + if Chat().WithPages(map[string]string{"empty": " "}) != Chat() { + t.Fatal("an empty page answered a new corpus") + } + layered := Chat().WithPages(map[string]string{"delegates": "# an impostor\n\n## nothing\n\nnothing\n"}) + text, _ := layered.Page("delegates") + if strings.Contains(text, "impostor") { + t.Fatal("an overlay replaced a packed page") + } +} diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index aaf9b756b..4f9465452 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -23,6 +23,7 @@ import ( "strings" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/manual" ) // DelegateRow is one delegate as a surface lists it: the command word, the @@ -105,6 +106,18 @@ var delegateFact = beltFact{ }, } +// chatManual is the manual this conversation answers from: the packed corpus, +// with every installed delegate's own page layered over it under +// `delegate-` (internal/manual's overlay). It is what makes "what does +// /swe-pro do" answerable from swe-pro's page and nowhere else, and it is built +// once per agent because the registry is read once per launch. +func (a *Agent) chatManual() *manual.Corpus { + a.manualOnce.Do(func() { + a.manualCorpus = manual.Chat().WithPages(a.config.Delegates.Pages()) + }) + return a.manualCorpus +} + // DelegateUnknownError is the refusal for a `via` naming no delegate this // machine can run. It names the ones it can, sorted, so the next attempt has // the words in front of it. diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index cf0f1f4a6..d0245cf56 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -194,3 +194,32 @@ func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { t.Fatal("a task node is told it may delegate") } } + +// The manual tool answers "what does /fake do" from the delegate's own page, +// layered over the packed corpus under `delegate-`, and lists it among +// the pages; a conversation with no delegates answers from the packed corpus +// alone. +func TestTheManualToolAnswersFromADelegatesOwnPage(t *testing.T) { + registry := installTestDelegate(t, "fake") + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = t.TempDir() + config.Delegates = registry + }) + tool := agent.manualTool() + text, refused, err := tool.Execute(context.Background(), []byte(`{"page":"delegate-fake"}`)) + if err != nil || refused { + t.Fatalf("the page read was refused: %v %v", refused, err) + } + if !strings.Contains(text, "## /fake") { + t.Fatalf("the page is not the delegate's own:\n%s", text) + } + if _, ok := agent.chatManual().Page("delegates"); !ok { + t.Fatal("the packed delegates page is gone from the layered corpus") + } + plain, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = t.TempDir() + }) + if _, refused, _ := plain.manualTool().Execute(context.Background(), []byte(`{"page":"delegate-fake"}`)); !refused { + t.Fatal("a conversation with no delegates read a delegate page") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 70ad33dca..5298f4046 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -32,6 +32,7 @@ import ( "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/manual" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/offpath" "github.com/Agent-Field/codeaf/internal/provider" @@ -3196,6 +3197,11 @@ type Agent struct { // held. beltMu sync.Mutex beltRun *beltRun + // manualCorpus is the manual this conversation answers from — the packed + // corpus with the installed delegates' pages over it — built once on first + // use ([Agent.chatManual]). + manualOnce sync.Once + manualCorpus *manual.Corpus // taskAnswers is the proposals a person owes an answer to, keyed by the id // the EventTaskProposal carried. It is consent's pending-id machinery for a // question whose CLOCK can be held: the wait ends on an answer, on an active diff --git a/internal/session/tools_manual.go b/internal/session/tools_manual.go index 67124b32a..ce097838e 100644 --- a/internal/session/tools_manual.go +++ b/internal/session/tools_manual.go @@ -80,18 +80,18 @@ func (a *Agent) manualTool() bare.Tool { // back is bounded (tools_manual_bound.go says why), and a heading // off the list that bound leaves behind returns that section whole. if name != "" { - text, found := manual.Chat().Page(name) + text, found := a.chatManual().Page(name) if !found { return "There is no manual page named " + name + ". The pages are:" + - manualPageList(), true, nil + a.manualPageList(), true, nil } if heading == "" { - return boundedPage(name, text), false, nil + return boundedPage(a.manualHeadings(name), text), false, nil } - section, found := manual.Chat().Section(name, heading) + section, found := a.chatManual().Section(name, heading) if !found { return "The page " + name + " has no section named " + heading + - ". Its sections are:" + boundedList(manualHeadings(name)), true, nil + ". Its sections are:" + boundedList(a.manualHeadings(name)), true, nil } return boundedSection(section.Body), false, nil } @@ -101,7 +101,7 @@ func (a *Agent) manualTool() bare.Tool { query := strings.TrimSpace(parsed.Query) if query == "" { - return "Give either a query or a page. The pages are:" + manualPageList(), true, nil + return "Give either a query or a page. The pages are:" + a.manualPageList(), true, nil } // AND THE PERSON'S OWN WORDS, taken here rather than asked of // the model. The model composes a query of its own and this @@ -120,7 +120,7 @@ func (a *Agent) manualTool() bare.Tool { // the person's own words for every node of it, and a steer into a // running node is a course correction rather than a question // about codeaf. - sections := manual.Chat().SearchBoth(query, a.taskRequest(), manualSections) + sections := a.chatManual().SearchBoth(query, a.taskRequest(), manualSections) if len(sections) == 0 { // NOT AN ERROR, and the difference matters: the manual having // nothing on a topic is a fact about codeaf worth reporting to @@ -128,7 +128,7 @@ func (a *Agent) manualTool() bare.Tool { // do that" — while an error would invite a retry with rephrased // words that will find nothing either. return "The manual has nothing on that, which usually means codeaf does not do it. The pages are:" + - manualPageList(), false, nil + a.manualPageList(), false, nil } return manual.Render(sections), false, nil }, @@ -137,13 +137,13 @@ func (a *Agent) manualTool() bare.Tool { // manualPageList is the invitation every refusal ends with, built only where a // refusal is being written — a lookup that succeeds never pays for it. -func manualPageList() string { return boundedList(manual.Chat().Pages()) } +func (a *Agent) manualPageList() string { return boundedList(a.chatManual().Pages()) } // manualHeadings is one page's section titles, which is the whole of what a cut // page or a missed heading has to offer: the names of the parts it can be asked // for by. -func manualHeadings(page string) []string { - sections := manual.Chat().PageSections(page) +func (a *Agent) manualHeadings(page string) []string { + sections := a.chatManual().PageSections(page) headings := make([]string, 0, len(sections)) for _, section := range sections { headings = append(headings, section.Title) diff --git a/internal/session/tools_manual_bound.go b/internal/session/tools_manual_bound.go index a85ff76db..8bd03a817 100644 --- a/internal/session/tools_manual_bound.go +++ b/internal/session/tools_manual_bound.go @@ -45,11 +45,11 @@ const ( // with the cut saying so and naming the sections the rest is in. The headings // are collected only when there is a cut to explain, so the common read pays // nothing for them. -func boundedPage(name, text string) string { +func boundedPage(headings []string, text string) string { if len(text) <= manualPageCap { return text } - sections := boundedList(manualHeadings(name)) + sections := boundedList(headings) return bounded(text, func(shown, total int) string { return pageCutNotice(shown, total, sections) }) } From 9a5c16d30c1df6757ffc841fad9b803f9e1416a6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:11:41 -0400 Subject: [PATCH 013/195] session: the stop-law ledger names the door that now publishes a run's row startKnownTaskRun became a wrapper over startKnownTaskRunVia when the delegate door landed, so the row's publisher is the via variant. Co-Authored-By: Claude Fable 5.1 --- internal/session/stoplaw_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/session/stoplaw_test.go b/internal/session/stoplaw_test.go index 9f0749a73..ad7e4256d 100644 --- a/internal/session/stoplaw_test.go +++ b/internal/session/stoplaw_test.go @@ -31,7 +31,7 @@ import ( // here because they are not built from a literal state: their notices copy the // node's state, and the graph is the owner `task:N` has always reached. var stoppableRowPublishers = map[string]struct{ kind, proof string }{ - "startKnownTaskRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "startKnownTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, From fcde8b93e9adf8da5e06996620131fe7c48e633b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:23:26 -0400 Subject: [PATCH 014/195] delegate: the door crosses the wire, every delegate door is asked off the loop, and the laws hold Wave 4 of docs/design/delegate/DESIGN.md. Delegate.List and Delegate.Start ride wire version 18, so a --host surface generates its rows from the far machine's registry and a delegate runs there; the surface asks for the list beside the line at the launch and on a switch, and starts one in the line. The two roads the door lengthened are split along their phases (joinBeltRun, commitProposalToRun), the stop-law ledger names both publishers, and the manual pages name no retired worker. Co-Authored-By: Claude Fable 5.1 --- docs/design/delegate/DESIGN.md | 2 +- internal/manual/chat/commands.md | 16 ++--- internal/manual/chat/delegates.md | 21 +++--- internal/manual/chat_test.go | 6 +- internal/remote/client.go | 32 +++++++++ internal/remote/server.go | 22 ++++++ internal/remote/wire.go | 11 ++- internal/remote/wire_task.go | 15 ++++ internal/session/stoplaw_test.go | 1 + internal/session/task.go | 111 +++++++++++++++++------------- internal/session/task_run_belt.go | 84 ++++++++++++---------- internal/tui3/app.go | 10 +-- internal/tui3/delegate.go | 83 ++++++++++++++-------- internal/tui3/delegate_test.go | 59 +++++++++++----- internal/tui3/detach.go | 4 +- internal/tui3/offlooplaw_test.go | 1 + internal/tui3/taskcommand.go | 15 +++- 17 files changed, 337 insertions(+), 156 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 443e3eea2..682cf9c6c 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -305,7 +305,7 @@ SIGTERM still writes the terminal record, and `--` before the goal parses. | **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 swe-pro 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; swe-pro's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to swe-pro", "what does /swe-pro do", "why can't the delegate ask me", "difference between /harness and /swe-pro" | -| **4** ✓ | hosted: a `--host` surface installs no rows and `/delegate` answers ` owns delegates · change it on that machine`; nothing crosses `internal/remote`, because the registry and the run are the far machine's | `internal/tui3` delegate tests | +| **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 diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 6f27cc48d..ebf5d6d05 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -180,7 +180,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/subharness` | `/sub` | `` | opens that subharness's intake card straight away | | `/delegate` | `/delegates` | — | lists the outside programs a task can be handed to whole, and what each leaves behind | | `/delegate` | `/delegates` | ` ` | hands that brief to the named delegate; `/ ` is the same door | -| `/` | — | `` | one row per installed delegate, e.g. `/swe-pro `: starts a task that program does on its own | +| `/` | — | `` | one row per installed delegate, spelled as its manifest names it: starts a task that program does on its own | | `/memory` | — | — | opens the memory panel | | `/memory` | `/memories` | `` | prints matching memories into the conversation | | `/memories` | — | — | prints every memory into the conversation | @@ -1263,18 +1263,18 @@ 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. -## /delegate — the outside programs a task can be handed to, and /swe-pro +## /delegate — the outside programs a task can be handed to, and the command each one adds `/delegate` (or `/delegates`) lists the delegates on this machine, one line each: the command to type, what it does, whether it lands its work on your branch or answers in the conversation, and the program it resolved to. Under those, dimly, any manifest whose program is not here and any that was not added, with the reason. -Every installed delegate is also a command of its own: `/swe-pro ` hands the brief -to swe-pro and starts a task at once, exactly as `/task ` does with codeaf's own +Every installed delegate is also 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. `/delegate ` is the same door written long. The rows come from the -manifests under `~/.codeaf/delegates/` and exist only where the program does; a machine with -no swe-pro has no `/swe-pro`. +manifests under `~/.codeaf/delegates/` and exist only where the program does; a machine +without the program has no row for it. With nothing installed it says, exactly: @@ -1282,8 +1282,8 @@ With nothing installed it says, exactly: no delegates here — a delegate is an outside program codeaf can hand a whole task to; a manifest under ~/.codeaf/delegates adds one ``` -Over `--host` it says ` owns delegates · change it on that machine`. The *Delegates* -page says what one is, what it cannot do, and where its work goes. +Over `--host` it lists the far machine's delegates, and a row you run starts the work there. +The *Delegates* 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 diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 434a1601d..f3cd1b1d6 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -1,6 +1,6 @@ # Delegates -## What a delegate is — programs codeaf can hand a task to, outside agents, another coding agent, swe-pro +## What a delegate is — programs codeaf can hand a task to, outside agents, another coding agent A **delegate** is an outside program on this machine that can do a whole coding task on its own. codeaf hands it a task the way it hands one to its own worker: in a working copy @@ -18,14 +18,17 @@ Each delegate is one manifest and one page under `~/.codeaf/delegates/`: ` says how to run the program, `.md` says what it does. A delegate is found at launch, so one added while codeaf is running appears the next time codeaf starts. -## How do I hand work to a delegate — /swe-pro, / , /delegate, via, "delegate this to swe-pro" +## How do I hand work to a delegate — / , /delegate, via, "delegate this to another agent", the command for a delegate Type the delegate's name as a command and the brief after it: ``` -/swe-pro rewrite the auth middleware to use the new session store +/ rewrite the auth middleware to use the new session store ``` +where `` is the word its manifest gives it — the command is generated from the +manifest, so it is spelled exactly as the manifest's `name`. + That is `/task` with the worker chosen. A run starts at once in a copy of your folder, the turn goes on, and the row appears on the rail with the program's current phase as its live step. `/delegate ` is the same door in long form. @@ -60,7 +63,7 @@ afterwards; what the program itself checked is reported in its result, kept apar what its model claimed. A name that is no delegate here is refused with the ones that are: -`no delegate is called ; the delegates here are swe-pro, …`. On a machine with none: +`no delegate is called ; the delegates here are …`. On a machine with none: `no delegate is called : this machine has no delegates (a manifest under ~/.codeaf/delegates adds one)`. @@ -70,7 +73,7 @@ A delegate that lands a **tree** works in a copy cut from your folder. When it e commit it made in that copy is squashed into **one commit** whose subject is the task's title and whose body is the program's own account of the ending, and that commit is merged into your folder the way every task's work comes home. A program that commits after every -edit, as swe-pro does, leaves no trail of bookkeeping commits on your branch. Nothing to +edit leaves no trail of bookkeeping commits on your branch. Nothing to land is said as `nothing to land: the run's working copy holds no change`. A delegate that lands **text** works in your folder in place and changes nothing; its @@ -80,7 +83,7 @@ What it spent is in the conversation's total, in `/cost` and on the status line, as the program reports it. The spending page shows it under the delegate's name rather than a model's, because the program's own calls did not go through codeaf. -## Why is there no /swe-pro here — the delegate is missing, not on this machine, adding a delegate, the manifest was not added +## Why is there no command for my delegate — the delegate is missing, not on this machine, adding a delegate, the manifest was not added A delegate's row exists only where its program does. `/delegate` says which of the manifests under `~/.codeaf/delegates/` could not be added and why: @@ -94,8 +97,10 @@ manifests under `~/.codeaf/delegates/` could not be added and why: - `: its name is already a command here — not added` — the name collides with a built-in command or alias. -Over `--host`, delegates are the far machine's: `/delegate` answers -` owns delegates · change it on that machine`. +Over `--host`, the delegates are the far machine's: `/delegate` lists what is installed +there, the rows are that machine's, and a delegate you start runs there, in a copy of that +machine's folder. A delegate installed only on this laptop is not offered in a hosted +conversation. The contract a program has to meet to be a delegate is one page, `docs/DELEGATE-PROTOCOL.md` in the codeaf repository: four kinds of line on its stdout, one terminal record, a clean stop diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 315baa9f2..960a0cbef 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -910,11 +910,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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 to swe-pro", "delegates"}, - {"what does /swe-pro do", "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 /swe-pro here", "delegates"}, + {"why is there no command for my delegate", "delegates"}, {"where does a delegate's work go, does it squash the commits", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/remote/client.go b/internal/remote/client.go index 5a28082ae..5ff2157ac 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1559,6 +1559,38 @@ func (a *Agent) StartTask(ctx context.Context, brief string, solo bool) (uint64, return started.ID, started.Title, started.Note, nil } +// Delegates is the engine machine's delegate registry as the surface lists it: +// the rows that can run there, the manifests whose program is not there, and +// the files its loader would not admit (internal/session's delegate_door.go). +// A failed read is the zero report, which the surface draws as one sentence, +// 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 delegate 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}) diff --git a/internal/remote/server.go b/internal/remote/server.go index b9e9cd5e4..036431bac 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2307,6 +2307,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/wire.go b/internal/remote/wire.go index ac52765c3..9d422a546 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 delegate 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 276b6e547..18be99613 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 delegate door + // (internal/session's delegate_door.go): the outside programs installed on + // the ENGINE machine, and handing a brief to one. They belong to the engine + // side for the reason the task door does — the registry is that machine's + // disk and the run spends that machine's money — so a hosted surface lists + // the far machine's delegates 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 delegate's name and the person's brief, both +// as typed: the name is resolved against the engine machine's registry 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/session/stoplaw_test.go b/internal/session/stoplaw_test.go index ad7e4256d..5414b3527 100644 --- a/internal/session/stoplaw_test.go +++ b/internal/session/stoplaw_test.go @@ -32,6 +32,7 @@ import ( // node's state, and the graph is the owner `task:N` has always reached. var stoppableRowPublishers = map[string]struct{ kind, proof string }{ "startKnownTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "joinBeltRun": {CancelTask, "TestAStopOnAJoinedRowEndsThatWorkAndLeavesTheRun"}, "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, diff --git a/internal/session/task.go b/internal/session/task.go index def7baeee..aa42549cd 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -832,59 +832,76 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // already underway is refused here ([standsElsewhereError]); any other // failure of the run road falls through to the shipped engine, exactly as a // typed /task does, and that engine cuts its own copy from the same stand. - if (bashBeltAsked() || spec.via != "") && chatRunEngine != nil && !a.config.InTask { - a.mu.Lock() - question := questionAtTaskHandoff(a.owedAsks) - a.mu.Unlock() - var via *delegate.Manifest - if spec.via != "" { - m, err := a.delegateFor(spec.via) - if err != nil { - return err.Error(), true, nil - } - via = &m - } - description := composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) - // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. - // This context is the turn's, and the turn cancels it on its way out - // (agent.go, `defer cancel(nil)`); a run driven under it would be stopped - // the moment the model finished its sentence. The values ride along, the - // cancellation does not. - // - // What ends it instead is the conversation: the person's stop - // (stoprun.go) or the room closing ([Agent.cutBeltRun]). Dropping the - // turn's cancellation here without either of those is what left a run's - // life belonging to the PROCESS, and a run whose room had closed went on - // spending with nobody able to read it or stop it. - joined := a.beltRunStandsOn(p.stand) - stand := p.stand - if via != nil { - stand = delegateStand(stand.dir, *via) - } - err := a.startKnownTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via) - if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { - return refusal.Error(), true, nil - } - if err == nil { - receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) - if via != nil { - receipt = withReport(receipt, "It is "+via.Name+"'s: the program works alone in the copy and lands when it ends.") - } else if joined { - receipt = withReport(receipt, "It joined the work already underway and shares its copy.") - } - return receipt, false, nil - } - if via != nil { - // A DELEGATE HAS NO OTHER ROAD. The shipped engine would seat a worker - // of its own on this brief, which is not what was asked for. - return "the delegate could not start: " + err.Error(), true, nil - } + if answer, refused, handled := a.commitProposalToRun(ctx, p, spec, elsewhere); handled { + return answer, refused, nil } state := graph.admit(p.id, spec) admitted = true return taskReceipt(p.id, spec, state, p.stand, elsewhere), false, nil } +// commitProposalToRun is the run road of an approved proposal: an approved +// hand-off under the bash belt, and every hand-off that names a delegate, is a +// RUN and never a session-tree node. It keeps the id the card showed, carries +// its acceptance in the brief and its depends_on as the store's own +// dependencies, and takes the person's ask with it when this turn owes one +// (CHAT-ROLE.md, "A landing speaks only when an answer is owed"). It answers +// handled=false when this proposal is not the run road's — the shipped engine +// admits it then — and handled=true with the model's answer otherwise. +// +// A task about ANOTHER FOLDER than the work already underway is refused here +// ([standsElsewhereError]); any other failure of the run road for an ordinary +// hand-off falls through to the shipped engine, exactly as a typed /task does, +// and that engine cuts its own copy from the same stand. A DELEGATE HAS NO OTHER +// ROAD: the shipped engine would seat a worker of its own on the brief, which is +// not what was asked for, so its failure is answered as a refusal. +func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec taskSpec, elsewhere string) (string, bool, bool) { + if !(bashBeltAsked() || spec.via != "") || chatRunEngine == nil || a.config.InTask { + return "", false, false + } + a.mu.Lock() + question := questionAtTaskHandoff(a.owedAsks) + a.mu.Unlock() + var via *delegate.Manifest + if spec.via != "" { + m, err := a.delegateFor(spec.via) + if err != nil { + return err.Error(), true, true + } + via = &m + } + description := composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) + // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. + // This context is the turn's, and the turn cancels it on its way out + // (agent.go, `defer cancel(nil)`); a run driven under it would be stopped + // the moment the model finished its sentence. The values ride along, the + // cancellation does not. What ends it instead is the conversation: the + // person's stop (stoprun.go) or the room closing ([Agent.cutBeltRun]). + joined := a.beltRunStandsOn(p.stand) + stand := p.stand + if via != nil { + stand = delegateStand(stand.dir, *via) + } + err := a.startKnownTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via) + if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { + return refusal.Error(), true, true + } + if err == nil { + receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) + switch { + case via != nil: + receipt = withReport(receipt, "It is "+via.Name+"'s: the program works alone in the copy and lands when it ends.") + case joined: + receipt = withReport(receipt, "It joined the work already underway and shares its copy.") + } + return receipt, false, true + } + if via != nil { + return "the delegate could not start: " + err.Error(), true, true + } + return "", false, false +} + // taskReceipt is what an admitted proposal hands back to the model. // // THE MODEL IS NAMED BACK ONLY WHEN IT WAS ASKED FOR. A word resolves to an id diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 1723ae17f..9d721dc15 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -309,34 +309,8 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie a.beltMu.Lock() live := a.beltRun a.beltMu.Unlock() - - // A SECOND TASK JOINS THE LIVE RUN. The store holds one root, so the new - // work is a child of it — normalizeSpec's own law for a task that names no - // parent — and the supervisor already turning finds it ready on its next - // pass. Nothing opens a second store. if live != nil { - // A DELEGATE NEVER JOINS A RUN AND NOTHING JOINS A DELEGATE'S. A delegated - // run is a run of one task whose worker owns the whole copy for the hour; - // a second task beside it would be a bash worker typing in the tree the - // program is editing, and a delegate added under a live run would be a - // second program in the same tree. Both are refused with what is underway. - if via != nil || live.delegate != nil { - return errors.New("work is already underway in a copy of " + live.ground + - "; a delegate runs alone, so propose it again when that work has ended") - } - if canonicalPath(stand.dir) != live.ground { - return standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} - } - if _, err := live.store.AddMany([]plandb.TaskSpec{{ - ID: storeID, ParentID: live.root, Title: title, Description: brief, Dependencies: dependencies, - }}); err != nil { - return err - } - a.beltMu.Lock() - live.joined = append(live.joined, id) - a.beltMu.Unlock() - a.publishRunRow(g, TaskNotice{ID: id, Title: title, State: TaskRunning, Parent: live.row, StartedAt: a.taskClockNow()}) - return nil + return a.joinBeltRun(g, live, id, title, brief, dependencies, stand, via) } plan, store, err := a.openBeltRunStore(g, path, storeID, title, brief) @@ -366,15 +340,7 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, workspace: tree.dir, ground: canonicalPath(stand.dir), tree: tree, cut: cut, - born: born, delegate: via, - } - if via != nil && via.LandsTree() { - // THE SQUASH POINT IS READ NOW, off the copy itself, before the program - // has written a byte: whatever the ground ladder put under this copy is - // under this commit, and everything the program commits is above it. - if head, err := git(tree.dir, "rev-parse", "HEAD"); err == nil { - run.startSha = strings.TrimSpace(head) - } + born: born, delegate: via, startSha: delegateStartSha(tree, via), } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because @@ -390,6 +356,52 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie return nil } +// joinBeltRun is the second task of a live run. The store holds one root, so the +// new work is a child of it — normalizeSpec's own law for a task that names no +// parent — and the supervisor already turning finds it ready on its next pass. +// Nothing opens a second store. +// +// A DELEGATE NEVER JOINS A RUN AND NOTHING JOINS A DELEGATE'S. A delegated run +// is a run of one task whose worker owns the whole copy for the hour; a second +// task beside it would be a bash worker typing in the tree the program is +// editing, and a delegate added under a live run would be a second program in +// the same tree. Both are refused with what is underway. +func (a *Agent) joinBeltRun(g *TaskGraph, live *beltRun, id uint64, title, brief string, dependencies []plandb.Dependency, stand taskStand, via *delegate.Manifest) error { + if via != nil || live.delegate != nil { + return errors.New("work is already underway in a copy of " + live.ground + + "; a delegate runs alone, so propose it again when that work has ended") + } + if canonicalPath(stand.dir) != live.ground { + return standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} + } + if _, err := live.store.AddMany([]plandb.TaskSpec{{ + ID: strconv.FormatUint(id, 10), ParentID: live.root, Title: title, Description: brief, Dependencies: dependencies, + }}); err != nil { + return err + } + a.beltMu.Lock() + live.joined = append(live.joined, id) + a.beltMu.Unlock() + a.publishRunRow(g, TaskNotice{ID: id, Title: title, State: TaskRunning, Parent: live.row, StartedAt: a.taskClockNow()}) + return nil +} + +// delegateStartSha is the commit a tree delegate's copy stands on before the +// program has written a byte — the point its commits are squashed back to at +// landing (delegate_door.go). It is read NOW, off the copy itself: whatever the +// ground ladder put under this copy is under this commit, and everything the +// program commits is above it. Empty for every run that is not a tree delegate's. +func delegateStartSha(tree taskTree, via *delegate.Manifest) string { + if via == nil || !via.LandsTree() { + return "" + } + head, err := git(tree.dir, "rev-parse", "HEAD") + if err != nil { + return "" + } + return strings.TrimSpace(head) +} + // beltRunSpec is what the engine is handed for a run of this conversation: its // seats, its bounds and the copy it works in. // diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3bdae4823..9b58439a0 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3090,9 +3090,6 @@ func newApp(ctx context.Context, opts Options) *app { // and from then on [app.retitle] sends it again only when it moves. a.titleSent = terminalTitle(a) a.refreshCreditWarnings() - // THE DELEGATE ROWS GO ON THE TABLE BEFORE THE FIRST FRAME, so the picker - // and /help list them from the first keystroke (delegate.go). - a.installDelegates() return a } @@ -3272,6 +3269,11 @@ func (a *app) Init() tea.Cmd { standing = append(standing, a.wake()) } } + // THE DELEGATE ROWS ARE ASKED FOR AT THE LAUNCH, off the loop, so the picker + // and /help list them from the first answer rather than the first keystroke + // (delegate.go). The registry is the conversation's, so a switch asks again + // ([app.attachConversation]). + standing = append(standing, a.installDelegates()) return tea.Batch(standing...) } @@ -7509,8 +7511,6 @@ func (a *app) takeUp(conv Conversation, whole bool) { // ANSWERING ABOUT SOMEWHERE ELSE (offloop.go). This is the one place the // agent in front changes, so it is the one place that counter moves. a.frontGen++ - // The delegate rows are the conversation's, so they follow it (delegate.go). - a.installDelegates() } a.file = conv.SessionFile // AND THE SENDS ARE NOT RE-KEYED HERE. They are held under the drafts lane's diff --git a/internal/tui3/delegate.go b/internal/tui3/delegate.go index bed815bb8..5eebfcc55 100644 --- a/internal/tui3/delegate.go +++ b/internal/tui3/delegate.go @@ -13,6 +13,7 @@ package tui3 import ( "context" + "strconv" "strings" "sync" @@ -110,29 +111,36 @@ func isDelegateCommand(name string) bool { return delegateRows[name] } -// installDelegates reads the conversation's delegates and puts their rows on -// the table. It runs when the surface is built and again when the conversation -// in front changes, because the registry is the conversation's. A hosted -// surface installs nothing: the registry lives on the far machine, and a row -// that opened a door there would be a command about somewhere else. -func (a *app) installDelegates() { - if a.hosted() { - installDelegateCommands(nil) - return - } +// installDelegates asks the conversation for its delegates OFF THE LOOP and, +// when the answer comes back, puts their rows on the table. It is asked at the +// launch and again when the conversation in front changes, because the registry +// is the conversation's — and over `--host` it is the far machine's, which is +// right: the program and the run are there, and the door crosses the wire +// (internal/remote's Delegate.List). It rides [app.besideLine] because nobody +// pressed for it: a read that waited in the door line behind a person's gesture +// would be a row arriving after the keystroke that wanted it. +func (a *app) installDelegates() tea.Cmd { agent, ok := a.delegateSeam() if !ok { installDelegateCommands(nil) - return + return nil } - // A collision is not said here — the surface is still being built and has - // nowhere to draw a line yet — it is said where the person will look for - // the missing row, on `/delegate`. - installDelegateCommands(agent.Delegates().Rows) + return a.besideLine(func() func(here bool) tea.Cmd { + report := agent.Delegates() + return func(here bool) tea.Cmd { + // A collision is not said here — it is said where the person will + // look for the missing row, on `/delegate`. + if here { + installDelegateCommands(report.Rows) + } + return nil + } + }) } // runDelegateCommand is `/ `: the brief goes to that delegate -// through the same door `/task` opens, and the answer lands as a task start. +// through a door of its own — asked off the loop like every door — and the +// answer lands as a task start, on the message `/task` lands on. func (a *app) runDelegateCommand(name, brief string) tea.Cmd { brief = strings.TrimSpace(brief) if brief == "" { @@ -144,18 +152,25 @@ func (a *app) runDelegateCommand(name, brief string) tea.Cmd { a.note("could not start the task · this session has no delegate door") return nil } - return a.startTaskDoorVia(brief, func(ctx context.Context) (uint64, string, string, error) { - return agent.StartDelegate(ctx, name, brief) + ctx := a.ctx + conv := a.taskDoorNotes(brief) + return a.offLoop(func() func(here bool) tea.Cmd { + id, title, note, err := agent.StartDelegate(ctx, name, brief) + return func(bool) tea.Cmd { + return func() tea.Msg { + return taskStartedMsg{ + kind: "single", id: strconv.FormatUint(id, 10), title: title, + err: err, note: note, brief: brief, conv: conv, + } + } + } }) } // openDelegate is `/delegate`: bare, the list; with a name and words, the -// delegate's own row run on those words. +// delegate's own row run on those words. The list is a door, so it is asked off +// the loop and said when it comes back. func (a *app) openDelegate(rest string) tea.Cmd { - if a.hosted() { - a.note(a.remoteProfileWord("delegates")) - return nil - } if name, brief, _ := strings.Cut(strings.TrimSpace(rest), " "); name != "" { if !isDelegateCommand(name) { a.note(delegateUnknownWord(name)) @@ -168,11 +183,24 @@ func (a *app) openDelegate(rest string) tea.Cmd { a.note(delegateNothingWord) return nil } - report := agent.Delegates() + return a.offLoop(func() func(here bool) tea.Cmd { + report := agent.Delegates() + return func(here bool) tea.Cmd { + if here { + a.note(delegateListNote(report)) + } + return nil + } + }) +} + +// delegateListNote is what `/delegate` says: one line per delegate that can +// run, then the ones whose program is not there, then the manifests that were +// not added and why — the loader's and this surface's own collisions alike. +func delegateListNote(report session.DelegateReport) string { report.Refused = append(report.Refused, delegateCollisionLines()...) if len(report.Rows) == 0 && len(report.Absent) == 0 && len(report.Refused) == 0 { - a.note(delegateNothingWord) - return nil + return delegateNothingWord } lines := make([]string, 0, len(report.Rows)+len(report.Absent)+len(report.Refused)) for _, row := range report.Rows { @@ -188,8 +216,7 @@ func (a *app) openDelegate(rest string) tea.Cmd { for _, refusal := range report.Refused { lines = append(lines, "not added: "+refusal) } - a.note(strings.Join(lines, "\n")) - return nil + return strings.Join(lines, "\n") } // delegateUnknownWord answers `/delegate ` for a name no row carries. diff --git a/internal/tui3/delegate_test.go b/internal/tui3/delegate_test.go index dcc6c1171..12f671a2d 100644 --- a/internal/tui3/delegate_test.go +++ b/internal/tui3/delegate_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" ) @@ -32,9 +34,29 @@ func newDelegateApp(t *testing.T, rows ...session.DelegateRow) (*app, *delegateF fake := &delegateFake{fakeAgent: &fakeAgent{}, report: session.DelegateReport{Rows: rows}} a := newTestApp(fake) t.Cleanup(func() { installDelegateCommands(nil) }) + settleDoor(t, a, a.installDelegates()) return a, fake } +// settleDoor runs one off-loop door to its answer and folds it in, the way the +// update loop would on the doorMsg: the command is run, the fold applied as +// though the window were still on the same conversation, and any command the +// fold hands back is run too, its message returned. +func settleDoor(t *testing.T, a *app, cmd tea.Cmd) tea.Msg { + t.Helper() + if cmd == nil { + return nil + } + msg, ok := cmd().(doorMsg) + if !ok { + t.Fatalf("the door did not answer on the door line: %T", cmd()) + } + if next := msg.fold(true); next != nil { + return next() + } + return nil +} + func TestAnInstalledDelegateIsACommandRowThatOpensTheDoor(t *testing.T) { a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "tree", Bin: "/usr/local/bin/fake"}) if !isDelegateCommand("fake") { @@ -53,8 +75,8 @@ func TestAnInstalledDelegateIsACommandRowThatOpensTheDoor(t *testing.T) { if cmd == nil { t.Fatal("/fake opened no door") } - if msg, ok := cmd().(taskStartedMsg); !ok || msg.id != "7" || msg.title != "the title" || msg.brief != "rewrite the auth middleware" { - t.Fatalf("the door answered %+v", cmd()) + if msg, ok := settleDoor(t, a, cmd).(taskStartedMsg); !ok || msg.id != "7" || msg.title != "the title" || msg.brief != "rewrite the auth middleware" { + t.Fatalf("the door answered %+v", msg) } if len(fake.started) != 1 || fake.started[0] != "fake: rewrite the auth middleware" { t.Fatalf("StartDelegate was asked %v", fake.started) @@ -63,7 +85,7 @@ func TestAnInstalledDelegateIsACommandRowThatOpensTheDoor(t *testing.T) { if cmd := a.slash("/delegate fake do the other thing"); cmd == nil { t.Fatal("/delegate opened no door") } else { - cmd() + settleDoor(t, a, cmd) } if len(fake.started) != 2 || fake.started[1] != "fake: do the other thing" { t.Fatalf("StartDelegate was asked %v", fake.started) @@ -88,9 +110,7 @@ func TestSlashDelegateListsTheRowsAndTheOnesNotHere(t *testing.T) { fake.report.Absent = []string{"swe-pro: swe-pro is not on this machine"} fake.report.Refused = []string{"broken: its manual page does not say /broken — not added"} a.width = 200 - if cmd := a.slash("/delegate"); cmd != nil { - t.Fatal("/delegate started something") - } + settleDoor(t, a, a.slash("/delegate")) got := plain(frame(a)) for _, want := range []string{"/fake ", "a fake delegate", "answers in the conversation", "not here: swe-pro", "not added: broken"} { if !strings.Contains(got, want) { @@ -102,7 +122,7 @@ func TestSlashDelegateListsTheRowsAndTheOnesNotHere(t *testing.T) { func TestSlashDelegateWithNothingInstalledSaysSo(t *testing.T) { a, _ := newDelegateApp(t) a.width = 200 - a.slash("/delegates") + settleDoor(t, a, a.slash("/delegates")) if got := plain(frame(a)); !strings.Contains(got, "no delegates here") { t.Fatalf("no sentence for a machine with none:\n%s", got) } @@ -124,7 +144,7 @@ func TestADelegateNamedLikeABuiltInCommandIsNotInstalled(t *testing.T) { t.Fatal("a delegate shadowed /task") } a.width = 200 - a.slash("/delegate") + settleDoor(t, a, a.slash("/delegate")) if got := plain(frame(a)); !strings.Contains(got, "not added: task: its name is already a command here") { t.Fatalf("the collision was not said:\n%s", got) } @@ -135,18 +155,25 @@ func TestADelegateNamedLikeABuiltInCommandIsNotInstalled(t *testing.T) { } } -func TestAHostedSurfaceInstallsNoDelegateRowsAndRefusesTheList(t *testing.T) { +// A HOSTED SURFACE LISTS AND RUNS THE FAR MACHINE'S DELEGATES: the seam crosses +// the wire (internal/remote's Delegate.List and Delegate.Start), the rows are +// generated from what the engine machine has, and the door starts the run +// there. Nothing is refused for being hosted. +func TestAHostedSurfaceInstallsTheFarMachinesDelegateRows(t *testing.T) { fake := &delegateFake{fakeAgent: &fakeAgent{}, report: session.DelegateReport{Rows: []session.DelegateRow{{Name: "fake", Description: "a fake delegate"}}}} a := newTestApp(fake) t.Cleanup(func() { installDelegateCommands(nil) }) a.host = "spark" - a.installDelegates() - if isDelegateCommand("fake") { - t.Fatal("a hosted surface installed a row for the far machine's delegate") + settleDoor(t, a, a.installDelegates()) + if !isDelegateCommand("fake") { + t.Fatal("a hosted surface did not install the far machine's delegate row") } - a.width = 200 - a.slash("/delegate") - if got := plain(frame(a)); !strings.Contains(got, "spark owns delegates") { - t.Fatalf("the hosted refusal is missing:\n%s", got) + if cmd := a.slash("/fake do it there"); cmd == nil { + t.Fatal("/fake opened no door on a hosted surface") + } else { + settleDoor(t, a, cmd) + } + if len(fake.started) != 1 || fake.started[0] != "fake: do it there" { + t.Fatalf("StartDelegate was asked %v", fake.started) } } diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 53deec6e6..f72449823 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -644,7 +644,9 @@ func (a *app) attachConversation(conv Conversation, side *aside) tea.Cmd { // armed are parked on the previous one's channels and discard themselves by // generation (watching.go's [followingMsg]). cmds := []tea.Cmd{a.watchTasks(), a.watchWakes(), a.watchDesigns(), a.watchTitles(), a.watchRuns(), a.watchQuestions(), a.loadTasks(), - a.askHeld(), a.watchDriving(), a.watchFollowing()} + a.askHeld(), a.watchDriving(), a.watchFollowing(), + // The delegate rows are the conversation's, so they follow it (delegate.go). + a.installDelegates()} if side != nil { cmds = append(cmds, a.restoreAside(side)) diff --git a/internal/tui3/offlooplaw_test.go b/internal/tui3/offlooplaw_test.go index 3bc524cec..c04874dc7 100644 --- a/internal/tui3/offlooplaw_test.go +++ b/internal/tui3/offlooplaw_test.go @@ -435,6 +435,7 @@ var doorsBesideTheLine = map[string]string{ "PlanRunSummary": "reads the run's stored summary for a refresh nobody pressed for", "PlanTasks": "reads the run's rows for the side list after a message; nobody pressed for it, and a verb's own read is asked only once the verb has landed", "RefreshRunSummary": "asks a model for the run's summary under a budget of seconds; nobody pressed for it and no gesture depends on it", + "Delegates": "reads the engine machine's delegate registry to generate the command rows at the launch and on a switch (delegate.go); nobody pressed for it, and the /delegate a person types asks the same door in the line", } func TestOnlyReadsNobodyPressedForAreAskedBesideTheLine(t *testing.T) { diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 6491fa836..3c0ec962a 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -138,8 +138,13 @@ func (a *app) startTaskDoor(door taskCommandAgent, brief string, solo bool) tea. // notes said before the spend and the start message are the same whichever // door opens — the conversation's own worker or a delegate (delegate.go) — and // two copies of the preflight would be two places for one line to drift. -func (a *app) startTaskDoorVia(brief string, start func(context.Context) (uint64, string, string, error)) tea.Cmd { - ctx := a.ctx +// taskDoorNotes says the two lines every task door says before the spend — who +// else is in these files, and what unsaved edits are about to travel — and +// answers which conversation is speaking, read HERE rather than when the answer +// lands ([app.adoptTypedBrief] is where that matters). It is one function +// because the conversation's own door and a delegate's (delegate.go) say the +// same two lines, and two copies would be two places for one line to drift. +func (a *app) taskDoorNotes(brief string) string { // WHICH CONVERSATION IS SAYING THIS, read HERE rather than when the answer // lands: the door is opened on a goroutine and the window may have moved on // by the time it answers ([app.adoptTypedBrief] is where that matters). @@ -174,6 +179,12 @@ func (a *app) startTaskDoorVia(brief string, start func(context.Context) (uint64 if line := session.UnsavedEditsNote(a.workspace); line != "" { a.note(line) } + return conv +} + +func (a *app) startTaskDoorVia(brief string, start func(context.Context) (uint64, string, string, error)) tea.Cmd { + ctx := a.ctx + conv := a.taskDoorNotes(brief) return func() tea.Msg { id, title, note, err := start(ctx) return taskStartedMsg{ From 3df3eb063824dfe1d0c950586424db6460593b41 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:50:16 -0400 Subject: [PATCH 015/195] delegate: the delegates folder is made at launch, so nobody has to mkdir it before installing one Co-Authored-By: Claude Fable 5.1 --- internal/delegate/load.go | 10 +++++++--- internal/delegate/load_test.go | 10 ++++++++-- internal/manual/chat/delegates.md | 7 ++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/delegate/load.go b/internal/delegate/load.go index e8264c913..b08f35784 100644 --- a/internal/delegate/load.go +++ b/internal/delegate/load.go @@ -54,9 +54,12 @@ type Registry struct { refusals []Refusal } -// Load reads every `.json` in dir. A missing directory is an empty -// registry and no error: most machines have no delegates. An error is only a -// directory that exists and cannot be read. +// Load reads every `.json` in dir. A missing directory is MADE, so the +// person who goes to install a delegate finds the folder waiting rather than +// reading its name off a page; it is then an empty registry and no error, since +// most machines have no delegates. An error is only a directory that exists and +// cannot be read: a folder that cannot be made is read as missing, because the +// registry is not worth failing a launch over. // // THE LAW IS CHECKED HERE, at the moment the command comes into existence: a // manifest whose manual page is missing or does not spell `/` is refused @@ -65,6 +68,7 @@ type Registry struct { // moved to load time for rows that cannot be in the table. func Load(dir string) (*Registry, error) { registry := &Registry{entries: map[string]Manifest{}} + _ = os.MkdirAll(dir, 0o755) entries, err := os.ReadDir(dir) if errors.Is(err, os.ErrNotExist) { return registry, nil diff --git a/internal/delegate/load_test.go b/internal/delegate/load_test.go index d2656a9d0..e67321a35 100644 --- a/internal/delegate/load_test.go +++ b/internal/delegate/load_test.go @@ -63,14 +63,20 @@ func TestLoadAdmitsAManifestWithItsPageAndItsProgram(t *testing.T) { } } -func TestLoadIsEmptyWhenTheFolderDoesNotExist(t *testing.T) { - registry, err := Load(filepath.Join(t.TempDir(), "nowhere")) +// A machine with no delegates gets an empty registry AND the folder, so the +// person who goes to add one finds it waiting. +func TestLoadIsEmptyWhenTheFolderDoesNotExistAndMakesIt(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nowhere") + registry, err := Load(dir) if err != nil { t.Fatal(err) } if !registry.Empty() { t.Fatalf("registry = %+v, want empty", registry) } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + t.Fatalf("the folder was not made: %v", err) + } } func TestLoadRefusesAManifestWithoutItsPageAndNamesTheCommand(t *testing.T) { diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index f3cd1b1d6..30997d716 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -14,9 +14,10 @@ codeaf's own parts and run inside this process; a delegate is somebody else's bi running as a child process. `/harness` and `/subharness` list the first kind; `/delegate` lists the second. -Each delegate is one manifest and one page under `~/.codeaf/delegates/`: `.json` -says how to run the program, `.md` says what it does. A delegate is found at launch, -so one added while codeaf is running appears the next time codeaf starts. +Each delegate is one manifest and one page under `~/.codeaf/delegates/`, a folder codeaf +makes at launch when it is not there: `.json` says how to run the program, +`.md` says what it does. A delegate is found at launch, so one added while codeaf +is running appears the next time codeaf starts. ## How do I hand work to a delegate — / , /delegate, via, "delegate this to another agent", the command for a delegate From 7909bb60218d7f9ca55753fc084112a57b71b8a0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:37:18 -0400 Subject: [PATCH 016/195] delegate: the first delegate is senior-dev, the name swe-pro took in its own repository swe-pro-go renamed its binary and namespace to senior-dev at b43daaf: the binary and cmd/senior-dev, the .senior-dev/ run folder, refs/senior-dev/* and every SENIOR_DEV_* variable. Its command line, its four stdout records and its terminal data spellings are unchanged, so the reader needs nothing new; the manifest, the page, the fixture, the tests and both documents take the new name, and the docs pin senior-dev at 6103488 with one line saying what it used to be called. Co-Authored-By: Claude Opus 5.5 --- docs/DELEGATE-PROTOCOL.md | 24 ++-- .../{swe-pro.json => senior-dev.json} | 4 +- docs/delegates/{swe-pro.md => senior-dev.md} | 26 ++-- docs/design/delegate/DESIGN.md | 111 ++++++++++-------- internal/delegate/launch.go | 2 +- internal/delegate/load_test.go | 2 +- internal/delegate/protocol.go | 12 +- internal/delegate/protocol_test.go | 16 +-- ...stream.ndjson => senior-dev-stream.ndjson} | 2 +- internal/manual/overlay_test.go | 16 +-- internal/run/delegateworker.go | 2 +- internal/session/delegate_door.go | 4 +- internal/session/delegate_door_test.go | 2 +- internal/tui3/commands.go | 2 +- internal/tui3/delegate_test.go | 4 +- 15 files changed, 119 insertions(+), 110 deletions(-) rename docs/delegates/{swe-pro.json => senior-dev.json} (88%) rename docs/delegates/{swe-pro.md => senior-dev.md} (59%) rename internal/delegate/testdata/{swe-pro-stream.ndjson => senior-dev-stream.ndjson} (97%) diff --git a/docs/DELEGATE-PROTOCOL.md b/docs/DELEGATE-PROTOCOL.md index e410d47f0..267fd61f2 100644 --- a/docs/DELEGATE-PROTOCOL.md +++ b/docs/DELEGATE-PROTOCOL.md @@ -2,7 +2,8 @@ *Version 1, 2026-09-22. What a program must do to be a codeaf delegate. The design behind it is `docs/design/delegate/DESIGN.md`. Conforming today: -`swe-pro` (`zeropoint95/improvements @ 5793499`).* +`senior-dev` (`swe-pro-go`, branch `zeropoint95/improvements @ 6103488`). It was +called `swe-pro` until `b43daaf`; the commit pins in §8 predate that rename.* A delegate is an outside program codeaf hands one task to. codeaf starts it, reads its stdout, stops it when a limit is hit, and takes its result. This page @@ -115,11 +116,11 @@ The manifest says which of two things the program produces. Two files in `~/.codeaf/delegates/`: ```jsonc -// swe-pro.json +// senior-dev.json { - "name": "swe-pro", // also the command: /swe-pro + "name": "senior-dev", // also the command: /senior-dev "description": "an autonomous coding agent for one large, well-specified change", - "bin": "swe-pro", // on PATH, or a path + "bin": "senior-dev", // on PATH, or a path "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], @@ -134,16 +135,16 @@ Two files in `~/.codeaf/delegates/`: - `{{key:}}` is filled from the person's profile. - A `bin` not found means the delegate is not offered. Nothing fails. -`swe-pro.md` beside it is the delegate's manual page: what it does, how to +`senior-dev.md` beside it is the delegate's manual page: what it does, how to ask it, what it cannot do, what a run costs, where the work lands. It follows the rules of `internal/manual/chat/` pages and **must mention `/`**. A page that does not refuses the manifest. -## 8. Conformance: swe-pro +## 8. Conformance: senior-dev -| requirement | swe-pro | +| requirement | senior-dev | | --- | --- | -| launch from argv | `swe-pro run --dir D --max-cost X --max-hours H -- "goal"` | +| launch from argv | `senior-dev run --dir D --max-cost X --max-hours H -- "goal"` | | no stdin, no questions | nothing reads stdin; `question` is auto-rejected | | stdout is JSON lines only | yes, EVENTS-CONTRACT.md | | `stage` | yes, thirteen stages | @@ -152,10 +153,11 @@ page that does not refuses the manifest. | exactly one `terminal`, last, on every path | yes, including crash and signal | | `status` set | `pass`, `fail`, `budget-exhausted`, `crashed`, exactly | | SIGTERM writes the terminal | yes | -| `lands: tree`, own state excluded | `.swe-pro/` is in `.git/info/exclude`; `refs/swe-pro/*` stay in the copy | +| `lands: tree`, own state excluded | `.senior-dev/` is in `.git/info/exclude`; `refs/senior-dev/*` stay in the copy | | runs without a control plane | yes, since `f3b9716` | +| name | `senior-dev` since `b43daaf`; the binary is built from `./cmd/senior-dev` | -Not yet mapped on swe-pro's side: `data.claim` and `data.observed` are spelled +Not yet mapped on senior-dev's side: `data.claim` and `data.observed` are spelled `submission_reason` / `submission_evidence` and `status` / -`verification_failing` in its `data`. The reader accepts swe-pro's spellings +`verification_failing` in its `data`. The reader accepts senior-dev's spellings for these two optional fields. diff --git a/docs/delegates/swe-pro.json b/docs/delegates/senior-dev.json similarity index 88% rename from docs/delegates/swe-pro.json rename to docs/delegates/senior-dev.json index c10baa7b8..b54ab0aa4 100644 --- a/docs/delegates/swe-pro.json +++ b/docs/delegates/senior-dev.json @@ -1,7 +1,7 @@ { - "name": "swe-pro", + "name": "senior-dev", "description": "an autonomous coding agent for one large, well-specified change", - "bin": "swe-pro", + "bin": "senior-dev", "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], "env": { "OPENROUTER_API_KEY": "{{key}}" }, "lands": "tree", diff --git a/docs/delegates/swe-pro.md b/docs/delegates/senior-dev.md similarity index 59% rename from docs/delegates/swe-pro.md rename to docs/delegates/senior-dev.md index 25ca17635..a480df3d6 100644 --- a/docs/delegates/swe-pro.md +++ b/docs/delegates/senior-dev.md @@ -1,10 +1,10 @@ -# swe-pro +# senior-dev -## What /swe-pro does — hand one large change to swe-pro, an autonomous coding agent +## What /senior-dev does — hand one large change to senior-dev, an autonomous coding agent -`/swe-pro ` hands the whole brief to **swe-pro**, an autonomous coding agent that +`/senior-dev ` hands the whole brief to **senior-dev**, an autonomous coding agent that runs on this machine as its own program. codeaf gives it a copy of your folder, this -conversation's dollar and time limits, and your OpenRouter key; swe-pro maps the +conversation's dollar and time limits, and your OpenRouter key; senior-dev maps the repository, pins a test command, edits, runs the tests, and freezes a candidate it has verified. When it ends, its work is squashed into one commit on your branch. @@ -12,9 +12,9 @@ Use it for one change that is big enough to want an agent of its own for an hour specified well enough that nobody will be asked anything: a rewrite across a package, a migration, a feature with tests. A change you would do in a few steps is not worth it. -## What swe-pro cannot do — it cannot ask, it has no step cap, it reports its own checking +## What senior-dev cannot do — it cannot ask, it has no step cap, it reports its own checking -swe-pro runs unattended. A question its model tries to ask is turned down inside the +senior-dev runs unattended. A question its model tries to ask is turned down inside the program, so put everything it would stop and ask into the brief: the files, the constraints, the wrong answer to avoid, how to check the result. @@ -22,19 +22,19 @@ It has no step cap. It is held to the dollar and hour ceilings codeaf hands it o command line, and codeaf stops it from outside at the same limits. On its task page the step count is what it reported. -Its result keeps two things apart: what its model claimed it did, and what swe-pro itself +Its result keeps two things apart: what its model claimed it did, and what senior-dev itself observed when it ran the project's tests on the frozen tree. Read the second for "did it work". ## Where its work goes and what it costs -swe-pro commits after every edit inside its copy. At landing those commits are squashed -into one commit whose subject is the task's title and whose body is swe-pro's account of +senior-dev commits after every edit inside its copy. At landing those commits are squashed +into one commit whose subject is the task's title and whose body is senior-dev's account of the ending, and that commit is merged into your folder. Its spending is folded into this -conversation's total as it reports it, under the name `swe-pro` on the spending page. +conversation's total as it reports it, under the name `senior-dev` on the spending page. -## Installing it — why there is no /swe-pro here +## Installing it — why there is no /senior-dev here -The row exists only where the `swe-pro` binary is on PATH. Put this file and -`swe-pro.json` in `~/.codeaf/delegates/` and start codeaf again. `/delegate` says what it +The row exists only where the `senior-dev` binary is on PATH. Put this file and +`senior-dev.json` in `~/.codeaf/delegates/` and start codeaf again. `/delegate` says what it found. diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 682cf9c6c..8e9778028 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -1,8 +1,15 @@ # Delegates — handing a task to an outside program — DESIGN (draft) -*2026-09-21, revised 2026-09-22. Written against `dev @ 17ae56d34` and -`swe-pro-go @ 5793499` (branch `zeropoint95/improvements`, PR #30). Waves 1 to -4 are built on this branch; every swe-pro change this asked for has landed.* +*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 @@ -10,7 +17,7 @@ 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: ``` -/swe-pro rewrite the auth middleware to use the new session store +/senior-dev rewrite the auth middleware to use the new session store ``` That starts an ordinary **task**. It runs in its own working copy, under your @@ -19,7 +26,7 @@ branch when it ends. The chat is not blocked while it runs. Inside codeaf, a delegate is one more **worker kind** behind the existing run supervisor. It is not a second engine. -`swe-pro` is the first delegate. Others are added later, one manifest each, +`senior-dev` is the first delegate. Others are added later, one manifest each, at the person's discretion. ## Decisions already taken @@ -30,10 +37,10 @@ at the person's discretion. | 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 | -| swe-pro's `wip(edit)` commits | squashed into one commit at landing | 2026-09-21 | -| swe-pro control plane | optional. Landed in swe-pro `f3b9716` | 2026-09-21 | -| Live cost from swe-pro | a top-level `spend` record. Landed in swe-pro `5793499` | 2026-09-22 | -| Steps from swe-pro | a `step` record per finished tool call. Landed in swe-pro `5793499` | 2026-09-22 | +| senior-dev's `wip(edit)` commits | squashed into one commit at landing | 2026-09-21 | +| 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 | @@ -61,25 +68,25 @@ is what the chat answers from. | --- | --- | --- | --- | | `/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 | -| `/swe-pro` | an outside program | someone else | a child process in a working copy | +| `/senior-dev` | an outside program | someone else | a child process in a working copy | ## The command -`/swe-pro ` is `/task ` with the worker already chosen. +`/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 swe-pro's terminal record and the landing note, and answers. + 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 swe-pro on the machine means -no `/swe-pro` row. A name that collides with a built-in command or alias is +`/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` @@ -119,36 +126,36 @@ type Report struct { Result string; Steps int; USD float64; Waiting bool } 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 `swe-pro run …` +**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 | swe-pro today | +| the program must | senior-dev today | | --- | --- | -| **launch** from argv with brief, directory, dollar ceiling, wall ceiling | `swe-pro run --dir D --max-cost X --max-hours H -- "goal"` | +| **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. `.swe-pro/` is git-excluded | +| **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.** swe-pro auto-rejects its own `question` tool and has no +- **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.** swe-pro has cost and hours only. The step count on the +- **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/swe-pro.json +// ~/.codeaf/delegates/senior-dev.json { - "name": "swe-pro", // also the command: /swe-pro + "name": "senior-dev", // also the command: /senior-dev "description": "an autonomous coding agent for one large, well-specified change", - "bin": "swe-pro", // resolved on PATH; a path is allowed + "bin": "senior-dev", // resolved on PATH; a path is allowed "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], @@ -168,7 +175,7 @@ Two things codeaf does **not** ask, and the manual page says so: 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: swe-pro's bus +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 | @@ -178,7 +185,7 @@ payloads pass straight through it. | `{"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 swe-pro's: +`terminal.status` is a closed set, and it is senior-dev's: | `status` | run outcome | rail word | | --- | --- | --- | @@ -194,7 +201,7 @@ program itself saw), `deliverable` (the answer text, for `"lands": "text"`). **What this costs each program:** -- **swe-pro** emits all four in exactly this shape as of `5793499`. Its +- **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 @@ -203,34 +210,34 @@ program itself saw), `deliverable` (the answer text, for `"lands": "text"`). `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 swe-pro's `message.updated`.** An assistant message is +**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. -swe-pro keeps the model's claim and its own observation as separate fields. -The landing note keeps them separate too: *swe-pro says it submitted; its +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` (swe-pro) | cut per run, passed as `{{workspace}}` | squash, merge home, landing card | +| `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. swe-pro spends the person's key outside codeaf's provider ledger. +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 swe-pro write its terminal record. + 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 swe-pro's calls, because they - did not go through a codeaf lane. The spending page says `via swe-pro`. +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 swe-pro cuts itself first. +conversation's limits (`runCostLeft`, #1281), so senior-dev cuts itself first. ### Stopping @@ -241,15 +248,15 @@ read and folded. Without one the row reads `stopped` with the last stage seen. ### Landing a `tree` delegate -1. swe-pro works in the run's own copy, passed as `--dir`. -2. swe-pro commits every edit as it goes: `wip(edit): `, dozens per run. - These stay on inside the copy, because swe-pro's crash recovery and its +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 swe-pro observed. + record, what the model claimed and what senior-dev observed. 4. That one commit merges home the way every task lands. -5. `.swe-pro/` is git-excluded in the copy and never lands. `refs/swe-pro/*` +5. `.senior-dev/` is git-excluded in the copy and never lands. `refs/senior-dev/*` die with the copy. ## The manual law @@ -266,23 +273,23 @@ it is enforced moves. 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 /swe-pro do" from swe-pro's own page. + 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: `swe-pro: its manual page does not say - /swe-pro — not added`. + 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 /swe-pro here". It never names a delegate + do I add one", "why is there no /senior-dev here". It never names a delegate the build cannot promise exists. -## What swe-pro changed for this +## 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": ""}`. - `swe-pro serve` still requires a plane. The manifest sets no `SWE_PRO_CP_*` + `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 @@ -291,20 +298,20 @@ Landed 2026-09-21 and 2026-09-22 on `zeropoint95/improvements`, PR #30. 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 swe-pro side to touch only the +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 swe-pro side against its code: the outcome table above holds, +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 swe-pro stream replayed through the reader | +| **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; swe-pro's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to swe-pro", "what does /swe-pro do", "why can't the delegate ask me", "difference between /harness and /swe-pro" | +| **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 | — | @@ -313,6 +320,6 @@ can type. ## Open questions -1. **Who picks swe-pro's models.** Today its own `--high` default. The manifest - could pass codeaf's work seat, but swe-pro speaks OpenRouter slugs and the +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/internal/delegate/launch.go b/internal/delegate/launch.go index 17bf4d509..b5158da29 100644 --- a/internal/delegate/launch.go +++ b/internal/delegate/launch.go @@ -23,7 +23,7 @@ import ( // 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: swe-pro ships its frozen tree on +// 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 diff --git a/internal/delegate/load_test.go b/internal/delegate/load_test.go index e67321a35..d4789e0ee 100644 --- a/internal/delegate/load_test.go +++ b/internal/delegate/load_test.go @@ -130,7 +130,7 @@ func TestLoadRefusesTheThingsValidateRefuses(t *testing.T) { "no brief": strings.Replace(goodManifest, `"--", "{{brief}}"`, `"--"`, 1), "a bad name": strings.Replace(goodManifest, `"name": "fake"`, `"name": "Fake Thing"`, 1), "an unknown lands": strings.Replace(goodManifest, `"lands": "tree"`, `"lands": "branch"`, 1), - "an unknown field": strings.Replace(goodManifest, `"lands": "tree"`, `"lands": "tree", "reader": "swe-pro"`, 1), + "an unknown field": strings.Replace(goodManifest, `"lands": "tree"`, `"lands": "tree", "reader": "senior-dev"`, 1), "not json": "{", } for name, manifest := range cases { diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go index 9202eb4e6..fe10249d3 100644 --- a/internal/delegate/protocol.go +++ b/internal/delegate/protocol.go @@ -2,7 +2,7 @@ package delegate // The protocol: one JSON object per line on the program's stdout, four record // types read, everything else ignored (docs/DELEGATE-PROTOCOL.md §2). Ignoring -// the rest is what makes the reader generic — swe-pro's bus payloads and any +// the rest is what makes the reader generic — senior-dev's bus payloads and any // future 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 a delegate. @@ -24,8 +24,8 @@ const ( RecordTerminal = "terminal" ) -// The terminal statuses. The set is closed and it is swe-pro's, because -// swe-pro's projection of an ending onto four words was already the right one: +// 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 ( @@ -50,7 +50,7 @@ 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 -// swe-pro's own, through the accessors below rather than by every caller +// senior-dev's own, through the accessors below rather than by every caller // knowing both. type Terminal struct { Status string `json:"status"` @@ -65,11 +65,11 @@ func (t Terminal) CostUSD() (float64, bool) { return t.number("cost_usd") } 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 swe-pro's record. +// `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. -// swe-pro spells its observation as its own inner status and a count of +// 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 { diff --git a/internal/delegate/protocol_test.go b/internal/delegate/protocol_test.go index 1a4420fcd..a0388c552 100644 --- a/internal/delegate/protocol_test.go +++ b/internal/delegate/protocol_test.go @@ -47,12 +47,12 @@ func (r *recorder) Terminal(t Terminal) { r.terminal = &t } -// A recorded swe-pro stream, taken from EVENTS-CONTRACT.md's shapes, read +// 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 spend // reaches the bank, the steps reach the page, the terminal is the result, and // every bus payload passes through untouched. -func TestTheReaderReplaysASweProStream(t *testing.T) { - data, err := os.ReadFile(filepath.Join("testdata", "swe-pro-stream.ndjson")) +func TestTheReaderReplaysASeniorDevStream(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "senior-dev-stream.ndjson")) if err != nil { t.Fatal(err) } @@ -62,7 +62,7 @@ func TestTheReaderReplaysASweProStream(t *testing.T) { t.Fatal(err) } if reading.Terminal == nil || reading.Terminal.Status != StatusPass { - t.Fatalf("terminal = %+v, want the pass swe-pro wrote last", reading.Terminal) + 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) @@ -85,16 +85,16 @@ func TestTheReaderReplaysASweProStream(t *testing.T) { 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 swe-pro's spelling. + // The terminal's optional keys read in senior-dev's spelling. 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 swe-pro's submission_reason", sink.terminal.Claim()) + t.Fatalf("claim = %q, want senior-dev's submission_reason", sink.terminal.Claim()) } if sink.terminal.Observed() != "pass" { - t.Fatalf("observed = %q, want swe-pro's own inner status", sink.terminal.Observed()) + t.Fatalf("observed = %q, want senior-dev's own inner status", sink.terminal.Observed()) } } @@ -143,7 +143,7 @@ func TestTheReaderCapsAStepOnARuneBoundary(t *testing.T) { } } -func TestObservedReadsSweProsVerificationCount(t *testing.T) { +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 { diff --git a/internal/delegate/testdata/swe-pro-stream.ndjson b/internal/delegate/testdata/senior-dev-stream.ndjson similarity index 97% rename from internal/delegate/testdata/swe-pro-stream.ndjson rename to internal/delegate/testdata/senior-dev-stream.ndjson index b04eb65fc..051154217 100644 --- a/internal/delegate/testdata/swe-pro-stream.ndjson +++ b/internal/delegate/testdata/senior-dev-stream.ndjson @@ -1,7 +1,7 @@ {"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":".swe-pro/spec.md","spec_bytes":42},"ts":1725000000020} +{"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}}}} diff --git a/internal/manual/overlay_test.go b/internal/manual/overlay_test.go index 5413a9954..a55c7c905 100644 --- a/internal/manual/overlay_test.go +++ b/internal/manual/overlay_test.go @@ -8,34 +8,34 @@ import ( // A page layered over the chat corpus is listed, read and searched beside the // packed pages, and the packed corpus is not changed by it. func TestAnOverlayPageIsSearchedReadAndListedBesideThePackedOnes(t *testing.T) { - page := "# swe-pro\n\n## What /swe-pro does — hand a large change to swe-pro\n\nswe-pro is an autonomous coding agent. Type `/swe-pro `.\n\n## What swe-pro cannot do\n\nIt cannot ask you anything.\n" - layered := Chat().WithPages(map[string]string{"delegate-swe-pro": page}) + page := "# senior-dev\n\n## What /senior-dev does — hand a large change to senior-dev\n\nsenior-dev is an autonomous coding agent. Type `/senior-dev `.\n\n## What senior-dev cannot do\n\nIt cannot ask you anything.\n" + layered := Chat().WithPages(map[string]string{"delegate-senior-dev": page}) if layered == Chat() { t.Fatal("WithPages with a page answered the same corpus") } - text, ok := layered.Page("delegate-swe-pro") + text, ok := layered.Page("delegate-senior-dev") if !ok || !strings.Contains(text, "autonomous coding agent") { t.Fatalf("the overlay page cannot be read: %v %q", ok, text) } - if !layered.Mentions("/swe-pro") { + if !layered.Mentions("/senior-dev") { t.Fatal("the layered corpus does not mention the delegate's command") } found := false for _, name := range layered.Pages() { - found = found || name == "delegate-swe-pro" + found = found || name == "delegate-senior-dev" } if !found { t.Fatalf("the overlay page is not listed: %v", layered.Pages()) } - hits := layered.Search("what does /swe-pro do", 4) - if len(hits) == 0 || hits[0].Page != "delegate-swe-pro" { + hits := layered.Search("what does /senior-dev do", 4) + if len(hits) == 0 || hits[0].Page != "delegate-senior-dev" { t.Fatalf("the question did not reach the overlay page first: %+v", hits) } // And a packed page is still there, unchanged. if _, ok := layered.Page("delegates"); !ok { t.Fatal("the packed delegates page is gone from the layered corpus") } - if _, ok := Chat().Page("delegate-swe-pro"); ok { + if _, ok := Chat().Page("delegate-senior-dev"); ok { t.Fatal("the packed corpus learnt the overlay page") } } diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 98c1421c4..2d407dec9 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -87,7 +87,7 @@ type delegateSink struct { func (s *delegateSink) Stage(stage, status string) { // THE LIVE STEP IS THE PROGRAM'S PHASE, numbered after the last step - // recorded, so the row reads "swe-pro: implement · running" while the + // recorded, so the row reads "senior-dev: implement · running" while the // program is inside that phase and the count on the row stays the steps'. label := s.name + ": " + stage if status != "" { diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 4f9465452..083de6c24 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -109,7 +109,7 @@ var delegateFact = beltFact{ // chatManual is the manual this conversation answers from: the packed corpus, // with every installed delegate's own page layered over it under // `delegate-` (internal/manual's overlay). It is what makes "what does -// /swe-pro do" answerable from swe-pro's page and nowhere else, and it is built +// /senior-dev do" answerable from senior-dev's page and nowhere else, and it is built // once per agent because the registry is read once per launch. func (a *Agent) chatManual() *manual.Corpus { a.manualOnce.Do(func() { @@ -193,7 +193,7 @@ func delegateStand(workspace string, m delegate.Manifest) taskStand { // landDelegateRun is a delegated run's landing, in place of the engine's own. // -// A TREE DELEGATE'S COMMITS ARE SQUASHED. swe-pro commits every edit as it goes +// A TREE DELEGATE'S COMMITS ARE SQUASHED. senior-dev commits every edit as it goes // (`wip(edit): `, dozens a run), so the copy's branch holds bookkeeping // history that is the program's own and nobody else's; the engine's landing // would also find nothing to commit, because everything is already committed, diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index d0245cf56..3c7935ce0 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -42,7 +42,7 @@ func installTestDelegate(t *testing.T, name string) *delegate.Registry { // squashed into ONE commit whose subject is the task's title and whose body is // the run's result, and that commit comes home to the folder the copy was cut // from. The engine is a double whose `work` hook plays the program: two files, -// two commits, the way swe-pro commits every edit. +// two commits, the way senior-dev commits every edit. func TestADelegatedRunSquashesTheProgramsCommitsIntoOneAndLandsIt(t *testing.T) { // The double answers the run's result off the completer it is handed, so // the result is scripted there: the sentence the landing commit must carry. diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 6a9477d7f..964f22803 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -232,7 +232,7 @@ var commands = []command{ alias: []string{"sub"}}, {name: "subharness", args: "", desc: "…straight to that one's card"}, // THE DELEGATES: outside programs a whole task can be handed to. Each - // installed one is a row of its own — `/swe-pro ` — generated at + // installed one is a row of its own — `/senior-dev ` — generated at // launch from its manifest (delegate.go), so this row is the list and the // long form, never the only door. {name: "delegate", desc: "the outside programs a task can be handed to whole", alias: []string{"delegates"}}, diff --git a/internal/tui3/delegate_test.go b/internal/tui3/delegate_test.go index 12f671a2d..03dd1caa1 100644 --- a/internal/tui3/delegate_test.go +++ b/internal/tui3/delegate_test.go @@ -107,12 +107,12 @@ func TestADelegateRowWithNoBriefSaysItsUsage(t *testing.T) { func TestSlashDelegateListsTheRowsAndTheOnesNotHere(t *testing.T) { a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "text", Bin: "/opt/fake"}) - fake.report.Absent = []string{"swe-pro: swe-pro is not on this machine"} + fake.report.Absent = []string{"senior-dev: senior-dev is not on this machine"} fake.report.Refused = []string{"broken: its manual page does not say /broken — not added"} a.width = 200 settleDoor(t, a, a.slash("/delegate")) got := plain(frame(a)) - for _, want := range []string{"/fake ", "a fake delegate", "answers in the conversation", "not here: swe-pro", "not added: broken"} { + for _, want := range []string{"/fake ", "a fake delegate", "answers in the conversation", "not here: senior-dev", "not added: broken"} { if !strings.Contains(got, want) { t.Fatalf("/delegate did not say %q:\n%s", want, got) } From 4237e012a76c13139c7d90e0d04c75f91bd7cae1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:52:55 -0400 Subject: [PATCH 017/195] delegate: a brief reaches the program as written, even when it spells a placeholder fill substituted a brief into argv and then kept rewriting it in map order, and checked the RESULT for unknown placeholders. So a review of a Go template or a Helm chart ({{ .Name }}) refused the launch every time, and a brief that said {{key}} or {{workspace}} could be rewritten after insertion, splicing the person's API key into a command line. Each element is now substituted in one pass by a strings.Replacer, which never rescans what it inserted, and the unknown-placeholder check reads the manifest's own text. Reported by the pr-af session with a reproduction; both faults are pinned by a test through Run that fails on the old code. Co-Authored-By: Claude Opus 5.5 --- internal/delegate/launch.go | 26 +++++++++++++++------ internal/delegate/launch_test.go | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go index b5158da29..926afde27 100644 --- a/internal/delegate/launch.go +++ b/internal/delegate/launch.go @@ -230,6 +230,15 @@ func (nopCloser) Close() error { return nil } // a program with no ceiling set is handed no `--max-cost` at all rather than a // zero it might read as "spend nothing". dropFlags is off for env values, where // there is no flag to drop and an empty fill leaves the variable unset. +// +// A FILLED VALUE IS NEVER READ AGAIN. Each element is substituted in one pass +// ([strings.Replacer] does not rescan what it inserted), and the check for a +// placeholder this build does not fill reads the manifest's own text, never the +// result. Both are there because the brief is the person's words: a review of a +// Go template or a Helm chart says `{{ .Name }}`, which must reach the program +// as written rather than refuse the launch, and a brief that says `{{key}}` +// must not have the person's key spliced into a command line every process on +// the machine can read. func fill(argv []string, fills Fills, dropFlags bool) ([]string, error) { values := map[string]string{ FillBrief: fills.Brief, @@ -247,6 +256,11 @@ func fill(argv []string, fills Fills, dropFlags bool) ([]string, error) { } else { values[FillHours] = "" } + pairs := make([]string, 0, 2*len(values)) + for placeholder, value := range values { + pairs = append(pairs, placeholder, value) + } + replacer := strings.NewReplacer(pairs...) out := make([]string, 0, len(argv)) for _, arg := range argv { if value, whole := values[arg]; whole && value == "" && (arg == FillCostUSD || arg == FillHours || arg == FillKey || arg == "{{key:openrouter}}") { @@ -255,14 +269,12 @@ func fill(argv []string, fills Fills, dropFlags bool) ([]string, error) { } continue } - filled := arg - for fill, value := range values { - filled = strings.ReplaceAll(filled, fill, value) - } - if rest := fillShape.FindString(filled); rest != "" { - return nil, fmt.Errorf("%s is not a placeholder this build fills", rest) + for _, placeholder := range fillShape.FindAllString(arg, -1) { + if _, known := values[placeholder]; !known { + return nil, fmt.Errorf("%s is not a placeholder this build fills", placeholder) + } } - out = append(out, filled) + out = append(out, replacer.Replace(arg)) } return out, nil } diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go index 26009ddff..0f4b95d76 100644 --- a/internal/delegate/launch_test.go +++ b/internal/delegate/launch_test.go @@ -86,6 +86,45 @@ func TestRunDropsACeilingFlagWhoseValueIsUnset(t *testing.T) { } } +// THE BRIEF IS THE PERSON'S WORDS AND REACHES THE PROGRAM AS WRITTEN. A review +// of a Go template says `{{ .Name }}`, which once refused the launch because +// the unknown-placeholder check read the filled argv; and a brief that said +// `{{key}}` or `{{workspace}}` was rewritten after it had been inserted, which +// put the person's key on a command line. Found by the pr-af session. +func TestRunHandsTheBriefOverVerbatimEvenWhenItSpellsAPlaceholder(t *testing.T) { + m := fakeProgram(t, terminalLine("pass", "done")) + args := filepath.Join(t.TempDir(), "args") + t.Setenv("FAKE_ARGS", args) + const secret = "sk-or-v1-not-for-argv" + for i := 0; i < 20; i++ { // map order once decided the outcome, so ask it more than once + // The second brief spells only placeholders this build knows, so the old + // refusal cannot hide the rewrite behind it. + brief := "https://github.com/o/r/pull/1 check {{ .Name }} escaping, and {{key}} in {{workspace}} under {{brief}}" + if i%2 == 1 { + brief = "https://github.com/o/r/pull/1 where does {{key}} go under {{workspace}}" + } + if _, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: brief, Workspace: t.TempDir(), Key: secret}}, nil); err != nil { + t.Fatalf("a brief spelling a placeholder refused the launch: %v", err) + } + got, _ := os.ReadFile(args) + lines := strings.Split(strings.TrimRight(string(got), "\n"), "\n") + if last := lines[len(lines)-1]; last != brief { + t.Fatalf("the brief reached the program as\n%q\nwant it verbatim", last) + } + if strings.Contains(string(got), secret) { + t.Fatal("the person's key was spliced into the command line") + } + } +} + +// The check a filled brief no longer trips still holds for the manifest's own +// text: a placeholder this build does not fill refuses the launch. +func TestFillRefusesAnUnknownPlaceholderInTheManifest(t *testing.T) { + if _, err := fill([]string{"--x", "{{typo}}"}, Fills{Brief: "b"}, true); err == nil || !strings.Contains(err.Error(), "{{typo}}") { + t.Fatalf("err = %v, want the unknown placeholder named", err) + } +} + func TestRunAnswersNoTerminalWhenTheProgramExitsWithoutOne(t *testing.T) { m := fakeProgram(t, "exit 3") result, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}}, nil) From e49bdd1210f3f9bfaa94807aa0d93f4643da63f9 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:26:37 -0400 Subject: [PATCH 018/195] delegate: the programs are built in, and the manifest road is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was true: a delegate was an outside program installed by hand — a manifest and a page in ~/.codeaf/delegates, a binary on PATH, `{{placeholders}}` filled at launch with the person's key among them — listed by `/delegate` and documented in the public docs/DELEGATE-PROTOCOL.md. What is true now (the owner's direction of 2026-09-23): the programs codeaf can hand a whole task to are compiled into it. internal/delegate is the v2 contract — a Delegate is a Go value in internal/delegate/builtin's list, with its own commands and flags; it runs as a child of codeaf's own executable (`codeaf run --json --dir … -- `); it writes `hello`, `stage`, `step` and `terminal` on stdout; and it reaches a model only through the model API codeaf will serve each run, whose address and token are the only model names in its environment. ChildEnv takes every provider key and redirection out, so no key reaches a program or a command its model runs. A `hello` of another protocol stops the run: the engine outlived a rebuild. The conversation log record (delegate.Turn) is fixed here for the API to write and the task page to read. Gone: the manifest, its validator and fill, the loader and its folder, `/delegate`, the manual overlay, docs/delegates and the public protocol page (kept on the tag delegate-manifest-v1). The chat's `/` rows, the door, `propose_task`'s `via`, the run road and the squashed landing carry over. "Delegate" is a working title, so every sentence a person or the model reads names the program itself. docs/design/delegate/PROTOCOL.md is the internal page. The build carries no program yet; senior-dev joins the list with its engine. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/carried.go | 64 +++++ cmd/codeaf/chatv3.go | 4 +- cmd/codeaf/chatv3_delegate.go | 24 +- cmd/codeaf/main.go | 7 + docs/DELEGATE-PROTOCOL.md | 163 ------------ docs/delegates/senior-dev.json | 9 - docs/delegates/senior-dev.md | 40 --- docs/design/delegate/DESIGN.md | 12 + docs/design/delegate/PROTOCOL.md | 112 ++++++++ internal/delegate/builtin/builtin.go | 54 ++++ internal/delegate/builtin/builtin_test.go | 18 ++ internal/delegate/builtin/carried_unix.go | 9 + internal/delegate/builtin/carried_windows.go | 11 + internal/delegate/cli.go | 212 +++++++++++++++ internal/delegate/cli_test.go | 170 +++++++++++++ internal/delegate/conversation.go | 187 ++++++++++++++ internal/delegate/conversation_test.go | 63 +++++ internal/delegate/delegate.go | 255 ++++++++++--------- internal/delegate/emit.go | 139 ++++++++++ internal/delegate/emit_test.go | 39 +++ internal/delegate/host.go | 106 ++++++++ internal/delegate/launch.go | 126 ++------- internal/delegate/launch_test.go | 151 ++++++----- internal/delegate/load.go | 244 ------------------ internal/delegate/load_test.go | 163 ------------ internal/delegate/protocol.go | 58 ++++- internal/delegate/protocol_test.go | 31 +++ internal/manual/chat/commands.md | 29 +-- internal/manual/chat/delegates.md | 174 ++++++------- internal/manual/overlay.go | 90 ------- internal/manual/overlay_test.go | 55 ---- internal/remote/client.go | 11 +- internal/remote/wire.go | 2 +- internal/remote/wire_task.go | 16 +- internal/run/delegateworker.go | 128 ++++++---- internal/run/delegateworker_test.go | 66 +++-- internal/run/enginewire.go | 2 +- internal/session/delegate_door.go | 154 +++++------ internal/session/delegate_door_test.go | 93 ++----- internal/session/session.go | 20 +- internal/session/task.go | 8 +- internal/session/task_run_belt.go | 46 ++-- internal/session/tools_manual.go | 22 +- internal/session/tools_manual_bound.go | 4 +- internal/tui3/app.go | 15 +- internal/tui3/commands.go | 6 - internal/tui3/delegate.go | 116 ++------- internal/tui3/delegate_test.go | 80 ++---- internal/tui3/detach.go | 2 +- internal/tui3/homeslash.go | 6 +- internal/tui3/offlooplaw_test.go | 2 +- 51 files changed, 1966 insertions(+), 1652 deletions(-) create mode 100644 cmd/codeaf/carried.go delete mode 100644 docs/DELEGATE-PROTOCOL.md delete mode 100644 docs/delegates/senior-dev.json delete mode 100644 docs/delegates/senior-dev.md create mode 100644 docs/design/delegate/PROTOCOL.md create mode 100644 internal/delegate/builtin/builtin.go create mode 100644 internal/delegate/builtin/builtin_test.go create mode 100644 internal/delegate/builtin/carried_unix.go create mode 100644 internal/delegate/builtin/carried_windows.go create mode 100644 internal/delegate/cli.go create mode 100644 internal/delegate/cli_test.go create mode 100644 internal/delegate/conversation.go create mode 100644 internal/delegate/conversation_test.go create mode 100644 internal/delegate/emit.go create mode 100644 internal/delegate/emit_test.go create mode 100644 internal/delegate/host.go delete mode 100644 internal/delegate/load.go delete mode 100644 internal/delegate/load_test.go delete mode 100644 internal/manual/overlay.go delete mode 100644 internal/manual/overlay_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go new file mode 100644 index 000000000..6e04b6024 --- /dev/null +++ b/cmd/codeaf/carried.go @@ -0,0 +1,64 @@ +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. + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// 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, os.Stdout) + 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 := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if _, child := delegate.ModelAPIFromEnv(); child { + return carriedExit(delegate.RunChild(ctx, inv, os.Stdout)) + } + return runCarriedHost(ctx, inv) +} + +// runCarriedHost is a person's shell run: this process serves the model API +// with the person's key and starts the program as its own child. +func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { + fmt.Fprintf(os.Stderr, "error: codeaf %s runs from a shell once its model API is in this build; the chat's /%s is the road until then\n", inv.Program.Name, inv.Program.Name) + return exitCannotRun +} + +// 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 + } +} diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index c4e6ba327..ddc76e1d5 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1048,8 +1048,8 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { SubharnessMemory: subharnesses.Memory, SubharnessLastRun: subharnesses.LastRun, SubharnessRecordRun: subharnesses.Record, - // AND THE DELEGATES, the outside programs a task can be handed to - // whole (chatv3_delegate.go). Nil is delegates off, on the terms above. + // 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 diff --git a/cmd/codeaf/chatv3_delegate.go b/cmd/codeaf/chatv3_delegate.go index 3c6537545..f61981aa0 100644 --- a/cmd/codeaf/chatv3_delegate.go +++ b/cmd/codeaf/chatv3_delegate.go @@ -2,23 +2,13 @@ package main import ( "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" ) -// THE DELEGATE SIDE OF ONE CONVERSATION: the outside programs a task can be -// handed to whole (internal/delegate, docs/DELEGATE-PROTOCOL.md). The registry -// is read here, at the door, for the reason every other registry on this path -// is (chatv3_subharness.go): where the manifests live is the SURFACE'S decision, -// and internal/session is handed the registry and nothing about a directory. -// -// IT IS SILENT ON FAILURE, in the same posture: a folder that cannot be read -// means DELEGATES OFF — the door lists nothing, `via` refuses every name, the -// prompt says nothing — and a registry is not worth failing a launch over. A -// manifest the loader would not admit is not a failure of the launch either; it -// is a line on `/delegate`, which is where the person who wrote it will look. -func v3Delegates() *delegate.Registry { - registry, err := delegate.Load(delegate.Dir()) - if err != nil { - return nil - } - return registry +// 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/main.go b/cmd/codeaf/main.go index 1e25b2d8f..4ec123bda 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]) } } diff --git a/docs/DELEGATE-PROTOCOL.md b/docs/DELEGATE-PROTOCOL.md deleted file mode 100644 index 267fd61f2..000000000 --- a/docs/DELEGATE-PROTOCOL.md +++ /dev/null @@ -1,163 +0,0 @@ -# The delegate protocol - -*Version 1, 2026-09-22. What a program must do to be a codeaf delegate. The -design behind it is `docs/design/delegate/DESIGN.md`. Conforming today: -`senior-dev` (`swe-pro-go`, branch `zeropoint95/improvements @ 6103488`). It was -called `swe-pro` until `b43daaf`; the commit pins in §8 predate that rename.* - -A delegate is an outside program codeaf hands one task to. codeaf starts it, -reads its stdout, stops it when a limit is hit, and takes its result. This page -is the whole interface. If a program does what is written here, a manifest -and a manual page beside it are all codeaf needs. - -## 1. Launch - -codeaf runs the program once per task, as a child process, with: - -| handed over | how | -| --- | --- | -| the brief, as text | an argv slot, `{{brief}}` | -| a directory to work in | an argv slot, `{{workspace}}` (absolute) | -| a dollar ceiling | an argv slot, `{{cost_usd}}` | -| a wall-clock ceiling in hours | an argv slot, `{{hours}}` | -| API keys | environment, resolved by codeaf from the person's profile | - -The program must: - -1. Take all of these on the command line or in the environment. There is no - stdin. Nothing is written to it and nothing is read from it. -2. Treat the ceilings as its own limits and stop itself when it reaches one. - codeaf also enforces them from outside, but a program that cuts itself - first ends cleanly and keeps its result. -3. Start with no interactive step. It cannot ask anything. - -## 2. Stream - -stdout carries **one JSON object per line and nothing else**. stderr is the -program's own; codeaf keeps it for a person to read and never parses it. - -Four record types are read. **Any other line is ignored**, so a program may -put whatever else it likes on stdout as long as every line is a JSON object. - -| record | fields | meaning | -| --- | --- | --- | -| `{"type":"stage","stage":S,"status":T}` | `stage`, `status`: short strings | the live step shown on the rail, `S · T`. Emit on every phase change | -| `{"type":"spend","cost_usd":C}` | `cost_usd`: number, **cumulative for the whole run, never decreasing** | what the run has cost so far. Emit after every model call. Emit even at zero | -| `{"type":"step","command":X,"observation":Y}` | `command`: one line, ≤ 200 bytes; `observation`: optional, ≤ 2048 bytes, valid UTF-8 | one row on the task page. Emit once per finished tool call or action. Optional: a program with none is drawn by its stages | -| `{"type":"terminal","status":U,"message":M,"data":{…}}` | see §3 | the result. **Exactly one, and the last record** | - -Every record may carry `"ts"`: unix milliseconds. Extra fields are ignored. - -## 3. Terminal - -The terminal record is the result. codeaf reads it and nothing else for the -verdict, and it does not read the exit code for the verdict. - -```json -{"type":"terminal","status":"pass","message":"submitted and verified", - "data":{"cost_usd":0.42,"reason":"…","claim":"…","observed":"…","deliverable":"…"}} -``` - -| field | required | values | -| --- | --- | --- | -| `status` | yes | `pass`, `fail`, `budget-exhausted`, `crashed` | -| `message` | yes | one sentence saying why | -| `data.cost_usd` | yes | the final total. Must be ≥ the last `spend` | -| `data.reason` | no | a longer reason | -| `data.claim` | no | what the program's model said it did | -| `data.observed` | no | what the program itself verified. Kept separate from `claim`, never merged | -| `data.deliverable` | for `lands: text` | the answer text | - -What codeaf makes of `status`: - -| `status` | rail word | meaning | -| --- | --- | --- | -| `pass` | done | the work stands | -| `fail` | incomplete | it ran and the work does not stand | -| `budget-exhausted` | stopped, naming the limit | a ceiling was reached before it passed | -| `crashed` | incomplete | the program itself failed | - -A process that exits with no terminal record is read as `incomplete`, with -the last `stage` seen as the reason. **Emit the terminal on every path**, -including error and signal. - -## 4. Stop - -codeaf sends **SIGTERM** to the process group when a limit is hit or a person -presses stop, then waits a grace period, then SIGKILL. - -The program must, on SIGTERM: - -1. Stop starting new work. -2. Write its terminal record, with the true `status` and `cost_usd`. -3. Exit. - -A terminal record inside the grace is kept. After SIGKILL nothing is read. - -## 5. Result on disk - -The manifest says which of two things the program produces. - -| `lands` | the program must | codeaf then | -| --- | --- | --- | -| `tree` | leave its changes in `{{workspace}}`, as commits on the current branch or as a dirty tree, and **nothing that is not its work** (its own state files git-excluded or outside the tree) | squashes everything past the cut point into one commit and merges it home | -| `text` | change nothing in `{{workspace}}`; put the answer in `data.deliverable` | folds the text into the conversation | - -## 6. What the program may not do - -- Ask a question and wait for an answer. There is nobody there. -- Read stdin. -- Write anything to stdout that is not a JSON object on its own line. -- Exit before writing the terminal record, except when killed. -- For `lands: tree`, touch files outside `{{workspace}}`. - -## 7. The manifest and the manual page - -Two files in `~/.codeaf/delegates/`: - -```jsonc -// 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", // on PATH, or a path - "argv": ["run", "--dir", "{{workspace}}", - "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", - "--", "{{brief}}"], - "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" }, - "lands": "tree", - "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } -} -``` - -- `name` is one lowercase word and becomes the command. It may not collide - with a built-in command or alias. -- `{{key:}}` is filled from the person's profile. -- A `bin` not found means the delegate is not offered. Nothing fails. - -`senior-dev.md` beside it is the delegate's manual page: what it does, how to -ask it, what it cannot do, what a run costs, where the work lands. It follows -the rules of `internal/manual/chat/` pages and **must mention `/`**. A -page that does not refuses the manifest. - -## 8. Conformance: senior-dev - -| requirement | senior-dev | -| --- | --- | -| launch from argv | `senior-dev run --dir D --max-cost X --max-hours H -- "goal"` | -| no stdin, no questions | nothing reads stdin; `question` is auto-rejected | -| stdout is JSON lines only | yes, EVENTS-CONTRACT.md | -| `stage` | yes, thirteen stages | -| `spend`, cumulative, top-level | yes, since `5793499` | -| `step` per tool call | yes, since `5793499` | -| exactly one `terminal`, last, on every path | yes, including crash and signal | -| `status` set | `pass`, `fail`, `budget-exhausted`, `crashed`, exactly | -| SIGTERM writes the terminal | yes | -| `lands: tree`, own state excluded | `.senior-dev/` is in `.git/info/exclude`; `refs/senior-dev/*` stay in the copy | -| runs without a control plane | yes, since `f3b9716` | -| name | `senior-dev` since `b43daaf`; the binary is built from `./cmd/senior-dev` | - -Not yet mapped on senior-dev's side: `data.claim` and `data.observed` are spelled -`submission_reason` / `submission_evidence` and `status` / -`verification_failing` in its `data`. The reader accepts senior-dev's spellings -for these two optional fields. diff --git a/docs/delegates/senior-dev.json b/docs/delegates/senior-dev.json deleted file mode 100644 index b54ab0aa4..000000000 --- a/docs/delegates/senior-dev.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "senior-dev", - "description": "an autonomous coding agent for one large, well-specified change", - "bin": "senior-dev", - "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], - "env": { "OPENROUTER_API_KEY": "{{key}}" }, - "lands": "tree", - "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } -} diff --git a/docs/delegates/senior-dev.md b/docs/delegates/senior-dev.md deleted file mode 100644 index a480df3d6..000000000 --- a/docs/delegates/senior-dev.md +++ /dev/null @@ -1,40 +0,0 @@ -# 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 that -runs on this machine as its own program. codeaf gives it a copy of your folder, this -conversation's dollar and time limits, and your OpenRouter key; senior-dev maps the -repository, pins a test command, edits, runs the tests, and freezes a candidate it has -verified. When it ends, its work is squashed into one commit on your branch. - -Use it for one change that is big enough to want an agent of its own for an hour and is -specified well enough that nobody will be asked anything: a rewrite across a package, a -migration, a feature with tests. A change you would do in a few steps is not worth it. - -## What senior-dev cannot do — it cannot ask, it has no step cap, it reports its own checking - -senior-dev runs unattended. A question its model tries to ask is turned down inside the -program, so put everything it would stop and ask into the brief: the files, the -constraints, the wrong answer to avoid, how to check the result. - -It has no step cap. It is held to the dollar and hour ceilings codeaf hands it on its -command line, and codeaf stops it from outside at the same limits. On its task page the -step count is what it reported. - -Its result keeps two things apart: what its model claimed it did, and what senior-dev itself -observed when it ran the project's tests on the frozen tree. Read the second for "did it -work". - -## Where its work goes and what it costs - -senior-dev commits after every edit inside its copy. At landing those commits are squashed -into one commit whose subject is the task's title and whose body is senior-dev's account of -the ending, and that commit is merged into your folder. Its spending is folded into this -conversation's total as it reports it, under the name `senior-dev` on the spending page. - -## Installing it — why there is no /senior-dev here - -The row exists only where the `senior-dev` binary is on PATH. Put this file and -`senior-dev.json` in `~/.codeaf/delegates/` and start codeaf again. `/delegate` says what it -found. diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 8e9778028..8353d002b 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -1,5 +1,17 @@ # 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. 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 landing (one squashed commit for a tree, the answer folded in for +> text), the stop and the reader below all carry over. + + *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.* diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md new file mode 100644 index 000000000..0e41fa877 --- /dev/null +++ b/docs/design/delegate/PROTOCOL.md @@ -0,0 +1,112 @@ +# 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, what it lands (`tree` or `text`), its +commands with their own flags, its default command, 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. + +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] -- +``` + +- **From the chat,** the engine's run (`internal/run`'s `DelegateWorker`) starts + that line in the run's working copy. +- **From a shell,** `codeaf ` becomes the host: it serves the model + API itself 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; +2. goes through codeaf's own model funnel, with its router, retries, caching and + billing; +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. + +## 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` | +| `step` | once per finished action | `command` (one line, 200 bytes at most), `observation` (2048 bytes at most) | +| `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. `spend` is still read until the model API meters +every call; after that it is redundant, because the API is the one source of +truth for money. + +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`). + +## 6. The conversation 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. The page draws the turns as +the conversation between the program and codeaf. + +## 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). + +## 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/delegate/builtin/builtin.go b/internal/delegate/builtin/builtin.go new file mode 100644 index 000000000..7f7964192 --- /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 000000000..7f2daff88 --- /dev/null +++ b/internal/delegate/builtin/builtin_test.go @@ -0,0 +1,18 @@ +package builtin + +import "testing" + +// 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 + } +} diff --git a/internal/delegate/builtin/carried_unix.go b/internal/delegate/builtin/carried_unix.go new file mode 100644 index 000000000..4c01bca3b --- /dev/null +++ b/internal/delegate/builtin/carried_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package builtin + +import "github.com/Agent-Field/codeaf/internal/delegate" + +// carried is every program this build carries on a unix. senior-dev joins it +// when its engine lands in internal/seniordev. +var carried = []delegate.Delegate{} diff --git a/internal/delegate/builtin/carried_windows.go b/internal/delegate/builtin/carried_windows.go new file mode 100644 index 000000000..e2cf94765 --- /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 000000000..94b33aeb4 --- /dev/null +++ b/internal/delegate/cli.go @@ -0,0 +1,212 @@ +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" + "path/filepath" + "strconv" + "strings" +) + +// 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 && rest[0] == "help" { + 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 ceiling in dollars; 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. +func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings) []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)) + } + return append(args, "--", brief) +} + +// Help writes a program's help: what it is, its commands, and the flags every +// command takes. +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] runs %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 ceiling in dollars; 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. +func RunChild(ctx context.Context, inv *Invocation, stdout io.Writer) string { + 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 +} + +// 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, status string) { _ = h.emitter.Stage(stage, status) } +func (h *childHost) Step(command, observation string) { _ = h.emitter.Step(command, observation) } +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 000000000..9b1ef520e --- /dev/null +++ b/internal/delegate/cli_test.go @@ -0,0 +1,170 @@ +package delegate + +import ( + "bytes" + "context" + "errors" + "flag" + "strings" + "testing" +) + +// 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", + 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("variant", *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}) + 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) + } +} + +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()) + } +} + +// 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) + } +} diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go new file mode 100644 index 000000000..61ff63839 --- /dev/null +++ b/internal/delegate/conversation.go @@ -0,0 +1,187 @@ +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" + +// 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 000000000..29fad85df --- /dev/null +++ b/internal/delegate/conversation_test.go @@ -0,0 +1,63 @@ +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) + } +} diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index 57895dfba..c96c1d25e 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -1,170 +1,175 @@ -// Package delegate is the half of a delegate that everything else in the -// binary needs: what one IS (a manifest beside its manual page), how the -// installed ones are found, the stdout protocol every delegate speaks and the -// one reader over it, and the launch of the program as a child process that -// streams, stops on SIGTERM and ends with one terminal record. +// 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. // -// A DELEGATE IS AN OUTSIDE PROGRAM CODEAF HANDS A WHOLE TASK TO. codeaf designs -// nothing about it and cannot see inside it; it starts it in a working copy, -// reads its stdout, stops it when a limit is reached and takes its result. The -// contract is docs/DELEGATE-PROTOCOL.md, and this package is its -// implementation. What runs a delegate AS A WORKER of a run — the live step, -// the trajectory, the spend bank — is internal/run's, which builds on this -// package; nothing here knows what a task is. +// "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. // -// THIS PACKAGE IS A LEAF ON PURPOSE. The session door lists delegates and -// checks a name; the run engine seats one; neither may import the other, so -// what they share lives here and imports neither. +// 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" "regexp" "strings" ) -// The two things a delegate can leave behind, named on its manifest. +// The two things a program can leave behind. const ( - // LandsTree is a delegate that works in the working copy it is given and + // LandsTree is a program that works in the working copy it is given and // leaves its changes there: codeaf squashes them into one commit and merges // that home the way every task lands. LandsTree = "tree" - // LandsText is a delegate that changes nothing in the copy and puts its + // 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" ) -// The placeholders a manifest's argv and env may carry, filled at launch. They -// are spelled here once so the loader can refuse one this build does not know -// rather than hand a program a literal `{{typo}}`. -const ( - FillBrief = "{{brief}}" - FillWorkspace = "{{workspace}}" - FillCostUSD = "{{cost_usd}}" - FillHours = "{{hours}}" - // FillKey is the person's API key, resolved by the caller through the same - // door every lane resolves one (config.APIKeyAt). `{{key:openrouter}}` is - // accepted as the same thing, because that is how the protocol page spells - // it and a manifest copied from there must load. - FillKey = "{{key}}" -) - -// Manifest is one delegate as its manifest file states it. Every field a -// person writes is here; nothing is inferred from the binary. -type Manifest struct { - // Name is one lowercase word: the file's name, the command a person types - // (`/ `) and the word every row says out loud. - Name string `json:"name"` - // Description is one sentence saying what the delegate does, in a person's - // words. It is the command row's tail and the offer's second line. - Description string `json:"description"` - // Bin is the program: a bare name resolved on PATH or a path. - Bin string `json:"bin"` - // Argv is the argument list, with placeholders. It never includes the - // program itself. - Argv []string `json:"argv"` - // Env is what is added to the child's environment, with placeholders. The - // child also inherits the parent's environment. - Env map[string]string `json:"env,omitempty"` +// 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 // Lands is LandsTree or LandsText. Empty reads as LandsTree, because a - // delegate that edits a tree is the one this was built for. - Lands string `json:"lands,omitempty"` - // Limits says which bounds the program honours itself. They are recorded - // for the manual page and the offer; codeaf enforces cost and time from - // outside whatever they say. - Limits Limits `json:"limits,omitempty"` - - // Path is the manifest file this was read from, and ManualPath the page - // beside it; Manual is that page's text, kept so the chat's manual can - // layer it over the packed corpus (internal/manual's overlay). All three - // are the loader's, never the file's. - Path string `json:"-"` - ManualPath string `json:"-"` - Manual string `json:"-"` - // BinPath is the program as it resolved at load time. The loader fills it; - // a manifest whose Bin is not found is not in the registry at all. - BinPath string `json:"-"` + // program that edits a tree is the one this was built for. + Lands string + // 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 } -// Limits is the manifest's own account of which bounds the program keeps. -type Limits struct { - Cost bool `json:"cost"` - Elapsed bool `json:"elapsed"` - Steps bool `json:"steps"` - Questions bool `json:"questions"` +// 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, so it has to be -// something a person can type after a slash without quoting. +// 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]+)*$`) -// knownFills is every placeholder the launch fills. A manifest naming any -// other `{{…}}` is refused at load, so a typo is a sentence to the person and -// not a literal handed to the program. -var knownFills = map[string]bool{ - FillBrief: true, FillWorkspace: true, FillCostUSD: true, FillHours: true, FillKey: true, "{{key:openrouter}}": true, -} - -var fillShape = regexp.MustCompile(`\{\{[^}]*\}\}`) +// 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 says whether a manifest is one the launch can run, naming the first -// thing wrong with it in a sentence a person can act on. It does not touch the -// disk: whether the binary exists and whether the page is there are the -// loader's readings, made beside this one. -func (m Manifest) Validate() error { - if strings.TrimSpace(m.Name) == "" { - return errors.New("the manifest names no delegate: `name` is empty") - } - if !nameShape.MatchString(m.Name) { - return fmt.Errorf("%q is not a delegate name: one lowercase word, letters, digits and hyphens", m.Name) - } - if strings.TrimSpace(m.Description) == "" { - return fmt.Errorf("%s: `description` is empty, and it is what the command row says", m.Name) - } - if strings.TrimSpace(m.Bin) == "" { - return fmt.Errorf("%s: `bin` is empty, so there is nothing to run", m.Name) +// 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 len(m.Argv) == 0 { - return fmt.Errorf("%s: `argv` is empty; it must at least carry %s", m.Name, FillBrief) + if strings.TrimSpace(d.Summary) == "" { + return fmt.Errorf("%s: the summary is empty, and it is what the command row says", d.Name) } - if !strings.Contains(strings.Join(m.Argv, "\x00"), FillBrief) { - return fmt.Errorf("%s: `argv` never says %s, so the task would never reach the program", m.Name, FillBrief) - } - switch m.Lands { + switch d.Lands { case "", LandsTree, LandsText: default: - return fmt.Errorf("%s: `lands` is %q; it is %q or %q", m.Name, m.Lands, LandsTree, LandsText) + return fmt.Errorf("%s: lands is %q; it is %q or %q", d.Name, d.Lands, LandsTree, LandsText) } - for _, arg := range m.Argv { - if err := checkFills(m.Name, arg); err != nil { - return err - } + 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) } - for key, value := range m.Env { - if strings.TrimSpace(key) == "" { - return fmt.Errorf("%s: `env` carries an empty variable name", m.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) } - if err := checkFills(m.Name, value); err != nil { - return err + 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) + } return nil } -// checkFills refuses a placeholder the launch does not fill. -func checkFills(name, text string) error { - for _, fill := range fillShape.FindAllString(text, -1) { - if !knownFills[fill] { - return fmt.Errorf("%s: %s is not a placeholder this build fills (they are %s, %s, %s, %s and %s)", - name, fill, FillBrief, FillWorkspace, FillCostUSD, FillHours, FillKey) +// 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 nil + return Command{}, false } -// LandsTree answers whether this delegate's work is a tree to land, which is -// the reading of an empty Lands too. -func (m Manifest) LandsTree() bool { return m.Lands == "" || m.Lands == LandsTree } +// 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 000000000..ae1ccf171 --- /dev/null +++ b/internal/delegate/emit.go @@ -0,0 +1,139 @@ +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. +func (e *Emitter) Stage(stage, status string) error { + return e.write(map[string]any{"type": RecordStage, "stage": stage, "status": status}) +} + +// Step writes one finished action, capped the way the reader caps it, so what +// the program meant to say is what arrives. +func (e *Emitter) Step(command, observation string) error { + record := map[string]any{"type": RecordStep, "command": cut(oneLine(command), commandCap)} + if observation != "" { + record["observation"] = cut(observation, observationCap) + } + 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 000000000..13adfe679 --- /dev/null +++ b/internal/delegate/emit_test.go @@ -0,0 +1,39 @@ +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"}) + _ = emitter.Stage("implement", "running") + _ = emitter.Step("bash: go test\n./...", "ok") + _ = 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 != "implement" || reading.Steps != 1 { + t.Fatalf("reading = %+v", reading) + } + if sink.steps[0] != "bash: go test ./...→ok" { + t.Fatalf("step = %q", sink.steps[0]) + } + 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 000000000..e74c798c5 --- /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, status string) + Step(command, observation string) + 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 index 926afde27..e1fe1c8da 100644 --- a/internal/delegate/launch.go +++ b/internal/delegate/launch.go @@ -1,9 +1,11 @@ package delegate -// The launch: one delegate as a child process, in its own process group, its -// stdout read as the protocol and its stderr kept in a file for a person, ended +// 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/DELEGATE-PROTOCOL.md §1 and §4). +// (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" @@ -13,7 +15,6 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "syscall" "time" @@ -28,24 +29,19 @@ import ( // whole protocol exists for. const DefaultGrace = 15 * time.Second -// Fills is what the launch puts in the manifest's placeholders. -type Fills struct { - Brief string - Workspace string - // CostUSD and Hours are the ceilings handed to the program. Zero means - // none, and a placeholder with no value is DROPPED together with the flag - // before it (see fill), because a program handed `--max-cost 0` may read - // that as a ceiling of nothing. - CostUSD float64 - Hours float64 - // Key is the person's API key, resolved by the caller. - Key string -} - -// Launch is one run of one delegate. +// Launch is one run of one program. type Launch struct { - Manifest Manifest - Fills Fills + // 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. @@ -86,32 +82,9 @@ var ErrNoTerminal = errors.New("the program exited without a terminal record") // 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) { - m := launch.Manifest - bin := m.BinPath - if bin == "" { - bin = m.Bin - } - argv, err := fill(m.Argv, launch.Fills, true) - if err != nil { - return Result{ExitCode: -1}, err - } - env := os.Environ() - for key, value := range m.Env { - filled, err := fill([]string{value}, launch.Fills, false) - if err != nil { - return Result{ExitCode: -1}, err - } - if len(filled) == 0 { - // A variable whose whole value was an empty fill is not set at all, - // so a program that reads "is it set" reads the truth. - continue - } - env = append(env, key+"="+filled[0]) - } - - cmd := exec.Command(bin, argv...) - cmd.Env = env - cmd.Dir = launch.Fills.Workspace + 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) @@ -134,7 +107,7 @@ func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { if err := cmd.Start(); err != nil { _ = stdoutRead.Close() _ = stdoutWrite.Close() - return Result{ExitCode: -1}, fmt.Errorf("start %s: %w", m.Name, err) + return Result{ExitCode: -1}, fmt.Errorf("start %s: %w", launch.Name, err) } _ = stdoutWrite.Close() group := processgroup.CaptureGroup(cmd.Process.Pid) @@ -200,7 +173,7 @@ func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { return result, ctx.Err() } if r.err != nil { - return result, fmt.Errorf("read %s's stdout: %w", m.Name, r.err) + return result, fmt.Errorf("read %s's stdout: %w", launch.Name, r.err) } if result.Reading.Terminal == nil { return result, ErrNoTerminal @@ -223,58 +196,3 @@ func openStderr(path string) (io.WriteCloser, error) { type nopCloser struct{ io.Writer } func (nopCloser) Close() error { return nil } - -// fill replaces placeholders in argv. AN EMPTY CEILING DROPS ITS FLAG: an -// element that is exactly a ceiling placeholder with no value is removed, and -// so is the element before it when that element is a flag (`--max-cost`), so -// a program with no ceiling set is handed no `--max-cost` at all rather than a -// zero it might read as "spend nothing". dropFlags is off for env values, where -// there is no flag to drop and an empty fill leaves the variable unset. -// -// A FILLED VALUE IS NEVER READ AGAIN. Each element is substituted in one pass -// ([strings.Replacer] does not rescan what it inserted), and the check for a -// placeholder this build does not fill reads the manifest's own text, never the -// result. Both are there because the brief is the person's words: a review of a -// Go template or a Helm chart says `{{ .Name }}`, which must reach the program -// as written rather than refuse the launch, and a brief that says `{{key}}` -// must not have the person's key spliced into a command line every process on -// the machine can read. -func fill(argv []string, fills Fills, dropFlags bool) ([]string, error) { - values := map[string]string{ - FillBrief: fills.Brief, - FillWorkspace: fills.Workspace, - FillKey: fills.Key, - "{{key:openrouter}}": fills.Key, - } - if fills.CostUSD > 0 { - values[FillCostUSD] = strconv.FormatFloat(fills.CostUSD, 'f', -1, 64) - } else { - values[FillCostUSD] = "" - } - if fills.Hours > 0 { - values[FillHours] = strconv.FormatFloat(fills.Hours, 'f', -1, 64) - } else { - values[FillHours] = "" - } - pairs := make([]string, 0, 2*len(values)) - for placeholder, value := range values { - pairs = append(pairs, placeholder, value) - } - replacer := strings.NewReplacer(pairs...) - out := make([]string, 0, len(argv)) - for _, arg := range argv { - if value, whole := values[arg]; whole && value == "" && (arg == FillCostUSD || arg == FillHours || arg == FillKey || arg == "{{key:openrouter}}") { - if dropFlags && len(out) > 0 && strings.HasPrefix(out[len(out)-1], "-") { - out = out[:len(out)-1] - } - continue - } - for _, placeholder := range fillShape.FindAllString(arg, -1) { - if _, known := values[placeholder]; !known { - return nil, fmt.Errorf("%s is not a placeholder this build fills", placeholder) - } - } - out = append(out, replacer.Replace(arg)) - } - return out, nil -} diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go index 0f4b95d76..85ed0f504 100644 --- a/internal/delegate/launch_test.go +++ b/internal/delegate/launch_test.go @@ -12,28 +12,37 @@ import ( "time" ) -// fakeProgram is a shell script that behaves as a delegate: it writes its argv -// to the file FAKE_ARGS names, emits a stage, a spend and a step, then runs -// the body it was given. -func fakeProgram(t *testing.T, body string) Manifest { +// 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 +// spend 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 Manifest{ - Name: "fake", - Description: "a fake delegate", - Bin: script, - BinPath: script, - Argv: []string{"run", "--dir", FillWorkspace, "--max-cost", FillCostUSD, "--max-hours", FillHours, "--", FillBrief}, - Env: map[string]string{"FAKE_KEY": FillKey}, + 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), + Env: ChildEnv(api), + Dir: workspace, } } @@ -41,18 +50,16 @@ func terminalLine(status, message string) string { return `echo '{"type":"terminal","status":"` + status + `","message":"` + message + `","data":{"cost_usd":0.02}}'` } -func TestRunFillsTheArgvAndReadsTheTerminal(t *testing.T) { - m := fakeProgram(t, terminalLine("pass", "done")) +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{} - result, err := Run(context.Background(), Launch{ - Manifest: m, - Fills: Fills{Brief: "rewrite the thing", Workspace: workspace, CostUSD: 1.5, Hours: 0.25, Key: "sk-test"}, - StderrPath: stderr, - }, sink) + 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) } @@ -60,74 +67,86 @@ func TestRunFillsTheArgvAndReadsTheTerminal(t *testing.T) { t.Fatalf("result = %+v", result) } got, _ := os.ReadFile(args) - want := "run\n--dir\n" + workspace + "\n--max-cost\n1.5\n--max-hours\n0.25\n--\nrewrite the thing\n" + 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.spend[0] != 0.01 || sink.steps[0] != "bash: true→ok" { + if sink.hello == nil || sink.hello.Delegate != "fake" || sink.spend[0] != 0.01 || sink.steps[0] != "bash: true→ok" { t.Fatalf("sink = %+v", sink) } } -func TestRunDropsACeilingFlagWhoseValueIsUnset(t *testing.T) { - m := fakeProgram(t, terminalLine("pass", "done")) +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(), Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: workspace}}, nil); err != nil { + 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) != "run\n--dir\n"+workspace+"\n--\nb\n" { + 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. A review -// of a Go template says `{{ .Name }}`, which once refused the launch because -// the unknown-placeholder check read the filled argv; and a brief that said -// `{{key}}` or `{{workspace}}` was rewritten after it had been inserted, which -// put the person's key on a command line. Found by the pr-af session. -func TestRunHandsTheBriefOverVerbatimEvenWhenItSpellsAPlaceholder(t *testing.T) { - m := fakeProgram(t, terminalLine("pass", "done")) +// 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) - const secret = "sk-or-v1-not-for-argv" - for i := 0; i < 20; i++ { // map order once decided the outcome, so ask it more than once - // The second brief spells only placeholders this build knows, so the old - // refusal cannot hide the rewrite behind it. - brief := "https://github.com/o/r/pull/1 check {{ .Name }} escaping, and {{key}} in {{workspace}} under {{brief}}" - if i%2 == 1 { - brief = "https://github.com/o/r/pull/1 where does {{key}} go under {{workspace}}" - } - if _, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: brief, Workspace: t.TempDir(), Key: secret}}, nil); err != nil { - t.Fatalf("a brief spelling a placeholder refused the launch: %v", err) + 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 { - t.Fatalf("the brief reached the program as\n%q\nwant it verbatim", last) - } - if strings.Contains(string(got), secret) { - t.Fatal("the person's key was spliced into the command line") + 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) } } } -// The check a filled brief no longer trips still holds for the manifest's own -// text: a placeholder this build does not fill refuses the launch. -func TestFillRefusesAnUnknownPlaceholderInTheManifest(t *testing.T) { - if _, err := fill([]string{"--x", "{{typo}}"}, Fills{Brief: "b"}, true); err == nil || !strings.Contains(err.Error(), "{{typo}}") { - t.Fatalf("err = %v, want the unknown placeholder named", err) +// 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) { - m := fakeProgram(t, "exit 3") - result, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}}, nil) + 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) } @@ -139,7 +158,7 @@ func TestRunAnswersNoTerminalWhenTheProgramExitsWithoutOne(t *testing.T) { func TestRunTerminatesOnCancelAndKeepsATerminalWrittenInTheGrace(t *testing.T) { // The program traps TERM, writes its terminal and exits; the sleep is what // the signal interrupts. - m := fakeProgram(t, strings.Join([]string{ + script := fakeProgram(t, strings.Join([]string{ `trap '` + strings.ReplaceAll(terminalLine("budget-exhausted", "stopped by the parent"), "'", `'"'"'`) + `; exit 0' TERM`, `sleep 30 &`, `wait $!`, @@ -155,7 +174,9 @@ func TestRunTerminatesOnCancelAndKeepsATerminalWrittenInTheGrace(t *testing.T) { time.Sleep(50 * time.Millisecond) cancel() }() - result, err := Run(ctx, Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}, Grace: 5 * time.Second}, sink) + 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) } @@ -168,14 +189,16 @@ func TestRunTerminatesOnCancelAndKeepsATerminalWrittenInTheGrace(t *testing.T) { } func TestRunKillsAProgramThatIgnoresTerm(t *testing.T) { - m := fakeProgram(t, strings.Join([]string{ + script := fakeProgram(t, strings.Join([]string{ `trap '' TERM`, `sleep 30`, }, "\n")) ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() started := time.Now() - result, err := Run(ctx, Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}, Grace: 200 * time.Millisecond}, nil) + 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) } @@ -188,9 +211,19 @@ func TestRunKillsAProgramThatIgnoresTerm(t *testing.T) { } func TestRunRefusesAProgramThatIsNotThere(t *testing.T) { - m := Manifest{Name: "gone", Bin: filepath.Join(t.TempDir(), "gone"), Argv: []string{FillBrief}} - _, err := Run(context.Background(), Launch{Manifest: m, Fills: Fills{Brief: "b", Workspace: t.TempDir()}}, nil) + _, 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/load.go b/internal/delegate/load.go deleted file mode 100644 index b08f35784..000000000 --- a/internal/delegate/load.go +++ /dev/null @@ -1,244 +0,0 @@ -package delegate - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/Agent-Field/codeaf/internal/home" -) - -// dirName is the folder under the state root the manifests live in: one -// `.json` and one `.md` per delegate. -const dirName = "delegates" - -// Dir is where this machine's delegates are: `$CODEAF_HOME/delegates`, which -// is `~/.codeaf/delegates` for a person and a throwaway root under test. -func Dir() string { return filepath.Join(home.Dir(), dirName) } - -// Refusal is one manifest the loader would not admit, and why, in the sentence -// `/delegate` draws for it. A refusal is never an error to the caller: a -// registry with a bad file in it is still a registry, and the person is told -// which file and what is wrong rather than losing every delegate to one typo. -type Refusal struct { - // Name is the file's stem, which is what the person will look for. - Name string - Reason string -} - -func (r Refusal) String() string { return r.Name + ": " + r.Reason } - -// Absent is a manifest whose program is not on this machine. It is not a -// refusal — the file is fine — and it is not offered either: A CAPABILITY THAT -// CANNOT WORK IS ABSENT, NOT BROKEN. It is kept so `/delegate` can draw one dim -// line naming the binary it looked for. -type Absent struct { - Name string - Bin string -} - -func (a Absent) String() string { return a.Name + ": " + a.Bin + " is not on this machine" } - -// Registry is what one launch knows about the delegates installed here: the -// ones it can run, the ones whose program is missing, and the files it would -// not admit. It is read once at launch and never watched; a manifest added -// while codeaf runs is seen at the next launch, which the manual page says. -type Registry struct { - entries map[string]Manifest - absent []Absent - refusals []Refusal -} - -// Load reads every `.json` in dir. A missing directory is MADE, so the -// person who goes to install a delegate finds the folder waiting rather than -// reading its name off a page; it is then an empty registry and no error, since -// most machines have no delegates. An error is only a directory that exists and -// cannot be read: a folder that cannot be made is read as missing, because the -// registry is not worth failing a launch over. -// -// THE LAW IS CHECKED HERE, at the moment the command comes into existence: a -// manifest whose manual page is missing or does not spell `/` is refused -// with the same shape of sentence the compile-time gate prints for a built-in -// command. The static command table keeps its static test; this is that test -// moved to load time for rows that cannot be in the table. -func Load(dir string) (*Registry, error) { - registry := &Registry{entries: map[string]Manifest{}} - _ = os.MkdirAll(dir, 0o755) - entries, err := os.ReadDir(dir) - if errors.Is(err, os.ErrNotExist) { - return registry, nil - } - if err != nil { - return nil, err - } - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { - continue - } - stem := strings.TrimSuffix(entry.Name(), ".json") - manifest, err := readManifest(filepath.Join(dir, entry.Name())) - if err != nil { - registry.refusals = append(registry.refusals, Refusal{Name: stem, Reason: err.Error()}) - continue - } - if manifest.Name != stem { - registry.refusals = append(registry.refusals, Refusal{Name: stem, - Reason: fmt.Sprintf("the file is %s.json but the manifest says its name is %q; the two must agree", stem, manifest.Name)}) - continue - } - manifest.ManualPath = filepath.Join(dir, stem+".md") - page, reason := readManualPage(manifest) - if reason != "" { - registry.refusals = append(registry.refusals, Refusal{Name: stem, Reason: reason}) - continue - } - manifest.Manual = page - bin, err := resolveBin(manifest.Bin, dir) - if err != nil { - registry.absent = append(registry.absent, Absent{Name: stem, Bin: manifest.Bin}) - continue - } - manifest.BinPath = bin - registry.entries[manifest.Name] = manifest - } - sort.Slice(registry.absent, func(i, j int) bool { return registry.absent[i].Name < registry.absent[j].Name }) - sort.Slice(registry.refusals, func(i, j int) bool { return registry.refusals[i].Name < registry.refusals[j].Name }) - return registry, nil -} - -// readManifest parses and validates one file. -func readManifest(path string) (Manifest, error) { - data, err := os.ReadFile(path) - if err != nil { - return Manifest{}, err - } - var manifest Manifest - decoder := json.NewDecoder(strings.NewReader(string(data))) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&manifest); err != nil { - return Manifest{}, fmt.Errorf("the manifest does not parse: %v", err) - } - manifest.Path = path - if err := manifest.Validate(); err != nil { - return Manifest{}, err - } - return manifest, nil -} - -// readManualPage is the load-time manual law. The page must exist and must -// say the command, because the chat answers "what does / do" from it and -// nowhere else. It answers the page's text, or the refusal. -func readManualPage(m Manifest) (string, string) { - page, err := os.ReadFile(m.ManualPath) - if errors.Is(err, os.ErrNotExist) { - return "", fmt.Sprintf("no manual page beside it — write %s.md saying what /%s does — not added", m.Name, m.Name) - } - if err != nil { - return "", "its manual page could not be read: " + err.Error() - } - if !strings.Contains(string(page), "/"+m.Name) { - return "", fmt.Sprintf("its manual page does not say /%s — not added", m.Name) - } - return strings.TrimSpace(strings.ReplaceAll(string(page), "\r\n", "\n")), "" -} - -// resolveBin finds the program. A name with no separator is looked up on -// PATH; a relative path is taken from the manifest's own directory, so a -// delegate can ship its binary beside its manifest; an absolute path is -// itself. Whatever is found must be a regular executable file. -func resolveBin(bin, dir string) (string, error) { - if !strings.ContainsRune(bin, os.PathSeparator) { - return exec.LookPath(bin) - } - if !filepath.IsAbs(bin) { - bin = filepath.Join(dir, bin) - } - info, err := os.Stat(bin) - if err != nil { - return "", err - } - if info.IsDir() || info.Mode()&0o111 == 0 { - return "", fmt.Errorf("%s is not an executable file", bin) - } - return bin, nil -} - -// Find answers the manifest for a name, and false when this machine has none -// by that name (including one that is absent or refused). -func (r *Registry) Find(name string) (Manifest, bool) { - if r == nil { - return Manifest{}, false - } - m, ok := r.entries[name] - return m, ok -} - -// Names is every runnable delegate, sorted, which is the order rows are drawn -// in. -func (r *Registry) Names() []string { - if r == nil { - return nil - } - names := make([]string, 0, len(r.entries)) - for name := range r.entries { - names = append(names, name) - } - sort.Strings(names) - return names -} - -// All is every runnable manifest in Names order. -func (r *Registry) All() []Manifest { - names := r.Names() - all := make([]Manifest, 0, len(names)) - for _, name := range names { - all = append(all, r.entries[name]) - } - return all -} - -// Absent is the manifests whose program is not here, sorted by name. -func (r *Registry) Absent() []Absent { - if r == nil { - return nil - } - return append([]Absent(nil), r.absent...) -} - -// Refusals is the files the loader would not admit, sorted by name. -func (r *Registry) Refusals() []Refusal { - if r == nil { - return nil - } - return append([]Refusal(nil), r.refusals...) -} - -// PagePrefix is what a delegate's manual page is called in the chat's corpus: -// `delegate-`, so a delegate can never wear a packed page's name. -const PagePrefix = "delegate-" - -// Pages is every runnable delegate's manual page, keyed by its corpus name, -// for the chat's manual to layer over its own (internal/manual's overlay). -func (r *Registry) Pages() map[string]string { - if r == nil { - return nil - } - pages := make(map[string]string, len(r.entries)) - for name, m := range r.entries { - if m.Manual != "" { - pages[PagePrefix+name] = m.Manual - } - } - return pages -} - -// Empty is a registry with nothing runnable, nothing absent and nothing -// refused: the machine has no delegates at all, which is most machines. -func (r *Registry) Empty() bool { - return r == nil || (len(r.entries) == 0 && len(r.absent) == 0 && len(r.refusals) == 0) -} diff --git a/internal/delegate/load_test.go b/internal/delegate/load_test.go deleted file mode 100644 index d4789e0ee..000000000 --- a/internal/delegate/load_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package delegate - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// writeDelegate installs one delegate under dir: its manifest, its page, and a -// program that exists (an empty executable script) unless bin says otherwise. -func writeDelegate(t *testing.T, dir, name, manifest, page string) { - t.Helper() - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, name+".json"), []byte(manifest), 0o644); err != nil { - t.Fatal(err) - } - if page != "" { - if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(page), 0o644); err != nil { - t.Fatal(err) - } - } -} - -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) - } -} - -const goodManifest = `{ - "name": "fake", - "description": "a fake delegate for the tests", - "bin": "./fake.sh", - "argv": ["run", "--dir", "{{workspace}}", "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", "--", "{{brief}}"], - "env": {"FAKE_KEY": "{{key}}"}, - "lands": "tree" -}` - -func TestLoadAdmitsAManifestWithItsPageAndItsProgram(t *testing.T) { - dir := t.TempDir() - writeDelegate(t, dir, "fake", goodManifest, "# fake\n\n## /fake — what it does\n") - writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") - registry, err := Load(dir) - if err != nil { - t.Fatal(err) - } - m, ok := registry.Find("fake") - if !ok { - t.Fatalf("fake is not in the registry: refusals %v absent %v", registry.Refusals(), registry.Absent()) - } - if m.BinPath != filepath.Join(dir, "fake.sh") || !m.LandsTree() { - t.Fatalf("manifest = %+v", m) - } - if got := registry.Names(); len(got) != 1 || got[0] != "fake" { - t.Fatalf("names = %v", got) - } -} - -// A machine with no delegates gets an empty registry AND the folder, so the -// person who goes to add one finds it waiting. -func TestLoadIsEmptyWhenTheFolderDoesNotExistAndMakesIt(t *testing.T) { - dir := filepath.Join(t.TempDir(), "nowhere") - registry, err := Load(dir) - if err != nil { - t.Fatal(err) - } - if !registry.Empty() { - t.Fatalf("registry = %+v, want empty", registry) - } - if info, err := os.Stat(dir); err != nil || !info.IsDir() { - t.Fatalf("the folder was not made: %v", err) - } -} - -func TestLoadRefusesAManifestWithoutItsPageAndNamesTheCommand(t *testing.T) { - dir := t.TempDir() - writeDelegate(t, dir, "fake", goodManifest, "") - writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") - registry, err := Load(dir) - if err != nil { - t.Fatal(err) - } - if _, ok := registry.Find("fake"); ok { - t.Fatal("a delegate with no manual page was admitted") - } - refusals := registry.Refusals() - if len(refusals) != 1 || !strings.Contains(refusals[0].Reason, "fake.md") || !strings.HasSuffix(refusals[0].Reason, "not added") { - t.Fatalf("refusals = %v", refusals) - } -} - -func TestLoadRefusesAPageThatDoesNotSayTheCommand(t *testing.T) { - dir := t.TempDir() - writeDelegate(t, dir, "fake", goodManifest, "# fake\n\nIt does things.\n") - writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") - registry, _ := Load(dir) - refusals := registry.Refusals() - if len(refusals) != 1 || refusals[0].String() != "fake: its manual page does not say /fake — not added" { - t.Fatalf("refusals = %v", refusals) - } -} - -func TestLoadKeepsAnAbsentProgramApartFromARefusal(t *testing.T) { - dir := t.TempDir() - manifest := strings.Replace(goodManifest, `"./fake.sh"`, `"no-such-program-on-any-path"`, 1) - writeDelegate(t, dir, "fake", manifest, "## /fake\n") - registry, _ := Load(dir) - if len(registry.Refusals()) != 0 { - t.Fatalf("refusals = %v, want none: the file is fine", registry.Refusals()) - } - absent := registry.Absent() - if len(absent) != 1 || absent[0].String() != "fake: no-such-program-on-any-path is not on this machine" { - t.Fatalf("absent = %v", absent) - } - if _, ok := registry.Find("fake"); ok { - t.Fatal("an absent delegate was offered") - } -} - -func TestLoadRefusesTheThingsValidateRefuses(t *testing.T) { - cases := map[string]string{ - "a stray placeholder": strings.Replace(goodManifest, "{{brief}}", "{{prompt}}", 1), - "no brief": strings.Replace(goodManifest, `"--", "{{brief}}"`, `"--"`, 1), - "a bad name": strings.Replace(goodManifest, `"name": "fake"`, `"name": "Fake Thing"`, 1), - "an unknown lands": strings.Replace(goodManifest, `"lands": "tree"`, `"lands": "branch"`, 1), - "an unknown field": strings.Replace(goodManifest, `"lands": "tree"`, `"lands": "tree", "reader": "senior-dev"`, 1), - "not json": "{", - } - for name, manifest := range cases { - t.Run(name, func(t *testing.T) { - dir := t.TempDir() - writeDelegate(t, dir, "fake", manifest, "## /fake\n") - writeProgram(t, filepath.Join(dir, "fake.sh"), "exit 0\n") - registry, err := Load(dir) - if err != nil { - t.Fatal(err) - } - if len(registry.Refusals()) != 1 { - t.Fatalf("refusals = %v, want one", registry.Refusals()) - } - if _, ok := registry.Find("fake"); ok { - t.Fatal("admitted") - } - }) - } -} - -func TestLoadRefusesAFileWhoseNameDisagreesWithItsManifest(t *testing.T) { - dir := t.TempDir() - writeDelegate(t, dir, "other", goodManifest, "## /other\n") - registry, _ := Load(dir) - refusals := registry.Refusals() - if len(refusals) != 1 || !strings.Contains(refusals[0].Reason, `other.json but the manifest says its name is "fake"`) { - t.Fatalf("refusals = %v", refusals) - } -} diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go index fe10249d3..168e3d87f 100644 --- a/internal/delegate/protocol.go +++ b/internal/delegate/protocol.go @@ -1,11 +1,16 @@ package delegate -// The protocol: one JSON object per line on the program's stdout, four record -// types read, everything else ignored (docs/DELEGATE-PROTOCOL.md §2). Ignoring -// the rest is what makes the reader generic — senior-dev's bus payloads and any -// future 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 a 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. +// +// 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" @@ -18,12 +23,32 @@ import ( // The record types. const ( - RecordStage = "stage" + // 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" + // RecordSpend is v1's cumulative cost. It is still read until codeaf's + // model API meters every call itself, which makes it the one source of + // truth for money and this record redundant. RecordSpend = "spend" 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. +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 @@ -136,6 +161,8 @@ func KnownStatus(status string) bool { // 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. Stage(stage, status string) // Spend is the cumulative cost so far. The reader guarantees it never @@ -155,6 +182,7 @@ type Sink interface { // stage, the high-water spend, how many steps, whether a terminal arrived, and // how many lines were not the protocol's (dropped, not failed). type Reading struct { + Hello *Hello LastStage string LastStatus string SpendUSD float64 @@ -183,6 +211,22 @@ func Read(r io.Reader, sink Sink) (Reading, error) { 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: var rec struct { Stage string `json:"stage"` diff --git a/internal/delegate/protocol_test.go b/internal/delegate/protocol_test.go index a0388c552..f2a9da621 100644 --- a/internal/delegate/protocol_test.go +++ b/internal/delegate/protocol_test.go @@ -15,6 +15,7 @@ type recorder struct { mu sync.Mutex once sync.Once spoke chan struct{} + hello *Hello stages []string spend []float64 steps []string @@ -23,6 +24,12 @@ type recorder struct { 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, status string) { r.mu.Lock() defer r.mu.Unlock() @@ -153,3 +160,27 @@ func TestObservedReadsSeniorDevsVerificationCount(t *testing.T) { 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) + } +} diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index ebf5d6d05..4d22978d9 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -178,9 +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 | -| `/delegate` | `/delegates` | — | lists the outside programs a task can be handed to whole, and what each leaves behind | -| `/delegate` | `/delegates` | ` ` | hands that brief to the named delegate; `/ ` is the same door | -| `/` | — | `` | one row per installed delegate, spelled as its manifest names it: starts a task that program does on its own | +| `/` | — | `` | one row per program this build carries: starts a task that program does on its own | | `/memory` | — | — | opens the memory panel | | `/memory` | `/memories` | `` | prints matching memories into the conversation | | `/memories` | — | — | prints every memory into the conversation | @@ -1263,27 +1261,16 @@ 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. -## /delegate — the outside programs a task can be handed to, and the command each one adds +## / — a program codeaf carries, handed a whole task -`/delegate` (or `/delegates`) lists the delegates on this machine, one line each: the -command to type, what it does, whether it lands its work on your branch or answers in the -conversation, and the program it resolved to. Under those, dimly, any manifest whose program -is not here and any that was not added, with the reason. - -Every installed delegate is also a command of its own: `/ ` hands the brief +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. `/delegate ` is the same door written long. The rows come from the -manifests under `~/.codeaf/delegates/` and exist only where the program does; a machine -without the program has no row for it. - -With nothing installed it says, exactly: - -``` -no delegates here — a delegate is an outside program codeaf can hand a whole task to; a manifest under ~/.codeaf/delegates adds one -``` +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` it lists the far machine's delegates, and a row you run starts the work there. -The *Delegates* page says what one is, what it cannot do, and where its work goes. +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 diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 30997d716..4cd239a1b 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -1,108 +1,88 @@ -# Delegates +# Programs codeaf carries -## What a delegate is — programs codeaf can hand a task to, outside agents, another coding agent +## What a program codeaf carries is — a delegate, another coding agent, an agent of its own for a whole task -A **delegate** is an outside program on this machine that can do a whole coding task on -its own. codeaf hands it a task the way it hands one to its own worker: in a working copy -of your folder, under this conversation's dollar and time limits, shown on the rail while -it runs, stoppable, and landed on your branch when it ends. codeaf designs nothing about -the program and cannot see inside it; it starts it, reads what it says, stops it when a -limit is reached, and takes its result. +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 a copy of your folder, under this +conversation's dollar and time limits, shows on the rail while it runs, can be stopped, +and lands on your branch when it ends. -A delegate is not a harness and not a subharness. Those are programs built out of -codeaf's own parts and run inside this process; a delegate is somebody else's binary -running as a child process. `/harness` and `/subharness` list the first kind; `/delegate` -lists the second. +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`. -Each delegate is one manifest and one page under `~/.codeaf/delegates/`, a folder codeaf -makes at launch when it is not there: `.json` says how to run the program, -`.md` says what it does. A delegate is found at launch, so one added while codeaf -is running appears the next time codeaf starts. +**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 shown as one turn of a conversation on the +run's task page. -## How do I hand work to a delegate — / , /delegate, via, "delegate this to another agent", the command for a delegate +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. -Type the delegate's name as a command and the brief after it: +## 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 ``` -where `` is the word its manifest gives it — the command is generated from the -manifest, so it is spelled exactly as the manifest's `name`. - -That is `/task` with the worker chosen. A run starts at once in a copy of your folder, -the turn goes on, and the row appears on the rail with the program's current phase as its -live step. `/delegate ` is the same door in long form. - -The model can choose a delegate too: `propose_task` takes `via` naming one, and the card -you answer says which program the work is going to. It is told the names this machine has -and nothing else, so it cannot propose a delegate that is not here. - -`/delegate` on its own lists every delegate on this machine, one line each: the command, -what it does, whether it lands its work on your branch or answers in the conversation, and -the program it resolved to. Under those, dimly, the manifests whose program is not on this -machine, and any manifest that was not added and why. - -## What a delegate cannot do — why it did not ask me, no questions, no step cap, why a delegate was refused - -**A delegate cannot ask you anything.** There is nobody at its keyboard: it runs -unattended, and a question it tried to ask is turned down inside the program. 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. - -**A delegate has no step cap.** It is held to this conversation's dollar and time limits, -which are handed to it on its command line and enforced by codeaf from outside as well. The -step count on its task page is what the program reported, not a limit. - -**A delegate runs alone.** While a delegated run is going, no other task can join its copy, -and no delegate can be added under another run. Both are refused with the folder that is -busy: `work is already underway in a copy of ; a delegate runs alone, so propose it -again when that work has ended`. - -**A delegated run has no review round.** codeaf's checker does not read the program's work -afterwards; what the program itself checked is reported in its result, kept apart from -what its model claimed. - -A name that is no delegate here is refused with the ones that are: -`no delegate is called ; the delegates here are …`. On a machine with none: -`no delegate is called : this machine has no delegates (a manifest under -~/.codeaf/delegates adds one)`. - -## Where a delegate's work goes — squashed into one commit, landed on my branch, the wip commits, what it costs - -A delegate that lands a **tree** works in a copy cut from your folder. When it ends, every -commit it made in that copy is squashed into **one commit** whose subject is the task's -title and whose body is the program's own account of the ending, and that commit is merged -into your folder the way every task's work comes home. A program that commits after every -edit leaves no trail of bookkeeping commits on your branch. Nothing to -land is said as `nothing to land: the run's working copy holds no change`. - -A delegate that lands **text** 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, folded in -as the program reports it. The spending page shows it under the delegate's name rather -than a model's, because the program's own calls did not go through codeaf. - -## Why is there no command for my delegate — the delegate is missing, not on this machine, adding a delegate, the manifest was not added - -A delegate's row exists only where its program does. `/delegate` says which of the -manifests under `~/.codeaf/delegates/` could not be added and why: - -- `: is not on this machine` — the manifest is fine and the program is - not on PATH. Install it and start codeaf again. -- `: no manual page beside it — write .md saying what / does — not - added` — every delegate ships the page the chat answers from. -- `: its manual page does not say / — not added` — the page exists and - never names the command. -- `: its name is already a command here — not added` — the name collides with a - built-in command or alias. - -Over `--host`, the delegates are the far machine's: `/delegate` lists what is installed -there, the rows are that machine's, and a delegate you start runs there, in a copy of that -machine's folder. A delegate installed only on this laptop is not offered in a hosted -conversation. - -The contract a program has to meet to be a delegate is one page, `docs/DELEGATE-PROTOCOL.md` -in the codeaf repository: four kinds of line on its stdout, one terminal record, a clean stop -on SIGTERM, and its work left in the tree it was given. +That is `/task` with the worker chosen. A run starts at once in a copy of your folder, the +turn goes on, and the row appears on the rail. + +The model can choose one as well. `propose_task` takes `via` naming the program, and the +card you answer says which program the work is going to. The model is told only the names +your build carries. + +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. + +## What it cannot do — why it did not ask me, no questions, no step cap, why it was refused + +**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: a model call that +would cross the dollar ceiling is refused before it is made. The step count on its task +page is what the program reported, not a limit. + +**It runs alone.** While one is running, no other task can join its copy, and it cannot be +started under another run. Both are refused with the folder that is busy: +`work is already underway in a copy of ; runs alone, so propose it again +when that work has ended`. + +**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. + +A name your build does not carry is refused with the ones it does: +`this codeaf carries no program called ; it carries …`. + +## Where its work goes — squashed into one commit, landed on my branch, the wip commits, what it costs + +A program that edits code works in a copy cut from your folder. When it ends, every commit +it made in that copy is squashed into **one commit**. The commit's subject is the task's +title, and its body is the program's own account of the ending. That commit is merged into +your folder the way every task's work comes home, so a program that commits after every +edit leaves no trail of bookkeeping commits on your branch. When there is nothing to land, +it says `nothing to land: the run's working copy holds no change`. + +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. + +## 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 a copy of that machine's folder. diff --git a/internal/manual/overlay.go b/internal/manual/overlay.go deleted file mode 100644 index 1b705c9c7..000000000 --- a/internal/manual/overlay.go +++ /dev/null @@ -1,90 +0,0 @@ -package manual - -// AN OVERLAY IS THE PACKED CORPUS PLUS PAGES THAT EXIST ONLY ON THIS MACHINE. -// The chat's manual is compiled into the binary and never changes at run time, -// which is the right property for every page about codeaf itself. A delegate -// (internal/delegate) is not codeaf: it is an outside program installed by the -// person, with a command row that exists only where it does, and the page that -// explains it ships beside its manifest. So the corpus the chat answers from is -// the packed one with those pages layered over it — searched, listed and read -// with the packed pages and by the same law — and the packed corpus itself is -// untouched. - -import ( - "errors" - "os" - "path" - "sort" - "strings" -) - -// layeredFiles is a corpusFiles over another, with pages of its own that are -// listed and read as though they sat in the same folder. A page whose name is -// already in the base is the base's: the overlay adds and never replaces, so -// nothing installed on a machine can rewrite what the binary says about itself. -type layeredFiles struct { - base corpusFiles - dir string - extra map[string]string -} - -func (f layeredFiles) Glob(pattern string) ([]string, error) { - entries, err := f.base.Glob(pattern) - if err != nil { - return nil, err - } - seen := map[string]bool{} - for _, entry := range entries { - seen[entry] = true - } - names := make([]string, 0, len(f.extra)) - for name := range f.extra { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - entry := path.Join(f.dir, name+".md") - if !seen[entry] { - entries = append(entries, entry) - } - } - return entries, nil -} - -func (f layeredFiles) ReadFile(name string) ([]byte, error) { - if data, err := f.base.ReadFile(name); err == nil { - return data, nil - } - if path.Dir(name) == f.dir { - if text, ok := f.extra[strings.TrimSuffix(path.Base(name), ".md")]; ok { - return []byte(text), nil - } - } - return nil, errors.Join(os.ErrNotExist, errors.New("manual: no page "+name)) -} - -// WithPages is this corpus with extra pages layered over it, keyed by page -// name (no folder, no `.md`). It is a new corpus, built lazily on its first -// question like any other, and the receiver is not changed. Extra pages follow -// every rule the packed ones do — a `# ` title, `## ` headings as the search -// index — because they are indexed by the same code. A name the packed corpus -// already has is left to the packed page. -// -// No pages is the receiver itself, so a caller may ask unconditionally. -func (c *Corpus) WithPages(extra map[string]string) *Corpus { - if len(extra) == 0 { - return c - } - pages := make(map[string]string, len(extra)) - for name, text := range extra { - name = strings.TrimSpace(name) - if name == "" || strings.TrimSpace(text) == "" { - continue - } - pages[name] = text - } - if len(pages) == 0 { - return c - } - return newCorpus(layeredFiles{base: c.files, dir: path.Dir(c.glob), extra: pages}, c.glob) -} diff --git a/internal/manual/overlay_test.go b/internal/manual/overlay_test.go deleted file mode 100644 index a55c7c905..000000000 --- a/internal/manual/overlay_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package manual - -import ( - "strings" - "testing" -) - -// A page layered over the chat corpus is listed, read and searched beside the -// packed pages, and the packed corpus is not changed by it. -func TestAnOverlayPageIsSearchedReadAndListedBesideThePackedOnes(t *testing.T) { - page := "# senior-dev\n\n## What /senior-dev does — hand a large change to senior-dev\n\nsenior-dev is an autonomous coding agent. Type `/senior-dev `.\n\n## What senior-dev cannot do\n\nIt cannot ask you anything.\n" - layered := Chat().WithPages(map[string]string{"delegate-senior-dev": page}) - if layered == Chat() { - t.Fatal("WithPages with a page answered the same corpus") - } - text, ok := layered.Page("delegate-senior-dev") - if !ok || !strings.Contains(text, "autonomous coding agent") { - t.Fatalf("the overlay page cannot be read: %v %q", ok, text) - } - if !layered.Mentions("/senior-dev") { - t.Fatal("the layered corpus does not mention the delegate's command") - } - found := false - for _, name := range layered.Pages() { - found = found || name == "delegate-senior-dev" - } - if !found { - t.Fatalf("the overlay page is not listed: %v", layered.Pages()) - } - hits := layered.Search("what does /senior-dev do", 4) - if len(hits) == 0 || hits[0].Page != "delegate-senior-dev" { - t.Fatalf("the question did not reach the overlay page first: %+v", hits) - } - // And a packed page is still there, unchanged. - if _, ok := layered.Page("delegates"); !ok { - t.Fatal("the packed delegates page is gone from the layered corpus") - } - if _, ok := Chat().Page("delegate-senior-dev"); ok { - t.Fatal("the packed corpus learnt the overlay page") - } -} - -func TestAnOverlayNeverReplacesAPackedPageAndNoPagesIsTheSameCorpus(t *testing.T) { - if Chat().WithPages(nil) != Chat() { - t.Fatal("no pages answered a new corpus") - } - if Chat().WithPages(map[string]string{"empty": " "}) != Chat() { - t.Fatal("an empty page answered a new corpus") - } - layered := Chat().WithPages(map[string]string{"delegates": "# an impostor\n\n## nothing\n\nnothing\n"}) - text, _ := layered.Page("delegates") - if strings.Contains(text, "impostor") { - t.Fatal("an overlay replaced a packed page") - } -} diff --git a/internal/remote/client.go b/internal/remote/client.go index 5ff2157ac..59a8a3c94 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1559,11 +1559,10 @@ func (a *Agent) StartTask(ctx context.Context, brief string, solo bool) (uint64, return started.ID, started.Title, started.Note, nil } -// Delegates is the engine machine's delegate registry as the surface lists it: -// the rows that can run there, the manifests whose program is not there, and -// the files its loader would not admit (internal/session's delegate_door.go). -// A failed read is the zero report, which the surface draws as one sentence, -// because a list is a reading and never worth a refusal at the door. +// 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 { @@ -1576,7 +1575,7 @@ func (a *Agent) Delegates() session.DelegateReport { return report } -// StartDelegate hands the brief to the named delegate on the engine machine +// 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) { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 9d422a546..d2141755d 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -351,7 +351,7 @@ import ( // 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 delegate the engine cannot start is a command +// first frame, and a row for a program the engine cannot start is a command // that lies. const Version = 18 diff --git a/internal/remote/wire_task.go b/internal/remote/wire_task.go index 18be99613..38c9a21e0 100644 --- a/internal/remote/wire_task.go +++ b/internal/remote/wire_task.go @@ -6,12 +6,12 @@ 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 delegate door - // (internal/session's delegate_door.go): the outside programs installed on - // the ENGINE machine, and handing a brief to one. They belong to the engine - // side for the reason the task door does — the registry is that machine's - // disk and the run spends that machine's money — so a hosted surface lists - // the far machine's delegates and its `/ ` starts work there. + // 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" @@ -165,8 +165,8 @@ type TaskStartArgs struct { Solo bool `json:"solo,omitempty"` } -// DelegateStartArgs carries the delegate's name and the person's brief, both -// as typed: the name is resolved against the engine machine's registry there. +// 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"` diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 2d407dec9..41e3b5d8b 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -1,23 +1,25 @@ package run -// A DELEGATE IS ONE MORE WORKER KIND. An outside program that does a whole -// task on its own (docs/DELEGATE-PROTOCOL.md, docs/design/delegate/DESIGN.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. +// 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 is started -// in the run's working copy (internal/delegate.Run), its stdout is the -// protocol, and its terminal record is the ending. Its stages feed the live -// step only; its `step` records are 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. +// 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 working copy, its stdout is the records, and its terminal record is +// the ending. Its stages feed the live step only; its `step` records are 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. import ( "context" "errors" "fmt" + "os" "path/filepath" "strings" "time" @@ -31,36 +33,43 @@ import ( // says why it could not start and a person opening the task should find it. const delegateStderrName = "delegate-stderr.log" -// DelegateWorker runs one delegate as the worker of one task. +// DelegateWorker runs one program as the worker of one task. type DelegateWorker struct { store *plandb.Store workspace string - manifest delegate.Manifest - key 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. cost float64 elapsed time.Duration - // grace overrides the launch's SIGTERM grace, for a test. - grace time.Duration } -// NewDelegateWorker builds the worker. key is the person's API key as the door -// resolved it; cost and elapsed are the run's ceilings, zero for none. -func NewDelegateWorker(store *plandb.Store, workspace string, m delegate.Manifest, key string, cost float64, elapsed time.Duration) *DelegateWorker { - return &DelegateWorker{store: store, workspace: workspace, manifest: m, key: key, cost: cost, elapsed: elapsed} +// DelegateSetup is how a delegated run starts its program's process. +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 +} + +// 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 delegate's, and every other task the run seats — the review round's +// 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, m delegate.Manifest, key string, limits Limits, rest WorkerFactory) WorkerFactory { +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, m, key, limits.CostUSD, limits.Elapsed) + return NewDelegateWorker(store, workspace, program, setup, limits.CostUSD, limits.Elapsed) } if rest == nil { return nil @@ -83,6 +92,25 @@ type delegateSink struct { usd float64 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 +} + +func (s *delegateSink) Hello(h delegate.Hello) { + if h.Protocol == delegate.ProtocolVersion { + 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(stage, status string) { @@ -125,22 +153,30 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err if err := appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryBeginKind, ExitsRecorded: true}); err != nil { return Report{}, fmt.Errorf("stamp the trajectory opening line: %w", err) } - sink := &delegateSink{worker: w, ctx: ctx, taskID: task.ID, storeDir: storeDir, name: w.manifest.Name} + launchCtx, stop := context.WithCancel(ctx) + defer stop() + sink := &delegateSink{worker: w, ctx: ctx, taskID: task.ID, storeDir: storeDir, name: w.program.Name, stop: stop} brief := strings.TrimSpace(task.Description) if brief == "" { brief = strings.TrimSpace(task.Title) } - result, err := delegate.Run(ctx, delegate.Launch{ - Manifest: w.manifest, - Fills: delegate.Fills{ - Brief: brief, - Workspace: w.workspace, - CostUSD: w.cost, - Hours: w.elapsed.Hours(), - Key: w.key, - }, + exe := w.setup.Exe + if exe == "" { + self, err := os.Executable() + if err != nil { + return Report{}, fmt.Errorf("find codeaf's own executable to run %s: %w", w.program.Name, err) + } + exe = self + } + 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()}), + // NO KEY REACHES THE PROGRAM (delegate.ChildEnv). + Env: delegate.ChildEnv(delegate.ModelAPI{}), + Dir: w.workspace, StderrPath: filepath.Join(plandb.TaskDir(storeDir, task.ID), delegateStderrName), - Grace: w.grace, + Grace: w.setup.Grace, }, sink) // 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 @@ -161,7 +197,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err if err != nil { role = plandb.RoleWork } - _ = w.store.AddSpend(task.ID, "delegate/"+w.manifest.Name, role, usd, 0, 0) + _ = w.store.AddSpend(task.ID, "delegate/"+w.program.Name, role, usd, 0, 0) } report := Report{Steps: sink.steps, USD: usd} @@ -172,19 +208,23 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err end("the record failed: "+sink.lastErr.Error(), "") return report, sink.lastErr } + if sink.mismatch != "" && ctx.Err() == nil { + end(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.manifest.Name + " said " + t.Message + reason += ": " + w.program.Name + " said " + t.Message } end(reason, "") return report, err } if errors.Is(err, delegate.ErrNoTerminal) { - reason := fmt.Sprintf("%s exited %d without a terminal record", w.manifest.Name, result.ExitCode) + 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 } @@ -196,33 +236,33 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err return report, err } t := *result.Reading.Terminal - report.Result = delegateResult(w.manifest, t) + report.Result = delegateResult(w.program, t) switch t.Status { case delegate.StatusPass: end("finished: "+t.Message, report.Result) return report, nil case delegate.StatusBudget: - reason := w.manifest.Name + " stopped on its own ceiling: " + t.Message + reason := w.program.Name + " stopped on its own ceiling: " + t.Message end(reason, report.Result) return report, errors.New(reason) case delegate.StatusCrashed: - reason := w.manifest.Name + " crashed: " + t.Message + reason := w.program.Name + " crashed: " + t.Message end(reason, report.Result) return report, errors.New(reason) default: // `fail`, and any word this build does not know, is work that does not // stand: the run reads it as incomplete. - reason := w.manifest.Name + " did not finish: " + t.Message + reason := w.program.Name + " did not finish: " + t.Message end(reason, report.Result) return report, errors.New(reason) } } -// delegateResult is the ending in words: the deliverable for a delegate that +// 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.Manifest, t delegate.Terminal) string { +func delegateResult(m delegate.Delegate, t delegate.Terminal) string { if !m.LandsTree() { if deliverable := t.Deliverable(); deliverable != "" { return deliverable diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go index 7037f5e5e..f0abd9ecb 100644 --- a/internal/run/delegateworker_test.go +++ b/internal/run/delegateworker_test.go @@ -16,9 +16,10 @@ import ( "github.com/Agent-Field/codeaf/internal/run" ) -// fakeDelegate writes a shell program that speaks the protocol — a stage, a -// spend, two steps, then body — and answers its manifest. -func fakeDelegate(t *testing.T, body string) delegate.Manifest { +// fakeDelegate writes a shell program that stands in for codeaf running a +// program — a stage, a spend, 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{ @@ -33,13 +34,7 @@ func fakeDelegate(t *testing.T, body string) delegate.Manifest { if err := os.WriteFile(script, []byte(program), 0o755); err != nil { t.Fatal(err) } - return delegate.Manifest{ - Name: "fake", - Description: "a fake delegate", - Bin: script, - BinPath: script, - Argv: []string{"run", "--dir", delegate.FillWorkspace, "--max-cost", delegate.FillCostUSD, "--", delegate.FillBrief}, - } + return delegate.Delegate{Name: "fake", Summary: "a fake program", Default: "run"}, run.DelegateSetup{Exe: script} } func passLine(claim string) string { @@ -52,8 +47,8 @@ func TestDelegateWorkerRecordsStepsBanksSpendAndReportsTheEnding(t *testing.T) { args := filepath.Join(t.TempDir(), "args") t.Setenv("FAKE_ARGS", args) workspace := t.TempDir() - m := fakeDelegate(t, passLine("tests are green")) - worker := run.NewDelegateWorker(store, workspace, m, "sk-test", 2.5, 0) + 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) }) @@ -78,7 +73,7 @@ func TestDelegateWorkerRecordsStepsBanksSpendAndReportsTheEnding(t *testing.T) { // The brief the program was handed is the task's description, and the // ceiling is the run's. got, _ := os.ReadFile(args) - if want := "run\n--dir\n" + workspace + "\n--max-cost\n2.5\n--\ndrive the plan to the ground\n"; string(got) != want { + 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. @@ -113,8 +108,8 @@ func TestDelegateWorkerRecordsStepsBanksSpendAndReportsTheEnding(t *testing.T) { func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { store := runOpenStore(t) - m := fakeDelegate(t, `echo '{"type":"terminal","status":"fail","message":"unsubmitted","data":{"cost_usd":0.2,"status":"unsubmitted"}}'`) - worker := run.NewDelegateWorker(store, t.TempDir(), m, "", 0, 0) + 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) @@ -126,8 +121,8 @@ func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { func TestDelegateWorkerNamesAnExitWithoutATerminal(t *testing.T) { store := runOpenStore(t) - m := fakeDelegate(t, "exit 7") - worker := run.NewDelegateWorker(store, t.TempDir(), m, "", 0, 0) + 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) @@ -136,12 +131,12 @@ func TestDelegateWorkerNamesAnExitWithoutATerminal(t *testing.T) { func TestDelegateWorkerComesHomeWithTheContextsEndingWhenTheRunStopsIt(t *testing.T) { store := runOpenStore(t) - m := fakeDelegate(t, strings.Join([]string{ + 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, "", 0, 0) + 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 @@ -174,8 +169,8 @@ func TestDelegateWorkerComesHomeWithTheContextsEndingWhenTheRunStopsIt(t *testin // supervisor to done, with the delegate's words as the run's result. func TestARunSeatsTheDelegateOnItsRootAndEndsDone(t *testing.T) { store := runOpenStore(t) - m := fakeDelegate(t, passLine("all green")) - factory := run.DelegateFactory(store, t.TempDir(), m, "", run.Limits{CostUSD: 5}, nil) + m, setup := fakeDelegate(t, passLine("all green")) + 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(), @@ -204,12 +199,12 @@ func TestARunSeatsTheDelegateOnItsRootAndEndsDone(t *testing.T) { // kept. func TestARunEndsADelegateThatCrossesTheCostCeiling(t *testing.T) { store := runOpenStore(t) - m := fakeDelegate(t, strings.Join([]string{ + m, setup := fakeDelegate(t, strings.Join([]string{ `trap 'echo "{\"type\":\"terminal\",\"status\":\"budget-exhausted\",\"message\":\"stopped\",\"data\":{\"cost_usd\":0.11}}"; exit 0' TERM`, `sleep 30 &`, `wait $!`, }, "\n")) - factory := run.DelegateFactory(store, t.TempDir(), m, "", run.Limits{CostUSD: 0.10}, nil) + 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}, @@ -222,3 +217,28 @@ func TestARunEndsADelegateThatCrossesTheCostCeiling(t *testing.T) { t.Fatalf("cut = %v, want the root cut by the run's own ending", summary.Cut) } } + +// 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") + } +} diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index eaa185fb8..5de5ab661 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -44,7 +44,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar // have left off, and the program's own verification is what its // terminal record reports ([DelegateWorker]). limits.ReviewRound = false - factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, spec.APIKey, limits, factory) + factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, DelegateSetup{}, limits, factory) } outcome, summary := Start(ctx, Spec{ Store: spec.Store, diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 083de6c24..f0d020a88 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -1,19 +1,23 @@ package session -// THE DELEGATE DOOR: how a conversation hands a task to an outside program -// (docs/design/delegate/DESIGN.md, docs/DELEGATE-PROTOCOL.md). A delegate is -// one more worker kind behind the run engine, and this file is the half a -// conversation needs of it — which delegates this launch has, the door +// THE DELEGATE DOOR: how a conversation hands a task to a program codeaf +// carries — senior-dev first (internal/delegate, docs/design/delegate/). A +// program is one more worker kind behind the run engine, and this file is the +// half a conversation needs of it: which programs this build carries, the door // `/ ` and `propose_task`'s `via` both open, and the landing of a // run whose worker was a program rather than a bash worker. // +// "DELEGATE" IS A WORKING TITLE. Every sentence here a person or the model can +// read names the program itself, so a later rename of the idea changes code +// and never a promise already made on a screen. +// // IT RIDES THE RUN ROAD WHATEVER THE BELT SAYS. `/task` takes the run road only // under CODEAF_TASK_BELT=bash, because that road's WORKER is the bash belt. A -// delegate's worker is the program, so the road is asked for outright here: the +// program's worker is the program, so the road is asked for outright here: the // store, the copy, the supervisor and the landing are the run's, and nothing in // them reads the belt switch. What a delegated run does not have is the review // round, because a check seat is a bash-belt worker and the belt may be off; the -// program's own verification is what the terminal record reports. +// program's own verification is what its terminal record reports. import ( "context" @@ -23,104 +27,78 @@ import ( "strings" "github.com/Agent-Field/codeaf/internal/delegate" - "github.com/Agent-Field/codeaf/internal/manual" ) -// DelegateRow is one delegate as a surface lists it: the command word, the +// DelegateRow is one program as a surface lists it: the command word, the // sentence under it, and what it leaves behind. type DelegateRow struct { Name string Description string // Lands is delegate.LandsTree or delegate.LandsText. Lands string - // Bin is the program as it resolved on this machine. - Bin string } -// DelegateReport is everything `/delegate` says: the delegates that can run, -// the ones whose program is not here (one dim line each), and the files the -// loader would not admit (one line each, with the reason). +// DelegateReport is the programs this conversation can hand work to, as the +// surface draws its command rows from them. type DelegateReport struct { - Rows []DelegateRow - Absent []string - Refused []string + Rows []DelegateRow } -// Delegates is the report for this conversation. A build with no registry -// answers the zero report, which a surface draws as one sentence. +// Delegates is the report for this conversation. A build that carries none +// answers the zero report, and the surface draws no rows. func (a *Agent) Delegates() DelegateReport { return a.config.delegateReport() } func (c Config) delegateReport() DelegateReport { var report DelegateReport - if c.Delegates == nil { - return report - } - for _, m := range c.Delegates.All() { - lands := m.Lands + for _, program := range c.Delegates { + lands := program.Lands if lands == "" { lands = delegate.LandsTree } - report.Rows = append(report.Rows, DelegateRow{Name: m.Name, Description: m.Description, Lands: lands, Bin: m.BinPath}) - } - for _, absent := range c.Delegates.Absent() { - report.Absent = append(report.Absent, absent.String()) - } - for _, refusal := range c.Delegates.Refusals() { - report.Refused = append(report.Refused, refusal.String()) + report.Rows = append(report.Rows, DelegateRow{Name: program.Name, Description: program.Summary, Lands: lands}) } return report } -// delegateNames is the runnable names, sorted, for the prompt and the refusal. +// delegateNames is the programs' names, sorted, for the prompt and the refusal. func (c Config) delegateNames() []string { - if c.Delegates == nil { - return nil + names := make([]string, 0, len(c.Delegates)) + for _, program := range c.Delegates { + names = append(names, program.Name) } - return c.Delegates.Names() + sort.Strings(names) + return names } -// mayDelegate says whether this belt may hand work to a delegate: it is the +// mayDelegate says whether this belt may hand work to a program: it is the // conversation's own hand-off predicate with one more condition, that this -// launch has at least one delegate that can run. A task node never delegates, -// for the reason it never proposes: there is nowhere for the work to go from -// there. +// build carries at least one. A task node never delegates, for the reason it +// never proposes: there is nowhere for the work to go from there. func (c Config) mayDelegate() bool { - return c.mayProposeTask() && !c.InTask && len(c.delegateNames()) > 0 + return c.mayProposeTask() && !c.InTask && len(c.Delegates) > 0 } -// delegateFact is the hand-off page's one paragraph about delegates. It is +// delegateFact is the hand-off page's one paragraph about these programs. It is // rendered only where [Config.mayDelegate] holds, and its `fill` writes the -// installed names in, so the model is told the words it can put in `via` and -// never a name this machine does not have. +// names in, so the model is told the words it can put in `via` and never a name +// this build does not carry. var delegateFact = beltFact{ tools: []string{"propose_task"}, holds: Config.mayDelegate, present: "AND WORK BIG ENOUGH TO WANT ITS OWN AGENT FOR AN HOUR — one large change, specified\n" + - "well enough that nobody will be asked anything — can go to a DELEGATE: an outside\n" + - "program on this machine that does the whole task on its own, in a copy of the folder,\n" + - "under the same dollar and time limits, landed when it ends. Name it in `propose_task`'s\n" + - "`via`. The delegates here are: %s. A delegate cannot ask the person anything, so its\n" + - "brief has to settle everything; a change you would do in a few steps is never worth one.", + "well enough that nobody will be asked anything — can go to a PROGRAM BUILT INTO CODEAF\n" + + "that does the whole task on its own, in a copy of the folder, under the same dollar and\n" + + "time limits, landed when it ends. Name it in `propose_task`'s `via`. The programs here\n" + + "are: %s. It cannot ask the person anything, so its brief has to settle everything;\n" + + "a change you would do in a few steps is never worth one.", fill: func(config Config, text string) string { return fmt.Sprintf(text, strings.Join(config.delegateNames(), ", ")) }, } -// chatManual is the manual this conversation answers from: the packed corpus, -// with every installed delegate's own page layered over it under -// `delegate-` (internal/manual's overlay). It is what makes "what does -// /senior-dev do" answerable from senior-dev's page and nowhere else, and it is built -// once per agent because the registry is read once per launch. -func (a *Agent) chatManual() *manual.Corpus { - a.manualOnce.Do(func() { - a.manualCorpus = manual.Chat().WithPages(a.config.Delegates.Pages()) - }) - return a.manualCorpus -} - -// DelegateUnknownError is the refusal for a `via` naming no delegate this -// machine can run. It names the ones it can, sorted, so the next attempt has -// the words in front of it. +// DelegateUnknownError is the refusal for a `via` or a command naming no +// program this build carries. It names the ones it does, sorted, so the next +// attempt has the words in front of it. type DelegateUnknownError struct { Named string Have []string @@ -128,64 +106,64 @@ type DelegateUnknownError struct { func (e DelegateUnknownError) Error() string { if len(e.Have) == 0 { - return "no delegate is called " + e.Named + ": this machine has no delegates (a manifest under ~/.codeaf/delegates adds one)" + return "this codeaf carries no program called " + e.Named } have := append([]string(nil), e.Have...) sort.Strings(have) - return "no delegate is called " + e.Named + "; the delegates here are " + strings.Join(have, ", ") + return "this codeaf carries no program called " + e.Named + "; it carries " + strings.Join(have, ", ") } -// delegateFor resolves a `via` word to its manifest, or the refusal. -func (a *Agent) delegateFor(name string) (delegate.Manifest, error) { +// delegateFor resolves a name to the program, or the refusal. +func (a *Agent) delegateFor(name string) (delegate.Delegate, error) { name = strings.TrimSpace(name) if name == "" { - return delegate.Manifest{}, errors.New("a delegate needs a name") + return delegate.Delegate{}, errors.New("name the program to hand the work to") } - if a.config.Delegates != nil { - if m, ok := a.config.Delegates.Find(name); ok { - return m, nil + for _, program := range a.config.Delegates { + if program.Name == name { + return program, nil } } - return delegate.Manifest{}, DelegateUnknownError{Named: name, Have: a.config.delegateNames()} + return delegate.Delegate{}, DelegateUnknownError{Named: name, Have: a.config.delegateNames()} } -// StartDelegate hands one person-authored brief to the named delegate. It is +// StartDelegate hands one person-authored brief to the named program. It is // `/ `'s door and it answers what StartTask answers: the id the // row wears, the title, a note about where the work stands (always empty here) // and the error. Nothing is waited for: the run starts and the turn goes on. // -// The refusals a person can meet, in their own words: a name this machine has -// no delegate for, an empty brief, and a build whose run road is not linked. +// The refusals a person can meet, in their own words: a name this build +// carries no program for, an empty brief, and a build whose run road is not +// linked. func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, string, string, error) { brief = strings.TrimSpace(brief) if brief == "" { - return 0, "", "", errors.New("a delegate needs a brief") + return 0, "", "", errors.New("/" + strings.TrimSpace(name) + " needs a brief: the whole task, in words") } - m, err := a.delegateFor(name) + program, err := a.delegateFor(name) if err != nil { return 0, "", "", err } if a.config.InTask { - return 0, "", "", errors.New("a task cannot hand its work to a delegate; only the conversation can") + return 0, "", "", errors.New("a task cannot hand its work to " + program.Name + "; only the conversation can") } g := a.graph() if chatRunEngine == nil || g == nil || g.planPath() == "" { - return 0, "", "", errors.New("delegates need the run road, and this build has none") + return 0, "", "", errors.New(program.Name + " needs the run road, and this build has none") } id := g.reserve() title := taskPersonTitle(brief) - if err := a.startKnownTaskRunVia(ctx, id, title, brief, nil, delegateStand(a.config.Workspace, m), "", &m); err != nil { + if err := a.startKnownTaskRunVia(ctx, id, title, brief, nil, delegateStand(a.config.Workspace, program), "", &program); err != nil { return 0, "", "", err } return id, title, "", nil } -// delegateStand is where a delegate works. A program that lands a tree gets a -// working copy of the folder, as every task does; one that lands text reads the -// person's folder in place and changes nothing, which is what its manifest -// promised. -func delegateStand(workspace string, m delegate.Manifest) taskStand { - if m.LandsTree() { +// delegateStand is where a program works. One that lands a tree gets a working +// copy of the folder, as every task does; one that lands text reads the +// person's folder in place and changes nothing, which is what it promises. +func delegateStand(workspace string, program delegate.Delegate) taskStand { + if program.LandsTree() { return taskStand{dir: workspace, mode: TaskModeWorktree} } return taskStand{dir: workspace, mode: TaskModeInPlace} @@ -193,7 +171,7 @@ func delegateStand(workspace string, m delegate.Manifest) taskStand { // landDelegateRun is a delegated run's landing, in place of the engine's own. // -// A TREE DELEGATE'S COMMITS ARE SQUASHED. senior-dev commits every edit as it goes +// A TREE PROGRAM'S COMMITS ARE SQUASHED. senior-dev commits every edit as it goes // (`wip(edit): `, dozens a run), so the copy's branch holds bookkeeping // history that is the program's own and nobody else's; the engine's landing // would also find nothing to commit, because everything is already committed, @@ -205,7 +183,7 @@ func delegateStand(workspace string, m delegate.Manifest) taskStand { // terminal record's two sentences. Then the copy comes home the way every run's // copy does. // -// A TEXT DELEGATE LANDS NOTHING: it worked in place and promised to change +// A TEXT PROGRAM LANDS NOTHING: it worked in place and promised to change // nothing, and its answer is the run's result, which the outcome note carries. func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { m := run.delegate @@ -218,7 +196,7 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { if err == nil && strings.TrimSpace(head) != run.startSha { if out, err := git(dir, "reset", "--soft", run.startSha); err != nil { if g := a.graph(); g != nil { - g.planNote("the delegate's commits could not be squashed: " + firstLine(out)) + g.planNote(m.Name + "'s commits could not be squashed: " + firstLine(out)) } } } diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 3c7935ce0..307eaf2e7 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -2,6 +2,7 @@ package session import ( "context" + "flag" "os" "path/filepath" "strings" @@ -10,31 +11,16 @@ import ( "github.com/Agent-Field/codeaf/internal/delegate" ) -// installTestDelegate writes one delegate — manifest, page and a program that -// exists — under dir and loads the registry from it. -func installTestDelegate(t *testing.T, name string) *delegate.Registry { - t.Helper() - dir := t.TempDir() - program := filepath.Join(dir, name+".sh") - if err := os.WriteFile(program, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { - t.Fatal(err) - } - manifest := `{"name":"` + name + `","description":"a fake delegate","bin":"./` + name + `.sh",` + - `"argv":["run","--dir","{{workspace}}","--","{{brief}}"],"lands":"tree"}` - if err := os.WriteFile(filepath.Join(dir, name+".json"), []byte(manifest), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte("# "+name+"\n\n## /"+name+"\n"), 0o644); err != nil { - t.Fatal(err) - } - registry, err := delegate.Load(dir) - if err != nil { - t.Fatal(err) - } - if _, ok := registry.Find(name); !ok { - t.Fatalf("the test delegate did not load: %v %v", registry.Refusals(), registry.Absent()) - } - return registry +// testPrograms is a build that carries one program called name. The session +// never starts its process — the run engine is a double here — so its command +// is a body that is never called. +func testPrograms(name string) []delegate.Delegate { + return []delegate.Delegate{{ + Name: name, Summary: "a fake program", Default: "run", Page: name, + Commands: []delegate.Command{{Name: "run", Bind: func(*flag.FlagSet) delegate.Body { + return func(context.Context, delegate.Host, []string) error { return nil } + }}}, + }} } // The whole road from the door to the branch: `/fake ` starts a run @@ -62,7 +48,7 @@ func TestADelegatedRunSquashesTheProgramsCommitsIntoOneAndLandsIt(t *testing.T) conversation := newTestRepo(t) base := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "HEAD")) sessionDir := t.TempDir() - registry := installTestDelegate(t, "fake") + registry := testPrograms("fake") agent, _ := newTestAgent(t, beltRunCompleter{text: result}, func(config *Config) { config.Workspace = conversation config.Place = Place{Dir: sessionDir} @@ -121,26 +107,26 @@ func TestADelegatedRunSquashesTheProgramsCommitsIntoOneAndLandsIt(t *testing.T) func TestStartDelegateRefusesANameThisMachineDoesNotHave(t *testing.T) { double := newBeltRunDouble("done") registerBeltRunEngine(t, double) - registry := installTestDelegate(t, "fake") + registry := testPrograms("fake") agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { config.Workspace = newTestRepo(t) config.Place = Place{Dir: t.TempDir()} config.Delegates = registry }) _, _, _, err := agent.StartDelegate(context.Background(), "other", "do a thing") - if err == nil || err.Error() != "no delegate is called other; the delegates here are fake" { + if err == nil || err.Error() != "this codeaf carries no program called other; it carries fake" { t.Fatalf("err = %v", err) } if double.didRun() { t.Fatal("a refused delegate started a run") } - // And on a machine with none at all, the sentence says how to get one. + // And a build that carries none says so plainly. none, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { config.Workspace = newTestRepo(t) config.Place = Place{Dir: t.TempDir()} }) _, _, _, err = none.StartDelegate(context.Background(), "fake", "do a thing") - if err == nil || !strings.Contains(err.Error(), "this machine has no delegates") { + if err == nil || err.Error() != "this codeaf carries no program called fake" { t.Fatalf("err = %v", err) } } @@ -154,7 +140,7 @@ func TestNothingJoinsADelegatedRunAndADelegateJoinsNothing(t *testing.T) { double.honoursStop = true registerBeltRunEngine(t, double) conversation := newTestRepo(t) - registry := installTestDelegate(t, "fake") + registry := testPrograms("fake") agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { config.Workspace = conversation config.Place = Place{Dir: t.TempDir()} @@ -167,59 +153,30 @@ func TestNothingJoinsADelegatedRunAndADelegateJoinsNothing(t *testing.T) { <-double.entered stand := taskStand{dir: conversation, mode: TaskModeWorktree} err := agent.startKnownTaskRun(context.Background(), 99, "a second piece", "brief", nil, stand, "") - if err == nil || !strings.Contains(err.Error(), "a delegate runs alone") { + if err == nil || !strings.Contains(err.Error(), "fake runs alone") { t.Fatalf("a task joined a delegated run: %v", err) } endBeltRun(t, agent, double) } -// The prompt names the delegates this launch has, and only where there are -// some: a conversation with a registry reads their names under the hand-off -// facts, and one without reads nothing about delegates at all. +// The prompt names the programs this build carries, and only where there are +// some: a conversation with one reads its name under the hand-off facts, and +// one without reads nothing about them at all. func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { - with := Config{Workspace: t.TempDir(), Delegates: installTestDelegate(t, "fake")} + with := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} page := promptWithBeltFacts(with) - if !strings.Contains(page, "The delegates here are: fake.") { + if !strings.Contains(page, "The programs here\nare: fake.") { t.Fatalf("the page does not name the delegate:\n%s", page) } if !strings.Contains(page, "`via`") { t.Fatal("the page does not say how a delegate is named on a proposal") } without := Config{Workspace: t.TempDir()} - if page := promptWithBeltFacts(without); strings.Contains(page, "The delegates here are") || strings.Contains(page, "can go to a DELEGATE") { + if page := promptWithBeltFacts(without); strings.Contains(page, "The programs here") || strings.Contains(page, "PROGRAM BUILT INTO CODEAF") { t.Fatalf("a launch with no delegates still speaks of them:\n%s", page) } inTask := Config{Workspace: t.TempDir(), Delegates: with.Delegates, InTask: true} - if page := promptWithBeltFacts(inTask); strings.Contains(page, "The delegates here are") { + if page := promptWithBeltFacts(inTask); strings.Contains(page, "The programs here") { t.Fatal("a task node is told it may delegate") } } - -// The manual tool answers "what does /fake do" from the delegate's own page, -// layered over the packed corpus under `delegate-`, and lists it among -// the pages; a conversation with no delegates answers from the packed corpus -// alone. -func TestTheManualToolAnswersFromADelegatesOwnPage(t *testing.T) { - registry := installTestDelegate(t, "fake") - agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { - config.Workspace = t.TempDir() - config.Delegates = registry - }) - tool := agent.manualTool() - text, refused, err := tool.Execute(context.Background(), []byte(`{"page":"delegate-fake"}`)) - if err != nil || refused { - t.Fatalf("the page read was refused: %v %v", refused, err) - } - if !strings.Contains(text, "## /fake") { - t.Fatalf("the page is not the delegate's own:\n%s", text) - } - if _, ok := agent.chatManual().Page("delegates"); !ok { - t.Fatal("the packed delegates page is gone from the layered corpus") - } - plain, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { - config.Workspace = t.TempDir() - }) - if _, refused, _ := plain.manualTool().Execute(context.Background(), []byte(`{"page":"delegate-fake"}`)); !refused { - t.Fatal("a conversation with no delegates read a delegate page") - } -} diff --git a/internal/session/session.go b/internal/session/session.go index 5298f4046..50005d0c1 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -32,7 +32,6 @@ import ( "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/exec/bare" - "github.com/Agent-Field/codeaf/internal/manual" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/offpath" "github.com/Agent-Field/codeaf/internal/provider" @@ -1572,13 +1571,13 @@ type Config struct { // the "absent, not broken" law arriving at a door that was never wired. Subharnesses *exec.Registry - // Delegates is this machine's delegate registry: the outside programs a - // task can be handed to whole (delegate_door.go, docs/DELEGATE-PROTOCOL.md). - // The surface reads it at launch from ~/.codeaf/delegates and hands it in, - // for the reason Subharnesses is a registry and not a path. NIL IS DELEGATES - // OFF: the door lists nothing, `via` refuses every name, and the prompt says - // nothing about them. - Delegates *delegate.Registry + // Delegates is the programs this build carries that a task can be handed to + // whole — senior-dev first (delegate_door.go, internal/delegate). The + // surface hands in the build's list (internal/delegate/builtin) rather than + // this package importing it, so a test of this package never carries a + // program's whole engine. EMPTY IS NONE: the door lists nothing, `via` + // refuses every name, and the prompt says nothing about them. + Delegates []delegate.Delegate // SubharnessMemory is where a running subharness keeps what it has learned // about its OWN domain — its file in its own bundle, never this @@ -3197,11 +3196,6 @@ type Agent struct { // held. beltMu sync.Mutex beltRun *beltRun - // manualCorpus is the manual this conversation answers from — the packed - // corpus with the installed delegates' pages over it — built once on first - // use ([Agent.chatManual]). - manualOnce sync.Once - manualCorpus *manual.Corpus // taskAnswers is the proposals a person owes an answer to, keyed by the id // the EventTaskProposal carried. It is consent's pending-id machinery for a // question whose CLOCK can be held: the wait ends on an answer, on an active diff --git a/internal/session/task.go b/internal/session/task.go index aa42549cd..2e009c309 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -192,7 +192,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + - `"via":{"type":"string","description":"Optional: the name of a delegate — an outside program on this machine that does the whole task on its own — for one large, well-specified change. Only a name your instructions list; it cannot ask the person anything"},` + + `"via":{"type":"string","description":"Optional: the name of a program built into codeaf that does the whole task on its own, for one large, well-specified change. Only a name your instructions list; it cannot ask the person anything"},` + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + `"no_progress":{"type":"integer","description":"Optional. Tool calls in a row that may add nothing before it is stopped as stuck (default ` + strconv.Itoa(taskNoProgress) + `). Raise it for work that must read a great deal first"}` + `},"required":["title","summary","brief","deliverable","acceptance"],"additionalProperties":false}` @@ -654,7 +654,7 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged return bare.Settled(err.Error(), true) } if a.config.InTask || chatRunEngine == nil { - return bare.Settled("a delegate can only be given work from the conversation, and only where the run road is linked", true) + return bare.Settled(spec.via+" can only be given work from the conversation, and only where the run road is linked", true) } } // WHICH HANDS THE WORK LEAVES ON, settled before anybody is asked anything @@ -862,7 +862,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec a.mu.Lock() question := questionAtTaskHandoff(a.owedAsks) a.mu.Unlock() - var via *delegate.Manifest + var via *delegate.Delegate if spec.via != "" { m, err := a.delegateFor(spec.via) if err != nil { @@ -897,7 +897,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec return receipt, false, true } if via != nil { - return "the delegate could not start: " + err.Error(), true, true + return via.Name + " could not start: " + err.Error(), true, true } return "", false, false } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 9d721dc15..f319c3b71 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -114,12 +114,11 @@ type RunSpec struct { CompleterFor func(model string) Completer // OnSpend observes the reconciled cumulative run spend while work is live. OnSpend func(float64) - // Delegate, when set, is the outside program this run's root task is handed - // to instead of a bash worker (delegate_door.go). APIKey is the person's key - // the program is handed through its manifest's `{{key}}`. Nil is every run - // the conversation's own workers drive. - Delegate *delegate.Manifest - APIKey string + // Delegate, when set, is the program this run's root task is handed to + // instead of a bash worker (delegate_door.go). No key goes with it: the + // program reaches a model only through the API codeaf serves the run. Nil is + // every run the conversation's own workers drive. + Delegate *delegate.Delegate } // RunLimit is which bound a person set ended a run. The engine's outcome word @@ -235,11 +234,11 @@ type beltRun struct { // It is the same reading the row published to the surface carries, so the // tree and the row cannot disagree about when the work began. born time.Time - // delegate is the outside program this run's root is handed to, nil for a - // run the conversation's own workers drive; startSha is the commit the copy - // stood on the moment the run began, the point a tree delegate's commits are - // squashed back to at landing (delegate_door.go). - delegate *delegate.Manifest + // delegate is the program this run's root is handed to, nil for a run the + // conversation's own workers drive; startSha is the commit the copy stood on + // the moment the run began, the point a tree program's commits are squashed + // back to at landing (delegate_door.go). + delegate *delegate.Delegate startSha string } @@ -289,11 +288,11 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s } // startKnownTaskRunVia is [Agent.startKnownTaskRun] with the worker named: nil -// is the conversation's own bash worker, and a manifest is the outside program -// the root task is handed to (delegate_door.go). One body serves both because a +// is the conversation's own bash worker, and a program is the one the root task +// is handed to (delegate_door.go). One body serves both because a // delegated run IS a run — the store, the copy, the row and the stop road are // the same — and a second body would be two roads that must stay in step. -func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Manifest) error { +func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Delegate) error { engine := chatRunEngine g := a.graph() if engine == nil || g == nil || g.planPath() == "" { @@ -366,10 +365,10 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie // task beside it would be a bash worker typing in the tree the program is // editing, and a delegate added under a live run would be a second program in // the same tree. Both are refused with what is underway. -func (a *Agent) joinBeltRun(g *TaskGraph, live *beltRun, id uint64, title, brief string, dependencies []plandb.Dependency, stand taskStand, via *delegate.Manifest) error { +func (a *Agent) joinBeltRun(g *TaskGraph, live *beltRun, id uint64, title, brief string, dependencies []plandb.Dependency, stand taskStand, via *delegate.Delegate) error { if via != nil || live.delegate != nil { return errors.New("work is already underway in a copy of " + live.ground + - "; a delegate runs alone, so propose it again when that work has ended") + "; " + aloneName(via, live.delegate) + " runs alone, so propose it again when that work has ended") } if canonicalPath(stand.dir) != live.ground { return standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} @@ -386,12 +385,24 @@ func (a *Agent) joinBeltRun(g *TaskGraph, live *beltRun, id uint64, title, brief return nil } +// aloneName is the program a refused join is about: the one asked for, or the +// one already running. +func aloneName(via, running *delegate.Delegate) string { + if via != nil { + return via.Name + } + if running != nil { + return running.Name + } + return "it" +} + // delegateStartSha is the commit a tree delegate's copy stands on before the // program has written a byte — the point its commits are squashed back to at // landing (delegate_door.go). It is read NOW, off the copy itself: whatever the // ground ladder put under this copy is under this commit, and everything the // program commits is above it. Empty for every run that is not a tree delegate's. -func delegateStartSha(tree taskTree, via *delegate.Manifest) string { +func delegateStartSha(tree taskTree, via *delegate.Delegate) string { if via == nil || !via.LandsTree() { return "" } @@ -442,7 +453,6 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { PlanModel: planSeat, CompleterFor: func(string) Completer { return a.beltRunCompleter() }, Delegate: run.delegate, - APIKey: a.config.APIKey, } } diff --git a/internal/session/tools_manual.go b/internal/session/tools_manual.go index ce097838e..67124b32a 100644 --- a/internal/session/tools_manual.go +++ b/internal/session/tools_manual.go @@ -80,18 +80,18 @@ func (a *Agent) manualTool() bare.Tool { // back is bounded (tools_manual_bound.go says why), and a heading // off the list that bound leaves behind returns that section whole. if name != "" { - text, found := a.chatManual().Page(name) + text, found := manual.Chat().Page(name) if !found { return "There is no manual page named " + name + ". The pages are:" + - a.manualPageList(), true, nil + manualPageList(), true, nil } if heading == "" { - return boundedPage(a.manualHeadings(name), text), false, nil + return boundedPage(name, text), false, nil } - section, found := a.chatManual().Section(name, heading) + section, found := manual.Chat().Section(name, heading) if !found { return "The page " + name + " has no section named " + heading + - ". Its sections are:" + boundedList(a.manualHeadings(name)), true, nil + ". Its sections are:" + boundedList(manualHeadings(name)), true, nil } return boundedSection(section.Body), false, nil } @@ -101,7 +101,7 @@ func (a *Agent) manualTool() bare.Tool { query := strings.TrimSpace(parsed.Query) if query == "" { - return "Give either a query or a page. The pages are:" + a.manualPageList(), true, nil + return "Give either a query or a page. The pages are:" + manualPageList(), true, nil } // AND THE PERSON'S OWN WORDS, taken here rather than asked of // the model. The model composes a query of its own and this @@ -120,7 +120,7 @@ func (a *Agent) manualTool() bare.Tool { // the person's own words for every node of it, and a steer into a // running node is a course correction rather than a question // about codeaf. - sections := a.chatManual().SearchBoth(query, a.taskRequest(), manualSections) + sections := manual.Chat().SearchBoth(query, a.taskRequest(), manualSections) if len(sections) == 0 { // NOT AN ERROR, and the difference matters: the manual having // nothing on a topic is a fact about codeaf worth reporting to @@ -128,7 +128,7 @@ func (a *Agent) manualTool() bare.Tool { // do that" — while an error would invite a retry with rephrased // words that will find nothing either. return "The manual has nothing on that, which usually means codeaf does not do it. The pages are:" + - a.manualPageList(), false, nil + manualPageList(), false, nil } return manual.Render(sections), false, nil }, @@ -137,13 +137,13 @@ func (a *Agent) manualTool() bare.Tool { // manualPageList is the invitation every refusal ends with, built only where a // refusal is being written — a lookup that succeeds never pays for it. -func (a *Agent) manualPageList() string { return boundedList(a.chatManual().Pages()) } +func manualPageList() string { return boundedList(manual.Chat().Pages()) } // manualHeadings is one page's section titles, which is the whole of what a cut // page or a missed heading has to offer: the names of the parts it can be asked // for by. -func (a *Agent) manualHeadings(page string) []string { - sections := a.chatManual().PageSections(page) +func manualHeadings(page string) []string { + sections := manual.Chat().PageSections(page) headings := make([]string, 0, len(sections)) for _, section := range sections { headings = append(headings, section.Title) diff --git a/internal/session/tools_manual_bound.go b/internal/session/tools_manual_bound.go index 8bd03a817..a85ff76db 100644 --- a/internal/session/tools_manual_bound.go +++ b/internal/session/tools_manual_bound.go @@ -45,11 +45,11 @@ const ( // with the cut saying so and naming the sections the rest is in. The headings // are collected only when there is a cut to explain, so the common read pays // nothing for them. -func boundedPage(headings []string, text string) string { +func boundedPage(name, text string) string { if len(text) <= manualPageCap { return text } - sections := boundedList(headings) + sections := boundedList(manualHeadings(name)) return bounded(text, func(shown, total int) string { return pageCutNotice(shown, total, sections) }) } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 9b58439a0..d11a86209 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3269,9 +3269,9 @@ func (a *app) Init() tea.Cmd { standing = append(standing, a.wake()) } } - // THE DELEGATE ROWS ARE ASKED FOR AT THE LAUNCH, off the loop, so the picker + // THE PROGRAM ROWS ARE ASKED FOR AT THE LAUNCH, off the loop, so the picker // and /help list them from the first answer rather than the first keystroke - // (delegate.go). The registry is the conversation's, so a switch asks again + // (delegate.go). The list is the engine's, so a switch asks again // ([app.attachConversation]). standing = append(standing, a.installDelegates()) return tea.Batch(standing...) @@ -7230,13 +7230,6 @@ func (a *app) slash(line string) tea.Cmd { a.openHarness() return nil - case "delegate": - // THE OUTSIDE PROGRAMS A TASK CAN BE HANDED TO WHOLE, listed, or one of - // them run on the words after its name (delegate.go). The installed rows - // are also commands in their own right and dispatch below, under the - // default arm, because they are not in this switch's literal table. - return a.openDelegate(rest) - case "subharness": a.noticeEvent(eventSubharnessOpened) // THE PROGRAMS THIS CONVERSATION CAN RUN, as a filterable list, and the @@ -7425,8 +7418,8 @@ func (a *app) slash(line string) tea.Cmd { if a.droppedLine(line) { return a.edited() } - // AN INSTALLED DELEGATE IS A COMMAND OF ITS OWN (delegate.go). It is - // asked for last, after the literal table, so nothing a delegate is + // A PROGRAM CODEAF CARRIES IS A COMMAND OF ITS OWN (delegate.go). It is + // asked for last, after the literal table, so nothing a program is // called can shadow a word this surface already answers to. if isDelegateCommand(name) { return a.runDelegateCommand(name, rest) diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 964f22803..cf38cec0e 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -231,12 +231,6 @@ var commands = []command{ {name: "subharness", desc: "the programs you can run · type to filter · enter opens its card", alias: []string{"sub"}}, {name: "subharness", args: "", desc: "…straight to that one's card"}, - // THE DELEGATES: outside programs a whole task can be handed to. Each - // installed one is a row of its own — `/senior-dev ` — generated at - // launch from its manifest (delegate.go), so this row is the list and the - // long form, never the only door. - {name: "delegate", desc: "the outside programs a task can be handed to whole", alias: []string{"delegates"}}, - {name: "delegate", args: " ", desc: "…hands that brief to the named one"}, // WHAT IT KNOWS ABOUT YOU, and the two ways to change it. They sit beside // /harness because they answer the neighbouring question — one is what this // conversation has learned to DO, these are what it has been told about YOU diff --git a/internal/tui3/delegate.go b/internal/tui3/delegate.go index 5eebfcc55..723b7da6f 100644 --- a/internal/tui3/delegate.go +++ b/internal/tui3/delegate.go @@ -1,15 +1,15 @@ package tui3 -// DELEGATES ON THE SURFACE: one command row per installed delegate, generated at -// launch from the registry the conversation holds, and `/delegate`, the list of -// them (docs/design/delegate/DESIGN.md). A delegate row runs like `/task`: the -// words after it are the brief, the same door opens, a run starts, the turn -// goes on. +// THE PROGRAMS CODEAF CARRIES, ON THE SURFACE: one command row per program the +// engine's build carries — `/senior-dev ` — generated at launch from the +// list the conversation holds (internal/delegate/builtin, handed in by the +// launch). A program's row runs like `/task`: the words after it are the +// brief, the same door opens, a run starts, the turn goes on. // // THE ROWS ARE APPENDED TO THE LIVE TABLE AND NEVER TO THE LITERAL. The static -// table keeps its static gate (manual_test.go walks it); the rows here exist -// only while their delegate does, and the manual law for them is checked where -// the row comes into existence, by the loader, against the delegate's own page. +// table keeps its static gate (manual_test.go walks it); these rows exist only +// in a build that carries their program, and over `--host` only when the FAR +// machine's build does — which is right, because the program runs there. import ( "context" @@ -22,8 +22,8 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -// delegateAgent is what this surface asks a conversation about delegates: the -// list, and the door. +// delegateAgent is what this surface asks a conversation about the programs: +// the list, and the door. type delegateAgent interface { Delegates() session.DelegateReport StartDelegate(context.Context, string, string) (uint64, string, string, error) @@ -37,11 +37,8 @@ func (a *app) delegateSeam() (delegateAgent, bool) { return agent, ok } -// The words `/delegate` says when there is nothing to list. -const ( - delegateNothingWord = "no delegates here — a delegate is an outside program codeaf can hand a whole task to; a manifest under ~/.codeaf/delegates adds one" - delegateUsageWordTail = " · hands the whole task to that program" -) +// delegateUsageWordTail is what a program's row says under a bare `/`. +const delegateUsageWordTail = " · hands the whole task to that program" // baseCommands is the literal table as this file found it, so the live table // can be rebuilt from it however many times a surface installs rows: a second @@ -50,16 +47,14 @@ var ( baseCommands = append([]command(nil), commands...) delegateRowsMu sync.Mutex delegateRows map[string]bool - // delegateCollisions is every delegate the last install left off the table - // for wearing a built-in command's name, in the sentence `/delegate` draws. - delegateCollisions []string ) // installDelegateCommands rebuilds the live command table as the literal plus -// one row per delegate. A name that collides with a built-in row or alias is -// left out — the loader refused nothing, so the collision is said here, in the -// note the caller draws — because [checkCommands]'s law holds for generated -// rows too: a word may not mean two things. +// one row per program. A name that collides with a row or alias of the literal +// table is left out and answered back, because [checkCommands]'s law holds for +// generated rows too: a word may not mean two things. The build's own test +// keeps any program from being named that way (cmd/codeaf), so this is the +// guard a far engine of another build would need. func installDelegateCommands(rows []session.DelegateRow) []string { delegateRowsMu.Lock() defer delegateRowsMu.Unlock() @@ -81,17 +76,9 @@ func installDelegateCommands(rows []session.DelegateRow) []string { } commands = table delegateRows = installed - delegateCollisions = refused return refused } -// collisions is what the last install would not seat. -func delegateCollisionLines() []string { - delegateRowsMu.Lock() - defer delegateRowsMu.Unlock() - return append([]string(nil), delegateCollisions...) -} - // baseNames is every word the literal table answers to: names and aliases. func baseNames() map[string]bool { names := map[string]bool{} @@ -111,10 +98,10 @@ func isDelegateCommand(name string) bool { return delegateRows[name] } -// installDelegates asks the conversation for its delegates OFF THE LOOP and, +// installDelegates asks the conversation for its programs OFF THE LOOP and, // when the answer comes back, puts their rows on the table. It is asked at the -// launch and again when the conversation in front changes, because the registry -// is the conversation's — and over `--host` it is the far machine's, which is +// launch and again when the conversation in front changes, because the list is +// the engine's — and over `--host` it is the far machine's build, which is // right: the program and the run are there, and the door crosses the wire // (internal/remote's Delegate.List). It rides [app.besideLine] because nobody // pressed for it: a read that waited in the door line behind a person's gesture @@ -128,8 +115,6 @@ func (a *app) installDelegates() tea.Cmd { return a.besideLine(func() func(here bool) tea.Cmd { report := agent.Delegates() return func(here bool) tea.Cmd { - // A collision is not said here — it is said where the person will - // look for the missing row, on `/delegate`. if here { installDelegateCommands(report.Rows) } @@ -138,7 +123,7 @@ func (a *app) installDelegates() tea.Cmd { }) } -// runDelegateCommand is `/ `: the brief goes to that delegate +// runDelegateCommand is `/ `: the brief goes to that program // through a door of its own — asked off the loop like every door — and the // answer lands as a task start, on the message `/task` lands on. func (a *app) runDelegateCommand(name, brief string) tea.Cmd { @@ -149,7 +134,7 @@ func (a *app) runDelegateCommand(name, brief string) tea.Cmd { } agent, ok := a.delegateSeam() if !ok { - a.note("could not start the task · this session has no delegate door") + a.note("could not start the task · this session cannot hand work to /" + name) return nil } ctx := a.ctx @@ -166,60 +151,3 @@ func (a *app) runDelegateCommand(name, brief string) tea.Cmd { } }) } - -// openDelegate is `/delegate`: bare, the list; with a name and words, the -// delegate's own row run on those words. The list is a door, so it is asked off -// the loop and said when it comes back. -func (a *app) openDelegate(rest string) tea.Cmd { - if name, brief, _ := strings.Cut(strings.TrimSpace(rest), " "); name != "" { - if !isDelegateCommand(name) { - a.note(delegateUnknownWord(name)) - return nil - } - return a.runDelegateCommand(name, brief) - } - agent, ok := a.delegateSeam() - if !ok { - a.note(delegateNothingWord) - return nil - } - return a.offLoop(func() func(here bool) tea.Cmd { - report := agent.Delegates() - return func(here bool) tea.Cmd { - if here { - a.note(delegateListNote(report)) - } - return nil - } - }) -} - -// delegateListNote is what `/delegate` says: one line per delegate that can -// run, then the ones whose program is not there, then the manifests that were -// not added and why — the loader's and this surface's own collisions alike. -func delegateListNote(report session.DelegateReport) string { - report.Refused = append(report.Refused, delegateCollisionLines()...) - if len(report.Rows) == 0 && len(report.Absent) == 0 && len(report.Refused) == 0 { - return delegateNothingWord - } - lines := make([]string, 0, len(report.Rows)+len(report.Absent)+len(report.Refused)) - for _, row := range report.Rows { - lands := "lands its work on your branch" - if row.Lands == "text" { - lands = "answers in the conversation" - } - lines = append(lines, "/"+row.Name+" · "+row.Description+" · "+lands+" · "+row.Bin) - } - for _, absent := range report.Absent { - lines = append(lines, "not here: "+absent) - } - for _, refusal := range report.Refused { - lines = append(lines, "not added: "+refusal) - } - return strings.Join(lines, "\n") -} - -// delegateUnknownWord answers `/delegate ` for a name no row carries. -func delegateUnknownWord(name string) string { - return "no delegate is called " + name + " · /delegate lists the ones here" -} diff --git a/internal/tui3/delegate_test.go b/internal/tui3/delegate_test.go index 03dd1caa1..448b99836 100644 --- a/internal/tui3/delegate_test.go +++ b/internal/tui3/delegate_test.go @@ -10,7 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -// delegateFake is the scripted session with the delegate door on it: a list of +// delegateFake is the scripted session with the program door on it: a list of // rows, and a StartDelegate that records what it was asked. type delegateFake struct { *fakeAgent @@ -57,10 +57,10 @@ func settleDoor(t *testing.T, a *app, cmd tea.Cmd) tea.Msg { return nil } -func TestAnInstalledDelegateIsACommandRowThatOpensTheDoor(t *testing.T) { - a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "tree", Bin: "/usr/local/bin/fake"}) +func TestACarriedProgramIsACommandRowThatOpensTheDoor(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "tree"}) if !isDelegateCommand("fake") { - t.Fatal("the delegate's row was not installed") + t.Fatal("the program's row was not installed") } named := false for _, c := range commands { @@ -81,14 +81,23 @@ func TestAnInstalledDelegateIsACommandRowThatOpensTheDoor(t *testing.T) { if len(fake.started) != 1 || fake.started[0] != "fake: rewrite the auth middleware" { t.Fatalf("StartDelegate was asked %v", fake.started) } - // And the long form is the same door. - if cmd := a.slash("/delegate fake do the other thing"); cmd == nil { - t.Fatal("/delegate opened no door") - } else { +} + +// THERE IS NO /delegate. "delegate" is a working title, and every program is +// reached by its own name; the word is not a command. +func TestThereIsNoSlashDelegate(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate"}) + a.width = 200 + if cmd := a.slash("/delegate fake do it"); cmd != nil { settleDoor(t, a, cmd) } - if len(fake.started) != 2 || fake.started[1] != "fake: do the other thing" { - t.Fatalf("StartDelegate was asked %v", fake.started) + if len(fake.started) != 0 { + t.Fatalf("/delegate started work: %v", fake.started) + } + for _, c := range commands { + if c.name == "delegate" { + t.Fatal("the command table still has a /delegate row") + } } } @@ -105,48 +114,13 @@ func TestADelegateRowWithNoBriefSaysItsUsage(t *testing.T) { } } -func TestSlashDelegateListsTheRowsAndTheOnesNotHere(t *testing.T) { - a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "text", Bin: "/opt/fake"}) - fake.report.Absent = []string{"senior-dev: senior-dev is not on this machine"} - fake.report.Refused = []string{"broken: its manual page does not say /broken — not added"} - a.width = 200 - settleDoor(t, a, a.slash("/delegate")) - got := plain(frame(a)) - for _, want := range []string{"/fake ", "a fake delegate", "answers in the conversation", "not here: senior-dev", "not added: broken"} { - if !strings.Contains(got, want) { - t.Fatalf("/delegate did not say %q:\n%s", want, got) - } - } -} - -func TestSlashDelegateWithNothingInstalledSaysSo(t *testing.T) { - a, _ := newDelegateApp(t) - a.width = 200 - settleDoor(t, a, a.slash("/delegates")) - if got := plain(frame(a)); !strings.Contains(got, "no delegates here") { - t.Fatalf("no sentence for a machine with none:\n%s", got) - } - if isDelegateCommand("fake") { - t.Fatal("a row exists for a delegate nobody installed") - } - // A session with no delegate door at all says the same sentence. - bare := newTestApp(&fakeAgent{}) - bare.width = 200 - bare.slash("/delegate") - if got := plain(frame(bare)); !strings.Contains(got, "no delegates here") { - t.Fatalf("no sentence for a session without the door:\n%s", got) - } -} - -func TestADelegateNamedLikeABuiltInCommandIsNotInstalled(t *testing.T) { - a, _ := newDelegateApp(t, session.DelegateRow{Name: "task", Description: "an impostor"}) +func TestAProgramNamedLikeABuiltInCommandIsNotInstalled(t *testing.T) { + newDelegateApp(t, session.DelegateRow{Name: "task", Description: "an impostor"}) if isDelegateCommand("task") { - t.Fatal("a delegate shadowed /task") + t.Fatal("a program shadowed /task") } - a.width = 200 - settleDoor(t, a, a.slash("/delegate")) - if got := plain(frame(a)); !strings.Contains(got, "not added: task: its name is already a command here") { - t.Fatalf("the collision was not said:\n%s", got) + if refused := installDelegateCommands([]session.DelegateRow{{Name: "task", Description: "an impostor"}}); len(refused) != 1 || !strings.Contains(refused[0], "its name is already a command here") { + t.Fatalf("refused = %v, want the collision answered back", refused) } for _, c := range commands { if c.name == "task" && c.desc == "an impostor" { @@ -155,10 +129,10 @@ func TestADelegateNamedLikeABuiltInCommandIsNotInstalled(t *testing.T) { } } -// A HOSTED SURFACE LISTS AND RUNS THE FAR MACHINE'S DELEGATES: the seam crosses +// A HOSTED SURFACE LISTS AND RUNS THE FAR MACHINE'S PROGRAMS: the seam crosses // the wire (internal/remote's Delegate.List and Delegate.Start), the rows are -// generated from what the engine machine has, and the door starts the run -// there. Nothing is refused for being hosted. +// generated from what the engine machine's build carries, and the door starts +// the run there. Nothing is refused for being hosted. func TestAHostedSurfaceInstallsTheFarMachinesDelegateRows(t *testing.T) { fake := &delegateFake{fakeAgent: &fakeAgent{}, report: session.DelegateReport{Rows: []session.DelegateRow{{Name: "fake", Description: "a fake delegate"}}}} a := newTestApp(fake) diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index f72449823..f23f21128 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -645,7 +645,7 @@ func (a *app) attachConversation(conv Conversation, side *aside) tea.Cmd { // generation (watching.go's [followingMsg]). cmds := []tea.Cmd{a.watchTasks(), a.watchWakes(), a.watchDesigns(), a.watchTitles(), a.watchRuns(), a.watchQuestions(), a.loadTasks(), a.askHeld(), a.watchDriving(), a.watchFollowing(), - // The delegate rows are the conversation's, so they follow it (delegate.go). + // The program rows are the engine's, so they follow the conversation (delegate.go). a.installDelegates()} if side != nil { diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 084e9e540..631f311bb 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -181,8 +181,8 @@ const ( // always did — `there is no command called /x · / lists them`, on home's line. func homeFate(word, rest string) string { rest = strings.TrimSpace(rest) - // AN INSTALLED DELEGATE'S ROW IS `/task` WITH THE WORKER CHOSEN (delegate.go), - // and it needs what a /task with a brief needs: a conversation to start in. + // A PROGRAM'S ROW IS `/task` WITH THE WORKER CHOSEN (delegate.go), and it + // needs what a /task with a brief needs: a conversation to start in. if isDelegateCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { return fateNeedsChat } @@ -205,7 +205,7 @@ func homeFate(word, rest string) string { return fateFresh case "land", "workspace": return fateBehind - case "files", "permissions", "connect", "harness", "subharness", "delegate", "autonomy", + case "files", "permissions", "connect", "harness", "subharness", "autonomy", "copy", "select", "rewind", "compact", "export", "drafts", "manual", "folder": // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a // turn of a conversation now (manualcmd.go), and a turn needs one. As diff --git a/internal/tui3/offlooplaw_test.go b/internal/tui3/offlooplaw_test.go index c04874dc7..f4c3ab25e 100644 --- a/internal/tui3/offlooplaw_test.go +++ b/internal/tui3/offlooplaw_test.go @@ -435,7 +435,7 @@ var doorsBesideTheLine = map[string]string{ "PlanRunSummary": "reads the run's stored summary for a refresh nobody pressed for", "PlanTasks": "reads the run's rows for the side list after a message; nobody pressed for it, and a verb's own read is asked only once the verb has landed", "RefreshRunSummary": "asks a model for the run's summary under a budget of seconds; nobody pressed for it and no gesture depends on it", - "Delegates": "reads the engine machine's delegate registry to generate the command rows at the launch and on a switch (delegate.go); nobody pressed for it, and the /delegate a person types asks the same door in the line", + "Delegates": "reads the programs the engine machine's build carries to generate their command rows at the launch and on a switch (delegate.go); nobody pressed for it", } func TestOnlyReadsNobodyPressedForAreAskedBesideTheLine(t *testing.T) { From 4f647c1994586ba8142dd91ab79e47bf459b295f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:29:06 -0400 Subject: [PATCH 019/195] delegate: the model route and the program record are fixed before the lanes Two pieces three lanes will build on at once, fixed here so none of them invents its own: internal/provider/modelapi.ChatURL, the one place a program in codeaf's tree gets its model route from (the funnel law lets only internal/provider spell it), and delegate.ProgramFile, the record of which program a run handed its task to and the stages its hello named, written by the worker and read by the task page. Co-Authored-By: Claude Opus 5.5 --- internal/delegate/conversation.go | 43 ++++++++++++++++++++++++ internal/delegate/conversation_test.go | 14 ++++++++ internal/provider/modelapi/route.go | 23 +++++++++++++ internal/provider/modelapi/route_test.go | 11 ++++++ 4 files changed, 91 insertions(+) create mode 100644 internal/provider/modelapi/route.go create mode 100644 internal/provider/modelapi/route_test.go diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go index 61ff63839..251d62310 100644 --- a/internal/delegate/conversation.go +++ b/internal/delegate/conversation.go @@ -26,6 +26,49 @@ import ( // 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; 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"` +} + +// 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 not said hello 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" diff --git a/internal/delegate/conversation_test.go b/internal/delegate/conversation_test.go index 29fad85df..6b1e43f8a 100644 --- a/internal/delegate/conversation_test.go +++ b/internal/delegate/conversation_test.go @@ -61,3 +61,17 @@ func TestReadTurnsOfARunThatCalledNothingIsEmpty(t *testing.T) { 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/provider/modelapi/route.go b/internal/provider/modelapi/route.go new file mode 100644 index 000000000..a123e0eca --- /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 000000000..7c73aae03 --- /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) + } + } +} From 3cce8db629f0b0c542fea5dc1e4f5f175d48231f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:50:18 -0400 Subject: [PATCH 020/195] session: a program's task page carries its conversation with codeaf What was true: a task a run handed to a program (senior-dev) read like any other task. Its page carried the store row, the brief and the steps its worker recorded, and nothing of the program: not its name, not the stages it said it would move through, not the calls it made to a model. PlanTaskPage never set the page's own Live, so the stage the worker publishes as the live step was drawn nowhere on the page, and a row named no program at all. What is true now: a row names the program its task was handed to and the stage it is in (PlanTaskRow.Program, .Stage), read off the program record in the task's own folder, or off the conversation's live run in the moment before the program's hello. A page carries PlanProgram for a task whose folder holds a program record or a conversation log: the name and stages, the newest 200 calls in the order they started with every text cut to its first line and the run's own copy taken out of paths, how many earlier calls it leaves out, and how many calls reached a model. The page's Live is set. The spend a page shows is still the store's spend rows, which is the figure that moves while the run goes once the model API banks one row per call. The run's ceiling has a field and no source yet: it is worked out when the run starts and written down nowhere a page can read. Every read is inside the page and row reads the surface already makes off its loop, and the remote wire carries the new fields unchanged on the page's own call (client_test.go proves the round trip). Co-Authored-By: Claude Opus 5.5 --- internal/remote/client_test.go | 18 ++ internal/session/plandb_program.go | 272 +++++++++++++++++++++ internal/session/plandb_program_test.go | 310 ++++++++++++++++++++++++ internal/session/plandb_tasks.go | 37 ++- 4 files changed, 635 insertions(+), 2 deletions(-) create mode 100644 internal/session/plandb_program.go create mode 100644 internal/session/plandb_program_test.go diff --git a/internal/remote/client_test.go b/internal/remote/client_test.go index 0be4a92fa..e93a355bd 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" @@ -973,6 +974,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{ @@ -982,6 +985,21 @@ 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. + 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, + }, } e.answers[MethodPlanTasks] = []session.PlanTaskRow{row} e.answers[MethodPlanTaskPage] = PlanTaskPageResult{Page: page, OK: true} diff --git a/internal/session/plandb_program.go b/internal/session/plandb_program.go new file mode 100644 index 000000000..2387586b6 --- /dev/null +++ b/internal/session/plandb_program.go @@ -0,0 +1,272 @@ +package session + +// A PROGRAM'S TASK IS READ AS A CONVERSATION. A task a run handed to a program +// codeaf carries (senior-dev first; internal/delegate) has no steps worth a +// page of their own: the program's work happens inside the calls it makes to a +// model, and every one of those goes through the model API codeaf serves the +// run, which writes each call as one turn of the program's conversation with +// codeaf into the task's own record folder (delegate.ConversationFile). This +// file is the reading side of that record for the task page: whose +// conversation it is, the stages it said it would move through, the stage it +// is in now, and the turns themselves, cut to what a page draws. +// +// NOTHING HERE WRITES. The run's worker writes the program record when the +// program says hello (delegate.ProgramFile) and the model API writes the +// turns; this file reads both, inside the page read the surface already makes +// off its loop ([Agent.PlanTaskPage]) and the row read the side list already +// makes on its beat ([Agent.PlanTasks]), and never on a frame. + +import ( + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// planProgramTurns is how many of a program's calls one page carries: the +// newest, because the page opens stuck to its bottom edge and an hour's run +// makes hundreds. The rest are counted ([PlanProgram.Earlier]) rather than +// carried, which is also what keeps a page read over a connection the size of +// a page and not the size of the log. +const planProgramTurns = 200 + +// planProgramHead is the most of any one text a page carries, in bytes. The +// page draws the first line of what a program sent and of what the model +// answered and never more, so the rest of each text would cross the wire on +// every beat to be thrown away at the far end; the whole of it stays in the +// program's own record on disk. +const planProgramHead = 240 + +// PlanProgram is the program a task was handed to, as the task's page reads it: +// its name and stages off the program record, and its conversation with codeaf +// off the log the model API writes. A page carries one only for a task whose +// record folder holds either — which is to say only for a program's task — and +// every other page carries nil. +type PlanProgram struct { + // Name is the program's own name, the word its command is spelled with. It + // is empty only for a log with no record beside it, which is a program that + // never said who it was; the page draws no name it was not given. + Name string + // Stages is every stage the program said, in its hello, it would move + // through, in order. Empty when the program named none. + Stages []string + // Turns is the conversation: the newest [planProgramTurns] calls the + // program made, in the order they started, each as its latest record says. + // EVERY TEXT IN IT IS CUT TO ITS HEAD — the first line that says anything, at + // most [planProgramHead] bytes — and the run's own copy is taken out of it + // ([planRunCopies.strip]), because that is all the page draws. The record on + // disk is untouched: this is a reading of it. + Turns []delegate.Turn + // Earlier is how many calls came before the first of Turns, which the page + // says rather than draws. Zero when the page carries the whole conversation. + Earlier int + // Calls is how many calls reached a model: every call the log holds that + // codeaf did not refuse, the one in flight included. It is counted over the + // whole log and not over Turns, so it stays the run's own figure however + // long the run has gone. + Calls int + // CeilingUSD is the dollar ceiling the run handed the program, when the page + // knows it, and zero when it does not — which today is always: the ceiling + // is worked out when the run starts (task_run_belt.go's beltRunSpec) and is + // written down nowhere a page can read it afterwards. The page draws it + // beside the spend the day a record carries it, and nothing before. + CeilingUSD float64 +} + +// planProgramRecord is the program a task was handed to: the record the run's +// worker wrote in the task's own folder when the program said hello, and +// otherwise the name the conversation's live run carries for its root +// (`carried`), with no stages. False is every task no program was handed — +// which is every task but a program's run's root. +// +// THE CARRIED NAME IS FOR THE SECONDS BEFORE THE HELLO. A run is published the +// moment it starts and the program writes its hello a moment later, so a row +// read in between would otherwise wear no name at all; once the record is on +// disk it is the record that answers, during the run and after it. +func planProgramRecord(dir, id, carried string) (delegate.ProgramRecord, bool) { + if record, ok := delegate.ReadProgram(plandb.TaskDir(dir, id)); ok { + return record, true + } + if carried = strings.TrimSpace(carried); carried != "" { + return delegate.ProgramRecord{Name: carried}, true + } + return delegate.ProgramRecord{}, false +} + +// planProgramStage is the stage a program says it is in, read off its task's +// live step. The worker publishes a program's phase as the live step in the +// program's own words, `: · ` (internal/run's +// delegateSink.Stage), so the stage is what is left without the name in front +// and without the status behind — `implement` — and nothing at all when no +// step is live, because a run whose program has ended is in no stage. +// +// IT IS READ FOR A PROGRAM'S TASK AND FOR NO OTHER. A live step of any other +// task is a command a worker is running, and a command is not a stage. +func planProgramStage(name string, live plandb.LiveStep) string { + name, label := strings.TrimSpace(name), strings.TrimSpace(live.Command) + if name == "" || label == "" { + return "" + } + label = strings.TrimPrefix(label, name+": ") + if stage, _, found := strings.Cut(label, " · "); found { + label = stage + } + return strings.TrimSpace(label) +} + +// planProgramRow names a row's program and the stage it is in now. It is the +// one place a row learns both, so the record's name and the name the live run +// carries are read into the row the same way. +func planProgramRow(row *PlanTaskRow, name string) { + row.Program = strings.TrimSpace(name) + row.Stage = planProgramStage(row.Program, row.Live) +} + +// planCarriedRow gives a row the program the conversation's live run carries +// for it, when the program's own record has not reached the disk yet +// ([planProgramRecord] says why there is such a moment). A row that already +// names its program keeps the record's name. +func planCarriedRow(row *PlanTaskRow, carried string) { + if row.Program != "" || strings.TrimSpace(carried) == "" { + return + } + planProgramRow(row, carried) +} + +// planCarriedPrograms is the program this conversation's live run was handed +// to, keyed by the run's root task: at most one entry, and none while no +// program's run is live. It is read once for a whole listing, under the belt's +// own lock, the way [Agent.planDisplayRunCopy] reads the live copy beside it. +func (a *Agent) planCarriedPrograms() map[string]string { + a.beltMu.Lock() + defer a.beltMu.Unlock() + if a.beltRun == nil || a.beltRun.delegate == nil { + return nil + } + return map[string]string{a.beltRun.root: a.beltRun.delegate.Name} +} + +// planProgramPage reads one task's program and conversation for its page, or +// nil for a task that is not a program's: no record in its folder, no name the +// live run carries for it, and no conversation log. +// +// A LOG THAT CANNOT BE READ IS A CONVERSATION WITH NO TURNS YET, never a page +// that fails. [delegate.ReadTurns] answers a missing log as nothing said and +// skips a line cut mid-write; anything worse leaves the page with its brief and +// its pinned line, which is still the truth about a run that has said nothing +// this page can read. +func planProgramPage(dir, id, carried string, copies planRunCopies) *PlanProgram { + record, known := planProgramRecord(dir, id, carried) + all, _ := delegate.ReadTurns(plandb.TaskDir(dir, id), 0) + if !known && len(all) == 0 { + return nil + } + program := &PlanProgram{Name: record.Name} + if len(record.Stages) > 0 { + program.Stages = append([]string(nil), record.Stages...) + } + for _, turn := range all { + if strings.TrimSpace(turn.Refused) == "" { + program.Calls++ + } + } + kept := all + if len(kept) > planProgramTurns { + kept = kept[len(kept)-planProgramTurns:] + } + program.Earlier = len(all) - len(kept) + if len(kept) > 0 { + program.Turns = make([]delegate.Turn, len(kept)) + for i, turn := range kept { + program.Turns[i] = planTurnForPage(turn, copies) + } + } + return program +} + +// planTurnForPage is one turn as a page carries it: every text cut to its head +// and the run's own copy taken out of it, and every other field — the call's +// clock, its models, its size and price, whether it was refused or failed — +// exactly as the log says. An absent list stays absent, so a turn that sent +// nothing new reads back the same across the wire as it was written. +func planTurnForPage(turn delegate.Turn, copies planRunCopies) delegate.Turn { + head := func(text string) string { return planTextHead(copies.strip(text)) } + if len(turn.Sent) > 0 { + sent := make([]delegate.Said, len(turn.Sent)) + for i, said := range turn.Sent { + said.Text = head(said.Text) + sent[i] = said + } + turn.Sent = sent + } + turn.Reply = head(turn.Reply) + if len(turn.Calls) > 0 { + calls := make([]delegate.ToolUse, len(turn.Calls)) + for i, call := range turn.Calls { + call.Args = planTextHead(copies.strip(call.Args)) + calls[i] = call + } + turn.Calls = calls + } + turn.Refused = head(turn.Refused) + turn.Failed = head(turn.Failed) + return turn +} + +// planTextHead is the first line of a text that says anything, bounded to +// [planProgramHead] bytes on a rune boundary with the cut marked. +func planTextHead(text string) string { + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimSpace(line); line != "" { + return clip(line, planProgramHead) + } + } + return "" +} + +// strip takes the run's own copy out of a line a program wrote, FOR THE PAGE +// ONLY. A program's tools name every file by its absolute path, and the path of +// the run's copy is the same forty-odd cells in front of every one of them: +// fitted to a row from the right, it was all the row said, and the file the +// call was about was the part cut off. What a path means inside the copy is the +// part after it, so that is what the page carries. +// +// TWO FOLDERS ARE A COPY, and they are the two [planRunCopies] already names: +// the live run's copy (or the row's own folder, when no run is live), and any +// folder directly under the conversation's own folder of copies — an ended +// run's copy has been given back, and its program's words still name it. +func (c planRunCopies) strip(text string) string { + if text == "" { + return text + } + if live := strings.TrimSpace(c.live); live != "" && filepath.IsAbs(live) { + text = strings.ReplaceAll(text, strings.TrimRight(filepath.Clean(live), "/")+"/", "") + } + root := strings.TrimSpace(c.root) + if root == "" || !filepath.IsAbs(root) { + return text + } + prefix := strings.TrimRight(filepath.Clean(root), "/") + "/" + var out strings.Builder + for { + at := strings.Index(text, prefix) + if at < 0 { + break + } + rest := text[at+len(prefix):] + // A COPY IS ONE FOLDER DOWN, named without a space or a quote in it: the + // root followed by anything else is some other path that happens to + // start there, and it is left as it was written. + slash := strings.IndexByte(rest, '/') + if slash <= 0 || strings.ContainsAny(rest[:slash], " \t\"'") { + out.WriteString(text[:at+len(prefix)]) + text = rest + continue + } + out.WriteString(text[:at]) + text = rest[slash+1:] + } + out.WriteString(text) + return out.String() +} diff --git a/internal/session/plandb_program_test.go b/internal/session/plandb_program_test.go new file mode 100644 index 000000000..2bef12c7e --- /dev/null +++ b/internal/session/plandb_program_test.go @@ -0,0 +1,310 @@ +package session + +// A program's task page, read off the two records a program's run leaves in the +// task's own folder: the program record the worker writes at the program's +// hello, and the conversation log the model API appends a turn to per call. +// Every fixture writes them through the contract's own doors +// (delegate.WriteProgram, delegate.AppendTurn) and seeds the store through its +// own API, exactly as the run does; no model is called and no program runs. + +import ( + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// programPageFixture is a store holding one task that was handed to +// senior-dev: its record, a conversation of three calls — one answered, one +// refused by codeaf, one still in flight — the stage the worker published as +// its live step, and two spend rows the model API banked, one per call. +func programPageFixture(t *testing.T) (*Agent, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha", Description: "rewrite the auth middleware"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev", Stages: []string{"intake", "implement", "verification"}}); err != nil { + t.Fatalf("write the program record: %v", err) + } + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + for _, turn := range []delegate.Turn{ + // The first call, as it is written when it starts and again when it ends. + {Seq: 1, Started: began, Model: "deepseek/deepseek-v4-flash", Sent: []delegate.Said{{Role: "system", Text: "you are senior-dev"}, {Role: "user", Text: "rewrite the auth middleware"}}}, + {Seq: 1, Started: began, Ended: began.Add(4 * time.Second), Model: "deepseek/deepseek-v4-flash", + Sent: []delegate.Said{{Role: "system", Text: "you are senior-dev"}, {Role: "user", Text: "rewrite the auth middleware"}}, + Reply: "I'll read the middleware first.", Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"internal/auth/middleware.go"}`}}, + TokensIn: 1200, TokensOut: 40, CostUSD: 0.01}, + {Seq: 2, Started: began.Add(5 * time.Second), Model: "deepseek/deepseek-v4-flash", Refused: "the run's dollar ceiling is reached"}, + {Seq: 3, Started: began.Add(6 * time.Second), Model: "deepseek/deepseek-v4-flash", Sent: []delegate.Said{{Role: "tool", Tool: "read", Text: "package auth\n\nfunc Middleware() {}"}}}, + } { + if err := delegate.AppendTurn(folder, turn); err != nil { + t.Fatalf("append a turn: %v", err) + } + } + store, err := plandb.Open(path, "", planRootID, "", "") + if err != nil { + t.Fatalf("reopen the store: %v", err) + } + if err := store.SetLive("alpha", 2, "senior-dev: implement · running"); err != nil { + t.Fatalf("publish the stage: %v", err) + } + for _, usd := range []float64{0.01, 0.02} { + if err := store.AddSpend("alpha", "deepseek/deepseek-v4-flash", "work", usd, 10, 20); err != nil { + t.Fatalf("bank a call: %v", err) + } + } + _ = store.Close() + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + return agent, dir +} + +// A PROGRAM'S PAGE CARRIES ITS PROGRAM, ITS CONVERSATION AND THE STAGE IT IS IN, +// and the live step the page used to leave unset. The name and the stages are +// the record's; the turns are the log's, one per call, each as its latest +// record says; the calls counted are the ones that reached a model, the call in +// flight included and codeaf's refusal left out; the spend is the store's own +// spend rows, which is what a page read while the run is still going shows. +func TestAProgramsPageCarriesItsConversationStageAndLiveStep(t *testing.T) { + agent, _ := programPageFixture(t) + page, ok := agent.PlanTaskPage("t-alpha") + if !ok { + t.Fatal("the program's task answered no page") + } + program := page.Program + if program == nil { + t.Fatal("a task whose folder holds a program record and a conversation read as no program's") + } + if program.Name != "senior-dev" || strings.Join(program.Stages, ",") != "intake,implement,verification" { + t.Fatalf("program = %q with stages %v, want senior-dev and its three stages", program.Name, program.Stages) + } + if len(program.Turns) != 3 { + t.Fatalf("the page carries %d turns, want the three calls once each", len(program.Turns)) + } + first := program.Turns[0] + if first.Ended.IsZero() || first.Reply != "I'll read the middleware first." || len(first.Calls) != 1 || first.Calls[0].Name != "read" { + t.Fatalf("the first call reads %+v, want its ending record with the reply and the call", first) + } + if program.Turns[1].Refused == "" { + t.Fatalf("the refused call lost its refusal: %+v", program.Turns[1]) + } + if last := program.Turns[2]; !last.InFlight() || len(last.Sent) != 1 || last.Sent[0].Tool != "read" { + t.Fatalf("the call in flight reads %+v, want it open with what the program sent", last) + } + if program.Calls != 2 || program.Earlier != 0 { + t.Fatalf("calls = %d, earlier = %d; want 2 calls that reached a model and nothing earlier", program.Calls, program.Earlier) + } + if page.Live.Step != 2 || page.Live.Command != "senior-dev: implement · running" { + t.Fatalf("the page's live step = %+v, want the stage the worker published", page.Live) + } + if page.Row.Program != "senior-dev" || page.Row.Stage != "implement" { + t.Fatalf("the page's row names %q in stage %q, want senior-dev in implement", page.Row.Program, page.Row.Stage) + } + if math.Abs(page.Row.USD-0.03) > 1e-9 { + t.Fatalf("the page's spend = %v, want the 0.03 its spend rows carry", page.Row.USD) + } + + // AND THE SIDE LIST'S ROW SAYS THE SAME, off the same read. + row := planRowByID(t, agent.PlanTasks(), "t-alpha") + if row.Program != "senior-dev" || row.Stage != "implement" || math.Abs(row.USD-0.03) > 1e-9 { + t.Fatalf("the row reads program %q, stage %q, spend %v", row.Program, row.Stage, row.USD) + } +} + +// AN ORDINARY TASK IS NO PROGRAM'S. A task with neither record in its folder +// carries no program on its page and no program or stage on its row, so every +// page but a program's draws exactly what it drew — and its live step, which +// is a command, is never read as a stage. +func TestAnOrdinaryTaskCarriesNoProgram(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + store, err := plandb.Open(path, "", planRootID, "", "") + if err != nil { + t.Fatal(err) + } + if err := store.SetLive("alpha", 4, "go test: ./internal/api · -run TestX"); err != nil { + t.Fatal(err) + } + _ = store.Close() + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + page, ok := agent.PlanTaskPage("t-alpha") + if !ok { + t.Fatal("the task answered no page") + } + if page.Program != nil || page.Row.Program != "" || page.Row.Stage != "" { + t.Fatalf("an ordinary task reads as a program's: page %+v, row program %q stage %q", page.Program, page.Row.Program, page.Row.Stage) + } + if page.Live.Step != 4 { + t.Fatalf("an ordinary page's live step = %+v, want the one its row carries", page.Live) + } +} + +// A LONG RUN'S PAGE CARRIES ITS NEWEST CALLS AND COUNTS THE REST. The page +// opens at its bottom edge, so the newest calls are the ones it holds; the ones +// before them are a number the page says, and the call count is the whole +// log's, however long the run has gone. +func TestAProgramsPageCarriesTheNewestCallsAndCountsTheRest(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + total := planProgramTurns + 5 + for seq := 1; seq <= total; seq++ { + at := began.Add(time.Duration(seq) * time.Second) + if err := delegate.AppendTurn(folder, delegate.Turn{Seq: seq, Started: at, Ended: at.Add(time.Second), Model: "m", Reply: "ok"}); err != nil { + t.Fatal(err) + } + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil { + t.Fatal("the program's task answered no program page") + } + if got := len(page.Program.Turns); got != planProgramTurns { + t.Fatalf("the page carries %d turns, want the newest %d", got, planProgramTurns) + } + if page.Program.Earlier != 5 || page.Program.Calls != total { + t.Fatalf("earlier = %d, calls = %d; want 5 earlier and %d calls", page.Program.Earlier, page.Program.Calls, total) + } + if first := page.Program.Turns[0].Seq; first != 6 { + t.Fatalf("the first call the page carries is %d, want 6", first) + } +} + +// EVERY TEXT A PAGE CARRIES IS ITS HEAD, AND THE RUN'S COPY IS TAKEN OUT OF IT. +// A program's tools name files by their absolute path inside the copy, and a +// row fitted from the right drew the copy and cut the file off; a reply of a +// hundred lines crossed the wire every beat to have one line drawn. The record +// on disk is not touched: the page is a reading of it. +func TestAProgramsTurnsAreCutToTheirHeadsAndLeaveTheCopyOut(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + // THE CONVERSATION HAS A FOLDER OF ITS OWN, and its copies are cut under it: + // an ended run's copy is one folder down from there. + place := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Place = Place{Dir: place} }) + armPlanStore(t, agent, path, "chat-a") + if agent.treesDir() == "" { + t.Fatal("the conversation has no folder of copies to cut a run's copy under") + } + copyDir := filepath.Join(agent.treesDir(), "7") + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + long := strings.Repeat("word ", 200) + if err := delegate.AppendTurn(folder, delegate.Turn{ + Seq: 1, Started: began, Ended: began.Add(time.Second), Model: "m", + Sent: []delegate.Said{{Role: "tool", Tool: "bash", Text: "\n\n ok " + copyDir + "/internal/auth\t0.3s\nPASS\n"}}, + Reply: "\nFirst line of the answer.\nSecond line.\n" + long, + Calls: []delegate.ToolUse{{Name: "edit", Args: `{"filePath":"` + copyDir + `/internal/auth/middleware.go","oldString":"x"}`}}, + }); err != nil { + t.Fatal(err) + } + onDisk, err := os.ReadFile(filepath.Join(folder, delegate.ConversationFile)) + if err != nil { + t.Fatal(err) + } + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil || len(page.Program.Turns) != 1 { + t.Fatalf("the program's page = %+v", page.Program) + } + turn := page.Program.Turns[0] + if turn.Reply != "First line of the answer." { + t.Fatalf("the reply's head = %q, want its first line that says anything", turn.Reply) + } + if got := turn.Sent[0].Text; got != "ok internal/auth\t0.3s" { + t.Fatalf("the tool result's head = %q, want its first line with the copy taken out", got) + } + if got := turn.Calls[0].Args; strings.Contains(got, copyDir) || !strings.Contains(got, `"internal/auth/middleware.go"`) { + t.Fatalf("the call's arguments = %q, want the file named inside the copy", got) + } + after, err := os.ReadFile(filepath.Join(folder, delegate.ConversationFile)) + if err != nil || string(after) != string(onDisk) { + t.Fatalf("reading the page changed the record on disk (err %v)", err) + } + if len(turn.Reply) > planProgramHead { + t.Fatalf("a head is %d bytes, over the %d a page carries", len(turn.Reply), planProgramHead) + } +} + +// THE LIVE RUN NAMES ITS PROGRAM BEFORE THE PROGRAM HAS SAID HELLO. A run is +// published the moment it starts and its program writes its record a moment +// later; the row read in between wears the name the run was handed, and once +// the record is there it is the record that answers. +func TestTheLiveRunNamesItsProgramBeforeTheRecordIsOnDisk(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + agent.beltMu.Lock() + agent.beltRun = &beltRun{root: "alpha", delegate: &delegate.Delegate{Name: "senior-dev"}} + agent.beltMu.Unlock() + t.Cleanup(func() { + agent.beltMu.Lock() + agent.beltRun = nil + agent.beltMu.Unlock() + }) + if row := planRowByID(t, agent.PlanTasks(), "t-alpha"); row.Program != "senior-dev" { + t.Fatalf("the live run's row names %q before the hello, want senior-dev", row.Program) + } + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil || page.Program.Name != "senior-dev" || len(page.Program.Turns) != 0 { + t.Fatalf("the live run's page before the hello = %+v", page.Program) + } + if other := planRowByID(t, agent.PlanTasks(), "t-"+planRootID); other.Program != "" { + t.Fatalf("a task the program was not handed names %q", other.Program) + } +} + +// THE STAGE IS THE PROGRAM'S PHASE AND NOTHING ELSE: the worker's label without +// the program's name in front and without the status behind, and nothing for a +// task no program runs or a program with no step live. +func TestTheStageIsReadOffTheProgramsLiveStep(t *testing.T) { + for _, tc := range []struct { + name, label, want string + }{ + {"senior-dev", "senior-dev: implement · running", "implement"}, + {"senior-dev", "senior-dev: verification", "verification"}, + {"senior-dev", "implement · submitted", "implement"}, + {"senior-dev", "", ""}, + {"", "senior-dev: implement · running", ""}, + } { + if got := planProgramStage(tc.name, plandb.LiveStep{Step: 1, Command: tc.label}); got != tc.want { + t.Errorf("planProgramStage(%q, %q) = %q, want %q", tc.name, tc.label, got, tc.want) + } + } +} + +// THE COPY IS STRIPPED WHERE IT IS A COPY AND NOWHERE ELSE: the live copy and +// any folder one step under the conversation's folder of copies, and never a +// path that merely starts at that folder. +func TestStripTakesOnlyTheRunsCopyOut(t *testing.T) { + copies := planRunCopies{live: "/w/trees/9", root: "/w/trees"} + for _, tc := range []struct{ in, want string }{ + {"/w/trees/9/a.go and /w/trees/3/b.go", "a.go and b.go"}, + {"cat /w/trees/README", "cat /w/trees/README"}, + {`{"path":"/w/trees/my copy/x"}`, `{"path":"/w/trees/my copy/x"}`}, + {"/elsewhere/x.go", "/elsewhere/x.go"}, + } { + if got := copies.strip(tc.in); got != tc.want { + t.Errorf("strip(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index 34c763b8f..23bf6ab09 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -77,6 +77,16 @@ type PlanTaskRow struct { // ending of its loop, so a task that is not running never claims a present. Live plandb.LiveStep LiveParts []PlanCommandPart + // Program is the name of the program this task was handed to — senior-dev — + // read off the program record in the task's own record folder + // ([planProgramRecord]), and empty for every task a worker of this + // conversation's own drives. Stage is the stage that program says it is in + // right now, its live step read without its name in front + // ([planProgramStage]), and empty whenever nothing is live. The rail draws + // both under the run's own row, where a program's run used to wear only its + // clock. + Program string + Stage string // TrajectoryPath is the file the task's steps are recorded in, for a reader // that wants the record itself and not only its length. TrajectoryPath string @@ -121,6 +131,12 @@ type PlanTaskPage struct { // WaitRows feed the page's two-way waits reading: own dependencies first, // then open tasks directly waiting on this task. Empty omits the section. WaitRows []PlanTaskRow + // Program is the program this task was handed to and the conversation it + // has had with codeaf so far (plandb_program.go): nil for every task a worker + // of this conversation's own drives, which is every page but a program's. + // A page that carries one is drawn as that conversation rather than as a + // list of steps. + Program *PlanProgram } // PlanStep is one line of a task's trajectory — one command the worker ran and @@ -202,6 +218,7 @@ func (a *Agent) PlanTasks() []PlanTaskRow { } var rows []PlanTaskRow copies := a.planDisplayRunCopy() + carried := a.planCarriedPrograms() for _, store := range stores { dir := filepath.Dir(store.Path()) spend := planSpendByTask(store.Path()) @@ -214,6 +231,7 @@ func (a *Agent) PlanTasks() []PlanTaskRow { root := store.RootID() for _, task := range tasks { row := planTaskRow(store, dir, task, spend, live) + planCarriedRow(&row, carried[task.ID]) row.Folder = a.planTaskRunCopy(task.ID) row.LiveParts = planStepDisplayFacts(PlanStep{Command: row.Live.Command}, copies.or(row.Folder), planShimFilename).Parts rows = append(rows, row) @@ -253,6 +271,7 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { spend := planSpendByTask(store.Path()) live := store.LiveSteps() copies := a.planDisplayRunCopy() + carried := a.planCarriedPrograms() // Walk admission order once; membership follows parent edges only. all := store.Tasks(plandb.Filter{Chat: plan.chat}) rows := make(map[string]PlanTaskRow, len(all)) @@ -260,6 +279,7 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { depths := map[string]int{task.ID: -1} for _, child := range all { row := planTaskRow(store, dir, child, spend, live) + planCarriedRow(&row, carried[child.ID]) row.Folder = a.planTaskRunCopy(child.ID) row.LiveParts = planStepDisplayFacts(PlanStep{Command: row.Live.Command}, copies.or(row.Folder), planShimFilename).Parts rows[child.ID] = row @@ -304,8 +324,14 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { Folder: pageRow.Folder, Notes: planTaskNotes(store, task.ID), Steps: planStepDisplayFactsForPage(planTrajectory(dir, task.ID), copies.or(pageRow.Folder)), - Children: children, - WaitRows: waitRows, + // THE PAGE CARRIES ITS OWN LIVE STEP, lifted off its row. The field was + // declared for a surface to draw the step one step early and was never + // set, so the step in flight — and a program's stage, which is published + // as that same step — was drawn nowhere on the page. + Live: pageRow.Live, + Children: children, + WaitRows: waitRows, + Program: planProgramPage(dir, task.ID, carried[task.ID], copies.or(pageRow.Folder)), }, true } @@ -600,6 +626,13 @@ func planTaskRow(store *plandb.Store, dir string, task *plandb.Task, spend map[s TrajectoryPath: planTrajectoryPath(dir, task.ID), Live: live[task.ID], } + // A PROGRAM'S ROW NAMES ITS PROGRAM AND THE STAGE IT IS IN, both off what is + // on disk beside the trajectory or already read: the record the worker wrote + // at the program's hello, and the live step the worker publishes the stage + // as. Every other row costs one look for a record that is not there. + if record, ok := planProgramRecord(dir, task.ID, ""); ok { + planProgramRow(&row, record.Name) + } if task.ParentID != "" { row.Parent = planStoreID(task.ParentID) } From d16758a1a8eb9b132fd431c5d8ddcfebd7a01974 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:10:23 -0400 Subject: [PATCH 021/195] tui3: a program's task page is its conversation with codeaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was true: a run handed to a program (senior-dev) opened on the page every task opens on — a telemetry line, the brief, the steps the program reported — with a note box and the sentence "the worker reads a note at its next step", neither of which a program ever honoured. What the program was doing, the calls it made to a model, was drawn nowhere; the rail row wore only its clock, and a plan row drew the program's stage behind the shell's `$` as if it were a command. What is true now: a program's page is the conversation between the program and the model that answered it, drawn from the page the surface already holds (taskconversation.go). The brief opens it under the program's name; 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 (its short name, the first line of its answer, one dim row per tool it asked for behind that tool's action mark); a refused call is codeaf's one line, a failed call the model's; the call in flight is the last line, the running mark, the model and its seconds, and it goes when the call returns. The line under the title is pinned — stage (or the state word), spend so far, calls, age — so it survives the page following its bottom edge; the head and the foot are now counted by the frame, the window and the scroll alike. A program's page, and its run's tab, have no box and take no note: the foot is the rule and the keys, and typed letters are nothing. The rail row of a program's run says its stage and its spend so far; a program's plan row draws its stage and never a `$` command. Nothing drawn is zero or unknown, every row is fitted to its width, every text is one clean row, and every mark comes through the glyph door. The manual's worker-harness page says what the page shows, and three probes reach it. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/worker-harness.md | 38 +- internal/manual/chat_test.go | 6 + internal/tui3/task.go | 92 +++- internal/tui3/taskconversation.go | 695 +++++++++++++++++++++++++ internal/tui3/taskconversation_test.go | 483 +++++++++++++++++ internal/tui3/taskplan.go | 231 +++++--- internal/tui3/worktab.go | 6 + 7 files changed, 1482 insertions(+), 69 deletions(-) create mode 100644 internal/tui3/taskconversation.go create mode 100644 internal/tui3/taskconversation_test.go diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 288195647..2f0e98dff 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -95,8 +95,9 @@ With the switch on, a run is drawn in the conversation's side list as its own ro door: click the run's row, or select it and press `enter`, and its page opens over the conversation; click a part's row or a check's row and THAT task's page opens. The page is the one the tasks place opens: what the task was asked, its notes, its steps, and the -box that leaves a note. `esc` goes back to the conversation exactly as you left it, with -whatever you had typed still in the box. +box that leaves a note — except a program's page, which is its conversation and has no +box (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 page can take a moment to arrive. From the press on, what you type belongs to the page and never to the conversation: the keys are kept in order and land in the page's @@ -184,6 +185,39 @@ steps When the command ends the store clears the live step and the next read draws it as an ordinary step, with its number and the head of what came back. +## A program's task page is a conversation, not steps — a delegate's page: what the program sent, what the model answered, the call in flight, no note box + +A task handed to a program codeaf carries (`/ `) has its own page. Every model +call the program makes goes through codeaf, so the page is that conversation: the program on one +side, like a very particular person asking codeaf things, and the model that answered on the +other. + +``` + rewrite the auth middleware + implement · $1.24 · 3 calls · 14m 3s + ─────────────────────────────────── + rewrite the auth middleware to use the new session store + deepseek-v4-flash I'll read the middleware and the store first. + ▤ read internal/auth/middleware.go + read: package auth + ◐ deepseek-v4-flash · 12s +``` + +The line under the title stays put while you scroll: the stage the program says it is in (the +task's own word, such as `running` or `done`, when there is none), what the run has spent so +far, how many model calls it has made, and how long it has been going. A figure with nothing +behind it is left out. The conversation opens on the brief. 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, gone when the call returns. + +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 whole of every call is kept in the task's own record. +The page has no note box: a program reads no note, so nothing typed there would reach it. While +the run goes, `x` stops it. On the side list the run's row says the stage and the spend so far. + ## 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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 960a0cbef..a9f8be409 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2800,6 +2800,12 @@ 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"}, } for _, ask := range asked { found := Chat().Search(ask.question, DefaultResults) diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 4ab207856..48754b741 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -5057,7 +5057,14 @@ func (a *app) railUnder(node *taskNode, width int) []string { // specific true thing there is about it, and the rows below would each // say something less: a call it is inside of, a hold that is not holding // it, or a clock. It takes the row for [app.railDoing]'s reason. + // + // AND A PROGRAM'S RUN SAYS THE STAGE ITS PROGRAM IS IN, next after a + // named phase and for the same reason: it is what the node is doing, in + // the only vocabulary the program has ([app.railStage]). rows := a.railDoing(node, width) + if len(rows) == 0 { + rows = a.railStage(node, width) + } if len(rows) == 0 { rows = a.railPhase(node, width) } @@ -5299,7 +5306,7 @@ func planUnderRows(item tasksItem, width int, pal palette) []string { return nil } rows := make([]string, 0, railUnderRows) - if line := planLiveRow(item.plan.Live.Command, item.plan.LiveParts, width, pal); line != "" { + if line := planLiveLine(*item.plan, width, pal); line != "" { rows = append(rows, line) } if figures := planFigures(item.plan); figures != "" { @@ -5311,6 +5318,28 @@ func planUnderRows(item tasksItem, width int, pal palette) []string { return rows } +// planLiveLine is a plan row's live line, whichever kind of worker it has. A +// PROGRAM'S LIVE STEP IS ITS STAGE AND NOT A COMMAND: the worker publishes the +// program's phase on the same live row a bash worker publishes its command on +// (internal/run's delegateSink.Stage), and drawn behind the shell's `$` it read +// as a command somebody typed — `$ senior-dev: implement · running`. So a +// program's row draws the running mark and the stage its row carries +// ([session.PlanTaskRow.Stage]), and every other row draws its command; a +// program's row with no stage to name draws what it always drew, so the line +// the layout counted is always a line with something on it. +func planLiveLine(row session.PlanTaskRow, width int, pal palette) string { + if strings.TrimSpace(row.Program) != "" { + if stage := strings.TrimSpace(row.Stage); stage != "" { + lead := pal.glyph(tokens.GStepRunning) + " " + if room := width - ansi.StringWidth(lead); room > 0 { + return lead + pal.dim(fit(stage, room)) + } + return "" + } + } + return planLiveRow(row.Live.Command, row.LiveParts, width, pal) +} + // planLiveRow is the live step's own line: the running step's glyph, the shell // lead, and the command the step is running. The glyph and the lead are drawn // OUTSIDE the fitting — they are two whole cells and a command never gets to @@ -5346,6 +5375,65 @@ func (a *app) railDoing(node *taskNode, width int) []string { return []string{a.pal.dim(fit(node.doing, width))} } +// railProgramRow is the run's own plan row for a node whose run was handed to +// a program, read out of the rows the surface already holds +// ([app.heldPlanRows]) — never out of the store, because this is asked on every +// frame the column is drawn. A run's row and its store's root are one piece of +// work under one number (the store is rooted at the task's own id), so the row +// is found by that number, and only a row that names a program answers. +func (a *app) railProgramRow(node *taskNode) (session.PlanTaskRow, bool) { + if node == nil || node.id == 0 { + return session.PlanTaskRow{}, false + } + rows, ok := a.heldPlanRows() + if !ok { + return session.PlanTaskRow{}, false + } + id := itoa(int(node.id)) + for _, row := range rows { + if strings.TrimPrefix(strings.TrimSpace(row.ID), "t-") == id && strings.TrimSpace(row.Program) != "" { + return row, true + } + } + return session.PlanTaskRow{}, false +} + +// railStage is the row a program's run wears while it runs: the stage its +// program says it is in, alone, the way a named phase is drawn ([app.railDoing]). +// +// implement senior-dev writing the change +// verification and checking it +// +// A program's run used to wear only its clock here, because nothing the run +// publishes on its row says what the program is doing: the stage lives on the +// store's live step, which the side list reads on its own beat. It is nil +// between stages and for every other node. +func (a *app) railStage(node *taskNode, width int) []string { + row, ok := a.railProgramRow(node) + if !ok { + return nil + } + stage := fit(strings.TrimSpace(row.Stage), width) + if stage == "" { + return nil + } + return []string{a.pal.dim(stage)} +} + +// railSpent is what a node has cost so far, for the telemetry under it. It is +// [taskNode.spent] for every node, and for a program's run the larger of that +// and what the run's own spend rows carry: the run publishes no price on its row +// until it lands, while the model API banks a row per call as it goes. THE TWO +// ARE THE SAME MONEY AND ARE NEVER ADDED — the larger is the more recent reading +// of one bill, the rule [taskNode.spent] already keeps for its own two lanes. +func (a *app) railSpent(node *taskNode) float64 { + spent := node.spent() + if row, ok := a.railProgramRow(node); ok && row.USD > spent { + spent = row.USD + } + return spent +} + // railMending is the row a node wears while it is closing a named gap in work it // has otherwise finished, or nil when there is no gap being closed. // @@ -5437,7 +5525,7 @@ func (a *app) railTelemetry(node *taskNode, width int) string { // difference between keeping the price and dropping it. segs = append(segs, tokenWord(node.tokens)) } - if spent := node.spent(); spent > 0 { + if spent := a.railSpent(node); spent > 0 { segs = append(segs, dollars(spent)) } if model := railModelWord(node); model != "" { diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go new file mode 100644 index 000000000..a1fe919c8 --- /dev/null +++ b/internal/tui3/taskconversation.go @@ -0,0 +1,695 @@ +package tui3 + +// taskconversation.go draws a PROGRAM'S task page as the conversation it is. +// +// A task a run handed to a program codeaf carries (senior-dev first) used to +// open on the same page as every other task: a telemetry line, the brief, and a +// list of steps, with a box for a note the program would never read. What the +// program was actually doing was invisible — its work happens inside the calls +// it makes to a model, and every one of those goes through the model API codeaf +// serves the run. So codeaf has the whole exchange, and this page draws it: the +// program on one side, like a very particular person asking codeaf things, and +// the model that answered on the other, one call at a time, with the call in +// flight as the last line while it is out. +// +// senior-dev rewrite the auth middleware to use the new session store +// deepseek-v4-flash I'll read the middleware first. +// ▤ read internal/auth/middleware.go +// senior-dev read: package auth +// ◐ deepseek-v4-flash · 12s +// +// THE PAGE READS NOTHING. Every line here is drawn from the page the surface +// already holds ([session.PlanTaskPage.Program], read off the loop on the page's +// own beat), with the frame's own clock for the two figures that tick — the +// run's age and the call in flight's. The texts arrive cut to their first line +// with the run's copy taken out of their paths (internal/session's +// plandb_program.go), so a row here is a choice of which line to show and never +// a reading of the record. +// +// WHAT IS DRAWN IS THE PERSON'S, NEVER THE MACHINERY'S. A program's system +// prompt and the model's own words handed back to it are part of every call and +// say nothing new, so neither is ever a row; a program that summarized its own +// history is said in one line, not replayed. + +import ( + "strconv" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +const ( + // convLabelMost is the most cells a speaker's name is given in the column + // the names stand in. A model id past it is cut in the middle, keeping the + // end that tells one model of a family from another ([rowTrim]). + convLabelMost = 22 + // convTextLeast is the least room the words beside the names keep. Under it + // the column costs more than it buys, and each name stands on a line of its + // own with its words under it. + convTextLeast = 28 + // convGap is the air between a name and its words: the row fitter's own + // gutter, because this is the same shape of row — a name and what it says. + convGap = rowGutter + // convIndent is how far a speaker's words hang under its name when the names + // stand on lines of their own: the two-cell lead every piece of this + // surface's machinery keeps. + convIndent = 2 + // convSaidMost is how many of the program's messages one of its turns draws. + // A turn that answers eight tool calls sends eight results, and the eight + // calls are already drawn one row each on the model's side just above it. + convSaidMost = 3 + // convCodeaf is who answers a call codeaf refused. No model saw it, so the + // line under the program's is codeaf's own. + convCodeaf = "codeaf" + // convProgramFallback names the program's side in the one case its name is + // unknown: a conversation log with no program record beside it. + convProgramFallback = "program" +) + +// The words this page says in its own voice, each quoted in the manual as it is +// spelled here (worker-harness.md). +const ( + // convRestartedWord is the program's side of a call made after it rewrote its + // own history as a summary: what it sent is its whole history again, and that + // is one sentence rather than a replay. + convRestartedWord = "summarized its history so far" + // convFailedWord leads the line a call the model's side failed draws. + convFailedWord = "the call failed" + // convEarlierWord follows the count of calls the page does not carry. + convEarlierWord = "earlier calls" +) + +// convSide is one speaker's turn at talking: the name in the column and the +// lines it said, each already painted and not yet fitted. +type convSide struct { + name string + lines []convLine +} + +// convLine is one line a speaker said. lead is a painted mark drawn in front of +// the words and outside their fitting, so a narrow row gives up the tail of the +// words and never half a mark; text is the words, painted by ink. +type convLine struct { + lead string + text string + ink func(string) string +} + +// taskPlanIsProgram reports whether the open page is a program's: its read +// carries the program's conversation, or its row names the program — which is +// all a page opened on a held row knows until its own read comes back +// ([app.openWorkTab]), and that page must not flash a box it will take away. +func (a *app) taskPlanIsProgram() bool { + page := a.taskSheet.plan + return page.Program != nil || strings.TrimSpace(page.Row.Program) != "" +} + +// convProgramOf is the page's program, never nil on a program's page: a page +// whose read has not come back yet is a program with nothing said, named off +// its row. +func convProgramOf(page session.PlanTaskPage) *session.PlanProgram { + if page.Program != nil { + return page.Program + } + return &session.PlanProgram{Name: strings.TrimSpace(page.Row.Program)} +} + +// taskPlanPinned is the one line a program's page pins under its title, which +// no scroll moves: where the program is, what it has spent, how many calls it +// has made, and how long it has been going. +// +// implement · $1.24 · 38 calls · 14m 3s +// +// THE LEAD IS THE STAGE, and the task's own state word when there is no stage — +// `running` in the seconds before the program names one, and `done`, +// `incomplete` or `stopped` once it has ended — so the line always says where +// the work stands. EVERY OTHER FIGURE IS DRAWN ONLY WHEN IT IS SOMETHING: no +// `$0.00`, no `0 calls`, no clock under a second. The spend is the store's +// spend rows for this task — the run's own money, which the status line's +// conversation total is not — and it names the ceiling beside it only when the +// page knows the ceiling. The line is ranked and fitted by [rowTail], so a +// narrow frame gives up the clock before the calls and the calls before the +// money. +func (a *app) taskPlanPinned(page session.PlanTaskPage, width int) string { + if (page.Program == nil && strings.TrimSpace(page.Row.Program) == "") || width < 1 { + return "" + } + program := convProgramOf(page) + lead := strings.TrimSpace(page.Row.Stage) + if lead == "" { + lead = planStateWord(page.Row) + } + fields := []rowField{rowSay(lead)} + if usd := planSpendWord(page.Row.USD); usd != "" { + if program.CeilingUSD > 0 { + fields = append(fields, rowSay(usd+" of "+dollars(program.CeilingUSD), usd)) + } else { + fields = append(fields, rowSay(usd)) + } + } + if n := program.Calls; n > 0 { + fields = append(fields, rowSay(itoa(n)+" "+plural("call", n))) + } + fields = append(fields, rowSay(a.taskPlanAge(page.Row))) + return rowTail(fields, width) +} + +// taskPlanAge is how long a task has been going: from when it was made to when +// it ended, or to now while it runs. A task that has ended without a moment +// recorded for its ending draws no age rather than one that keeps climbing. +func (a *app) taskPlanAge(row session.PlanTaskRow) string { + if row.Started.IsZero() { + return "" + } + end := row.Ended + if end.IsZero() { + if planEnded(row) { + return "" + } + end = a.now() + } + if end.Before(row.Started) { + return "" + } + return countUpWord(end.Sub(row.Started)) +} + +// taskProgramBody is what a person reads on a program's page, under the pinned +// line: the conversation, and under it the notes the run left — its outcome and +// where its work went, which arrive when it ends and so belong at the bottom +// edge the page opens on, not above an hour of calls. +// +// EVERYTHING AN ORDINARY PAGE SPENDS ON ITS OWN MACHINERY IS ABSENT. The +// telemetry line is the pinned one; the brief opens the conversation; and a +// program's run records no steps worth a list of their own when the calls that +// did the work are on the page. A run whose conversation was never written — +// one from before the model API kept one — still has the steps its program +// reported, and draws them, so no page shows less than it did. +func (a *app) taskProgramBody(width int) []string { + page, pal := a.taskSheet.plan, a.pal + var out []string + if n := len(a.taskSheet.planBack); n > 0 { + out = append(out, pal.dim("esc/← "+a.taskSheet.planBack[n-1].Row.Title)) + } + out = append(out, a.taskConversation(page, width)...) + if len(convProgramOf(page).Turns) == 0 && len(page.Steps) > 0 { + out = append(out, "", pal.dim("steps")) + for _, step := range page.Steps { + if step.NotRun { + continue + } + if command := planDisplayCommand(step.Command, step.Parts); command != "" { + out = append(out, pal.ink(itoa(step.Step)+" "+command)) + } + } + } + if len(page.Notes) > 0 { + if len(out) > 0 { + out = append(out, "") + } + out = append(out, pal.dim("notes")) + out = append(out, a.taskPlanNoteRows(page.Notes, width)...) + } + return out +} + +// taskConversation is a program's page where an ordinary page draws its steps: +// the brief the program was handed, then every call it made, each as the +// program's side and the side that answered it. +// +// THE NAMES STAND IN A COLUMN OF THEIR OWN while the frame has the room, so the +// eye reads down the speakers and across to what each said; under +// [convTextLeast] cells of words each name stands on its own line instead. The +// column is as wide as the widest name on the page, so it does not move as the +// conversation grows by a call from the same model. +func (a *app) taskConversation(page session.PlanTaskPage, width int) []string { + if width < 1 { + return nil + } + program := convProgramOf(page) + pal := a.pal + speaker := convProgramName(page) + running := planStateWord(page.Row) == "running" + column, text := convColumns(convNames(program, speaker), width) + + var out []string + // THE BRIEF OPENS THE CONVERSATION. It is what the program was handed, in + // the person's own words, and it stands for the program's side of the first + // call — whose own words are the program's prompt around the same brief. + // Folded to the brief's own three lines, with the key that unfolds it, the + // way every other page folds a brief. + opening := convSide{name: speaker} + for _, line := range a.taskConversationBrief(page, text) { + opening.lines = append(opening.lines, convLine{text: line, ink: pal.ink}) + } + if len(opening.lines) > 0 { + out = append(out, convDraw(opening, column, width, pal)...) + } + // THE CALLS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, never in + // a speaker's column, where the count read as something the program said. It + // is spelled the way every fold line on this surface is ([bandFoldWord]). + if program.Earlier > 0 { + out = append(out, pal.dim(fit(glyphMore+itoa(program.Earlier)+" "+convEarlierWord, width))) + } + briefHead := convBriefHead(page.Description) + for i, turn := range program.Turns { + first := i == 0 && program.Earlier == 0 + if said := a.convProgramSide(turn, speaker, briefHead, first); len(said.lines) > 0 { + out = append(out, convDraw(said, column, width, pal)...) + } + switch { + case convHead(turn.Refused) != "": + out = append(out, convDraw(convSide{name: convCodeaf, lines: []convLine{{ + text: taskPlanRefusedWord + railSep + convHead(turn.Refused), ink: pal.dim, + }}}, column, width, pal)...) + case convHead(turn.Failed) != "": + out = append(out, convDraw(convSide{name: convModelWord(turn), lines: []convLine{{ + text: convFailedWord + railSep + convHead(turn.Failed), ink: pal.dim, + }}}, column, width, pal)...) + case turn.InFlight(): + // THE CALL IN FLIGHT IS THE LIVE EDGE, and it is drawn only while the + // task can still be waiting on it. A call whose ending never reached the + // log before the run ended is not in flight on a page about work that + // is over: it draws no line at all rather than a clock that never stops. + if running { + if line := a.convInFlight(turn, width); line != "" { + out = append(out, line) + } + } + default: + if answer := a.convModelSide(turn); len(answer.lines) > 0 { + out = append(out, convDraw(answer, column, width, pal)...) + } + } + } + return out +} + +// taskConversationBrief is the brief as the conversation opens with it: the +// description through the reader every page draws a brief with +// ([planBriefRows]), at the width the words get beside the names, folded to +// [briefFoldLines] with the line that says how many more and which key opens +// them. +func (a *app) taskConversationBrief(page session.PlanTaskPage, text int) []string { + lines := planBriefRows(page.Description, text) + if a.taskSheet.planBriefFull || len(lines) <= briefFoldLines { + return lines + } + return append(append([]string(nil), lines[:briefFoldLines]...), + bandFoldWord(len(lines)-briefFoldLines, briefFoldWhat, true)+railSep+briefFoldKey) +} + +// taskConversationFolds reports whether a program's brief is long enough to +// fold at the frame's own width, which is what `ctrl+o` asks before it opens +// or closes it ([app.taskPlanKey]). It measures the brief at the width the +// conversation draws it at, so the key and the fold line cannot disagree. +func (a *app) taskConversationFolds() bool { + page := a.taskSheet.plan + width, _ := a.size() + _, text := convColumns(convNames(convProgramOf(page), convProgramName(page)), width-2) + return len(planBriefRows(page.Description, text)) > briefFoldLines +} + +// convProgramSide is what the program said on one call, in the lines a person +// reads for it: nothing new on the first call, whose words the brief above +// already stands for; one sentence on a call made after it summarized its own +// history; and otherwise its newest messages, each as its first line — a tool's +// result as `: ` and its own words as they were. +// +// NEITHER A PROMPT NOR AN ECHO IS A ROW. A `system` message is the program +// instructing its model, and an `assistant` one is the model's last answer +// handed back to it, which the model's own side has already drawn; and a +// message that is the brief again says nothing the opening has not. +func (a *app) convProgramSide(turn delegate.Turn, speaker, briefHead string, first bool) convSide { + pal := a.pal + side := convSide{name: speaker} + if turn.Restarted { + side.lines = append(side.lines, convLine{text: convRestartedWord, ink: pal.dim}) + return side + } + if first { + return side + } + var said []string + for _, message := range turn.Sent { + line := convHead(message.Text) + switch strings.TrimSpace(message.Role) { + case "system", "assistant": + continue + case "tool": + if tool := strings.TrimSpace(message.Tool); tool != "" { + line = tool + ": " + line + } + default: + if convRepeatsBrief(line, briefHead) { + continue + } + } + if strings.TrimSpace(line) != "" { + said = append(said, line) + } + } + shown := said + if len(shown) > convSaidMost { + shown = shown[:convSaidMost] + } + for _, line := range shown { + side.lines = append(side.lines, convLine{text: line, ink: pal.dim}) + } + if more := len(said) - len(shown); more > 0 { + side.lines = append(side.lines, convLine{text: "+" + itoa(more) + " more", ink: pal.dim}) + } + return side +} + +// convModelSide is what the model answered on one call: the first line of its +// words, and every tool it asked the program to run, one dim row each behind +// that tool's action mark — the same family marks the conversation's own steps +// wear ([app.actionMarkFor]), so a person who has learned `✎` for an edit there +// reads it here. +func (a *app) convModelSide(turn delegate.Turn) convSide { + pal := a.pal + side := convSide{name: convModelWord(turn)} + if reply := convHead(turn.Reply); reply != "" { + side.lines = append(side.lines, convLine{text: reply, ink: pal.ink}) + } + for _, call := range turn.Calls { + name := convHead(call.Name) + if name == "" { + continue + } + words := name + if about := convHead(convCallAbout(call.Args)); about != "" { + words += " " + about + } + mark := a.actionMarkFor(session.ActionCategoryForTool(name)) + side.lines = append(side.lines, convLine{lead: pal.dim(mark) + " ", text: words, ink: pal.dim}) + } + return side +} + +// convInFlight is the call in flight: the running mark, the model it went to, +// and how long it has been out — one line, the last on the page, gone the moment +// its ending reaches the log. The mark comes off the vocabulary's own door, so +// the line gets this terminal's repertoire; the clock is the frame's and says +// nothing for the call's first second. +func (a *app) convInFlight(turn delegate.Turn, width int) string { + mark := a.icon(tokens.GStepRunning) + room := width - ansi.StringWidth(mark) - 1 + if room < 1 { + return "" + } + var words []string + if model := convModelWordOf(turn.Model); model != "" { + words = append(words, model) + } + if !turn.Started.IsZero() { + if clock := countUpWord(a.now().Sub(turn.Started)); clock != "" { + words = append(words, clock) + } + } + return a.pal.ink(mark) + " " + a.pal.dim(fit(strings.Join(words, railSep), room)) +} + +// convDraw lays one side out: its name in the column on its first line and its +// words beside it, or — where the frame is too narrow for a column — its name +// on a line of its own and its words hung under it. EVERY ROW IS FITTED TO THE +// WIDTH: the name is cut in the middle when it must be ([rowTrim]), a mark in +// front of the words is kept whole, and the words give up their tail. +func convDraw(side convSide, column, width int, pal palette) []string { + var out []string + if column == 0 { + if name := strings.TrimSpace(side.name); name != "" { + label, _ := rowTrim(name, width, false) + out = append(out, pal.muted(label)) + } + indent := strings.Repeat(" ", convIndent) + for _, line := range side.lines { + out = append(out, indent+convWords(line, width-convIndent)) + } + return out + } + gap := strings.Repeat(" ", convGap) + blank := strings.Repeat(" ", column) + for i, line := range side.lines { + cell := blank + if i == 0 && strings.TrimSpace(side.name) != "" { + label, _ := rowTrim(side.name, column, false) + cell = padTo(pal.muted(label), column) + } + out = append(out, cell+gap+convWords(line, width-column-convGap)) + } + return out +} + +// convWords is one line's words at their width, behind its mark when it has +// one. A width too small to hold the mark draws the words alone. +func convWords(line convLine, width int) string { + if width < 1 { + return "" + } + ink := line.ink + if ink == nil { + ink = func(s string) string { return s } + } + lead := line.lead + if lead != "" { + if cells := ansi.StringWidth(ansi.Strip(lead)); cells < width { + return lead + ink(fit(line.text, width-cells)) + } + } + return ink(fit(line.text, width)) +} + +// convColumns decides the page's two widths from the names on it: the column +// the names stand in, and the room their words get beside it. A column of zero +// is the narrow layout, where every name stands on its own line and the words +// hang [convIndent] cells under it. +func convColumns(names []string, width int) (int, int) { + widest := 0 + for _, name := range names { + if cells := ansi.StringWidth(strings.TrimSpace(name)); cells > widest { + widest = cells + } + } + column := widest + if column > convLabelMost { + column = convLabelMost + } + if third := width / 3; column > third { + column = third + } + if column < 1 || width-column-convGap < convTextLeast { + return 0, width - convIndent + } + return column, width - column - convGap +} + +// convNames is every name the page will draw in its column: the program's, +// codeaf's when a call was refused, and the model of every call on the page. +func convNames(program *session.PlanProgram, speaker string) []string { + names := []string{speaker} + for _, turn := range program.Turns { + if strings.TrimSpace(turn.Refused) != "" { + names = append(names, convCodeaf) + continue + } + if !turn.InFlight() { + names = append(names, convModelWord(turn)) + } + } + return names +} + +// convProgramName is the name the program's side wears: the program record's, +// then its row's, and a plain noun only when neither said. +func convProgramName(page session.PlanTaskPage) string { + if page.Program != nil { + if name := strings.TrimSpace(page.Program.Name); name != "" { + return name + } + } + if name := strings.TrimSpace(page.Row.Program); name != "" { + return name + } + return convProgramFallback +} + +// convModelWord is the model that answered a call: the one that served it +// when codeaf's router answered with another than the program asked for, and +// otherwise the one asked for. +func convModelWord(turn delegate.Turn) string { + if served := strings.TrimSpace(turn.Served); served != "" { + return convModelWordOf(served) + } + return convModelWordOf(turn.Model) +} + +// convModelWordOf is a model id as a speaker's name: the part after the last +// vendor, which is the part that names the model rather than who sells it — +// the rail's own reading of a model ([railModelWord]). +func convModelWordOf(id string) string { + id = convHead(id) + if at := strings.LastIndexByte(id, '/'); at >= 0 && at+1 < len(id) { + id = id[at+1:] + } + return id +} + +// convHead is the first line of a text that says anything, CLEANED TO BE ONE +// ROW. The page's texts arrive already cut to their heads (internal/session's +// plandb_program.go), and this is still the one door every text on the page is +// drawn through, for two reasons: a page built any other way — a test's, an +// older engine's — must not draw a second line into one row, and what a +// program sends is a tool's raw output, which carries tabs a row cannot measure +// and escape sequences a terminal would obey. So the line is stripped of every +// escape sequence, a tab becomes a space and any other control character goes. +func convHead(text string) string { + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimSpace(convClean(line)); line != "" { + return line + } + } + return "" +} + +// convClean is one line of a program's words with nothing a terminal would act +// on left in it. +func convClean(line string) string { + return strings.Map(func(r rune) rune { + switch { + case r == '\t': + return ' ' + case r < 0x20, r == 0x7f, r >= 0x80 && r < 0xa0: + return -1 + } + return r + }, ansi.Strip(line)) +} + +// convBriefHead is the brief's first line as a program's own message would +// carry it, so a message that is the brief again can be told from one that +// says something new. +func convBriefHead(description string) string { return convHead(description) } + +// convRepeatsBrief reports whether a message's first line is the brief's again. +// The page carries a message's head cut at a couple of hundred bytes with the +// cut marked, so a long brief repeated is the brief's own line up to that mark. +func convRepeatsBrief(line, briefHead string) bool { + if line == "" || briefHead == "" { + return false + } + if line == briefHead { + return true + } + cut := strings.TrimSuffix(line, glyphMore) + return cut != line && cut != "" && strings.HasPrefix(briefHead, cut) +} + +// convAboutKeys are the arguments that say what a call was about, most telling +// first: the command a shell ran, the pattern a search looked for (a search +// names where it looked too, and the pattern is the part a person reads it +// for), the address a fetch went to, then the file a call opened or wrote. They +// are the names the programs' tools use for them, in both spellings those tools +// use. +var convAboutKeys = []string{ + "command", "cmd", "pattern", "query", "url", "filePath", "file_path", "path", "description", "prompt", +} + +// convCallAbout is what a call was about, in the fewest words that say it. A +// program's tool call carries its arguments as one line of JSON, cut at a +// couple of hundred bytes when it is long — an edit carries the text it +// replaces — so it is READ FORGIVINGLY rather than parsed: the most telling +// argument that has a string value, then the first string value there is. A +// line that is not an object is drawn as it was written, and an object that +// holds no string at all says nothing a row can carry. +func convCallAbout(args string) string { + args = strings.TrimSpace(args) + if !strings.HasPrefix(args, "{") { + return args + } + for _, key := range convAboutKeys { + if value, ok := convArgNamed(args, key); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + for at := 0; at < len(args); { + colon := strings.IndexByte(args[at:], ':') + if colon < 0 { + break + } + at += colon + 1 + if rest := strings.TrimLeft(args[at:], " \t"); strings.HasPrefix(rest, `"`) { + if value := strings.TrimSpace(convJSONString(rest[1:])); value != "" { + return value + } + } + } + return "" +} + +// convArgNamed is the string value of one argument, when the line names it +// with a string value; a value cut off at the line's end is read to where it +// was cut. +func convArgNamed(args, key string) (string, bool) { + name := `"` + key + `"` + for from := 0; ; { + at := strings.Index(args[from:], name) + if at < 0 { + return "", false + } + from += at + len(name) + rest := strings.TrimLeft(args[from:], " \t") + if !strings.HasPrefix(rest, ":") { + continue + } + rest = strings.TrimLeft(rest[1:], " \t") + if !strings.HasPrefix(rest, `"`) { + return "", false + } + return convJSONString(rest[1:]), true + } +} + +// convJSONString reads a JSON string's body up to its closing quote, or to the +// end of a line that was cut inside it, with its escapes read as the characters +// they stand for and a line break or a tab as a space. +func convJSONString(body string) string { + var out strings.Builder + for i := 0; i < len(body); i++ { + c := body[i] + switch { + case c == '"': + return out.String() + case c != '\\': + out.WriteByte(c) + continue + } + if i+1 >= len(body) { + break + } + i++ + switch body[i] { + case 'n', 't', 'r': + out.WriteByte(' ') + case 'u': + if i+4 < len(body) { + if code, err := strconv.ParseUint(body[i+1:i+5], 16, 32); err == nil && utf8.ValidRune(rune(code)) { + out.WriteRune(rune(code)) + i += 4 + continue + } + } + out.WriteByte('u') + default: + // `\"`, `\\` and `\/` stand for the character after the backslash. + out.WriteByte(body[i]) + } + } + return out.String() +} diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go new file mode 100644 index 000000000..eea6d2147 --- /dev/null +++ b/internal/tui3/taskconversation_test.go @@ -0,0 +1,483 @@ +package tui3 + +// A program's task page, drawn from a scripted page the way the store answers +// one ([session.PlanTaskPage.Program]): the pinned line, the conversation in +// place of the steps, the call in flight, and the foot with no box. Nothing +// here seeds a store or runs a program; the page is the fake's, and every +// reading the surface makes of it is the one a real window makes. + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "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/tui2/tokens" +) + +// programRunBegan is when the fixture's run started: fourteen minutes and three +// seconds before the frame's own clock, so the pinned age is a figure a test +// can spell. +var programRunBegan = taskFixtureNow.Add(-(14*time.Minute + 3*time.Second)) + +// programRow is the run's root as the store answers it mid-way: running, +// handed to senior-dev, in its implement stage, with a dollar and a quarter of +// spend rows banked by the model API. +func programRow() session.PlanTaskRow { + return session.PlanTaskRow{ + ID: "t-7", Title: "rewrite the auth middleware", Status: "running", + Program: "senior-dev", Stage: "implement", USD: 1.24, Started: programRunBegan, + Live: plandb.LiveStep{Step: 3, Command: "senior-dev: implement · running", Since: programRunBegan}, + } +} + +// programTurns is a conversation three calls long: two answered, each with the +// tools the model asked for, and the third still out, twelve seconds in. +func programTurns() []delegate.Turn { + model := "deepseek/deepseek-v4-flash" + return []delegate.Turn{ + {Seq: 1, Started: programRunBegan, Ended: programRunBegan.Add(4 * time.Second), Model: model, + Sent: []delegate.Said{{Role: "system", Text: "you are senior-dev"}, {Role: "user", Text: "rewrite the auth middleware to use the new session store"}}, + Reply: "I'll read the middleware and the store first.", + Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"internal/auth/middleware.go"}`}, {Name: "grep", Args: `{"pattern":"SessionStore","path":"internal"}`}}, + CostUSD: 0.4}, + {Seq: 2, Started: programRunBegan.Add(5 * time.Second), Ended: programRunBegan.Add(9 * time.Second), Model: model, + Sent: []delegate.Said{ + {Role: "assistant", Text: "I'll read the middleware and the store first."}, + {Role: "tool", Tool: "read", Text: "package auth"}, + {Role: "tool", Tool: "grep", Text: "internal/auth/store.go:12: type SessionStore interface {"}, + }, + Reply: "The store interface is small; I'll change the handler.", + Calls: []delegate.ToolUse{{Name: "edit", Args: `{"filePath":"internal/auth/middleware.go","oldString":"func Middleware(`}}, + CostUSD: 0.5}, + {Seq: 3, Started: taskFixtureNow.Add(-12 * time.Second), Model: model, + Sent: []delegate.Said{{Role: "tool", Tool: "edit", Text: "applied 1 edit"}}}, + } +} + +// programPage is the page the fixture's row opens. +func programPage(row session.PlanTaskRow, turns []delegate.Turn) session.PlanTaskPage { + return session.PlanTaskPage{ + Row: row, + Description: "rewrite the auth middleware to use the new session store", + Live: row.Live, + Program: &session.PlanProgram{ + Name: "senior-dev", Stages: []string{"intake", "implement", "verification"}, + Turns: turns, Calls: len(turns), + }, + } +} + +// programPageApp opens the program's page at a width and a height, the two +// keys a person presses, on a surface whose clock is the fixture's. +func programPageApp(t *testing.T, page session.PlanTaskPage, width, height int) (*app, *planFake) { + t.Helper() + a, fake := planAppWith(t, []session.PlanTaskRow{page.Row}, map[string]session.PlanTaskPage{page.Row.ID: page}) + a.width, a.height = width, height + openPlanPage(t, a) + if !a.taskPlanIsProgram() { + t.Fatalf("the page opened is not a program's: %+v", a.taskSheet.plan.Program) + } + return a, fake +} + +// programPageLines is the page as drawn, one plain string per screen row. +func programPageLines(a *app) []string { + width, height := a.size() + lines, _, _ := a.taskPlanFrame(width, height) + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = plain(line) + } + return out +} + +// saidBy reports whether a row of the page has a speaker's name in the names' +// column and these words beside it, whatever width the column came to. +func saidBy(lines []string, name, words string) bool { + for _, line := range lines { + rest, ok := strings.CutPrefix(strings.TrimSpace(line), name) + if ok && strings.HasPrefix(rest, " ") && strings.HasPrefix(strings.TrimLeft(rest, " "), words) { + return true + } + } + return false +} + +// A PROGRAM'S PAGE IS ITS CONVERSATION WITH CODEAF. The brief opens it under the +// program's name; each call is the program's side and the model's, the model +// named by its short name; every tool the model asked for is a dim row behind +// its family's mark; the call still out is the last line, with its model and its +// clock; and the line under the title is pinned with the stage, the spend, the +// calls and the age. +func TestAProgramsPageDrawsItsConversationWithCodeaf(t *testing.T) { + a, _ := programPageApp(t, programPage(programRow(), programTurns()), 80, 30) + lines := programPageLines(a) + page := strings.Join(lines, "\n") + t.Logf("a program's page, mid-way:\n%s", page) + + if lines[0] != "rewrite the auth middleware" { + t.Fatalf("the head's first row is %q, want the task's title", lines[0]) + } + if lines[1] != "implement · $1.24 · 3 calls · 14m 3s" { + t.Fatalf("the pinned line is %q, want the stage, the spend, the calls and the age", lines[1]) + } + for _, said := range []struct{ name, words string }{ + {"senior-dev", "rewrite the auth middleware to use the new session store"}, + {"deepseek-v4-flash", "I'll read the middleware and the store first."}, + {"senior-dev", "read: package auth"}, + {"deepseek-v4-flash", "The store interface is small; I'll change the handler."}, + {"senior-dev", "edit: applied 1 edit"}, + } { + if !saidBy(lines, said.name, said.words) { + t.Fatalf("the page does not have %s saying %q:\n%s", said.name, said.words, page) + } + } + read := a.actionMarkFor(session.ActionCategoryForTool("read")) + grep := a.actionMarkFor(session.ActionCategoryForTool("grep")) + edit := a.actionMarkFor(session.ActionCategoryForTool("edit")) + for _, want := range []string{ + read + " read internal/auth/middleware.go", + grep + " grep SessionStore", + // A result longer than the room beside the names gives up its tail. + "grep: internal/auth/store.go:12: type SessionStore interfa" + glyphMore, + edit + " edit internal/auth/middleware.go", + a.icon(tokens.GStepRunning) + " deepseek-v4-flash · 12s", + } { + if !strings.Contains(page, want) { + t.Fatalf("the page is missing %q:\n%s", want, page) + } + } + // THE CALL IN FLIGHT IS THE CONVERSATION'S LAST LINE. + last := "" + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "─") && !strings.Contains(line, taskCardBackWord) { + last = line + } + } + if !strings.Contains(last, a.icon(tokens.GStepRunning)+" deepseek-v4-flash") { + t.Fatalf("the last line of the conversation is %q, want the call in flight", last) + } + // NEITHER A PROMPT NOR AN ECHO IS A ROW: the program's system prompt and the + // model's own answer handed back to it are nowhere on the page, and the brief + // is said once. + if strings.Contains(page, "you are senior-dev") || strings.Count(page, "I'll read the middleware and the store first.") != 1 { + t.Fatalf("the page drew a prompt or an echo:\n%s", page) + } + if strings.Count(page, "rewrite the auth middleware to use the new session store") != 1 { + t.Fatalf("the brief is drawn more than once:\n%s", page) + } + // EVERY ROW FITS THE FRAME. + for i, line := range lines { + if cells := ansi.StringWidth(line); cells > 80 { + t.Fatalf("row %d is %d cells in an 80-cell frame: %q", i, cells, line) + } + } +} + +// A PROGRAM'S PAGE HAS NO BOX. A program reads no note, so the page draws no +// composer, never promises that a worker reads a note at its next step, offers +// no send, hides the caret, and a letter typed at it is nothing — never a note +// sent to the store and never a letter in a box that is not there. +func TestAProgramsPageHasNoNoteBoxAndTakesNoNote(t *testing.T) { + a, fake := programPageApp(t, programPage(programRow(), programTurns()), 80, 30) + page := strings.Join(programPageLines(a), "\n") + for _, never := range []string{taskPlanNoteWord, taskPlanPickupWord, "enter send", "notes", "steps"} { + if strings.Contains(page, never) { + t.Fatalf("a program's page says %q:\n%s", never, page) + } + } + if !strings.Contains(page, tasksPlanCancelWord) || !strings.Contains(page, taskCardBackWord) { + t.Fatalf("a program's page lost its stop or its way back:\n%s", page) + } + if a.caret { + t.Fatal("a program's page shows a caret over no box") + } + for _, r := range "pause it" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(fake.noted) != 0 || len(fake.paused) != 0 || !a.taskSheet.planNote.empty() { + t.Fatalf("typing on a program's page wrote notes %v, paused %v, box %q", fake.noted, fake.paused, a.taskSheet.planNote.String()) + } + if !a.taskSheet.planOn { + t.Fatal("typing on a program's page closed it") + } +} + +// THE PINNED LINE IS PINNED. The page opens stuck to its bottom edge and follows +// the conversation down, and a conversation longer than the frame scrolls — the +// pinned line stays under the title at the bottom, part way up, and back at the +// bottom again, while the newest call is what the bottom shows. +func TestAProgramsPinnedLineSurvivesScrollingToTheBottom(t *testing.T) { + row := programRow() + var turns []delegate.Turn + for i := 1; i <= 40; i++ { + at := programRunBegan.Add(time.Duration(i) * 10 * time.Second) + turns = append(turns, delegate.Turn{Seq: i, Started: at, Ended: at.Add(5 * time.Second), Model: "deepseek/deepseek-v4-flash", + Sent: []delegate.Said{{Role: "tool", Tool: "bash", Text: "result " + itoa(i)}}, Reply: "answer " + itoa(i)}) + } + a, _ := programPageApp(t, programPage(row, turns), 80, 20) + pinned := "implement · $1.24 · 40 calls · 14m 3s" + check := func(when string) []string { + t.Helper() + lines := programPageLines(a) + if lines[1] != pinned { + t.Fatalf("%s: the row under the title is %q, want the pinned line %q", when, lines[1], pinned) + } + return lines + } + lines := check("opened") + if !strings.Contains(strings.Join(lines, "\n"), "answer 40") { + t.Fatalf("a page opened at its bottom edge does not show the newest call:\n%s", strings.Join(lines, "\n")) + } + for i := 0; i < 12; i++ { + drive(t, a, key("up")) + } + if a.taskSheet.planStick { + t.Fatal("scrolling up left the page stuck to its bottom edge") + } + check("scrolled up") + drive(t, a, tea.KeyPressMsg{Code: tea.KeyPgDown}) + drive(t, a, tea.KeyPressMsg{Code: tea.KeyPgDown}) + if !a.taskSheet.planStick { + t.Fatal("scrolling back to the bottom did not take the follow up again") + } + lines = check("back at the bottom") + if !strings.Contains(strings.Join(lines, "\n"), "answer 40") { + t.Fatalf("back at the bottom, the newest call is not on screen:\n%s", strings.Join(lines, "\n")) + } +} + +// THE CALL IN FLIGHT IS GONE WHEN IT RETURNS. The page follows the task on its +// beat; the read after the call's ending reached the log draws the model's +// answer in its place and no running mark anywhere. +func TestTheCallInFlightLeavesWhenItReturns(t *testing.T) { + row := programRow() + a, fake := programPageApp(t, programPage(row, programTurns()), 80, 30) + flying := a.icon(tokens.GStepRunning) + " deepseek-v4-flash" + if page := strings.Join(programPageLines(a), "\n"); !strings.Contains(page, flying) { + t.Fatalf("the call in flight is not drawn:\n%s", page) + } + back := programTurns() + back[2].Ended = taskFixtureNow + back[2].Reply = "The handler now reads the session store." + fake.pages[row.ID] = programPage(row, back) + planBeat(t, a) + page := strings.Join(programPageLines(a), "\n") + if strings.Contains(page, flying) { + t.Fatalf("the call that returned is still drawn in flight:\n%s", page) + } + if !saidBy(programPageLines(a), "deepseek-v4-flash", "The handler now reads the session store.") { + t.Fatalf("the call that returned did not draw its answer:\n%s", page) + } +} + +// NOTHING IS DRAWN FOR NOTHING. A program that has spent nothing, made no call +// and named no stage wears its state word alone on the pinned line — no +// `$0.00`, no `0 calls` — and a call left open in the log of a run that has +// ended is not in flight on a page about work that is over. +func TestAProgramsPageDrawsNothingForZeroOrUnknown(t *testing.T) { + row := programRow() + row.USD, row.Stage, row.Started, row.Live = 0, "", time.Time{}, plandb.LiveStep{} + page := programPage(row, nil) + page.Program.Calls = 0 + a, _ := programPageApp(t, page, 80, 20) + lines := programPageLines(a) + if lines[1] != "running" { + t.Fatalf("the pinned line of a program that has said nothing is %q, want its state word alone", lines[1]) + } + text := strings.Join(lines, "\n") + for _, never := range []string{"$0.00", "0 calls", "0s", "earlier calls"} { + if strings.Contains(text, never) { + t.Fatalf("the page drew %q for a figure nobody has:\n%s", never, text) + } + } + if !saidBy(lines, "senior-dev", "rewrite the auth middleware to use the new session store") { + t.Fatalf("a program that has made no call yet does not open on its brief:\n%s", text) + } + + // AN ENDED RUN'S OPEN CALL IS NO CALL IN FLIGHT. + ended := programRow() + ended.Status, ended.Stage, ended.Live, ended.Ended = "done", "", plandb.LiveStep{}, taskFixtureNow.Add(-time.Minute) + b, _ := programPageApp(t, programPage(ended, programTurns()), 80, 30) + done := strings.Join(programPageLines(b), "\n") + if strings.Contains(done, b.icon(tokens.GStepRunning)) { + t.Fatalf("an ended run's page draws a call in flight:\n%s", done) + } + if lines := programPageLines(b); lines[1] != "done · $1.24 · 3 calls · 13m 3s" { + t.Fatalf("an ended run's pinned line is %q, want its state, spend, calls and the age it ended at", lines[1]) + } +} + +// A REFUSED OR FAILED CALL IS ONE PLAIN LINE. codeaf's own refusal is codeaf's +// line, because no model saw the call; a model's failure is that model's line. +// And a program that summarized its own history says so in one line, rather than +// replaying it. +func TestARefusedFailedOrRestartedCallIsOnePlainLine(t *testing.T) { + turns := programTurns()[:2] + at := programRunBegan.Add(time.Minute) + turns = append(turns, + delegate.Turn{Seq: 3, Started: at, Ended: at, Model: "deepseek/deepseek-v4-flash", Failed: "upstream 503\nretry later"}, + delegate.Turn{Seq: 4, Started: at, Ended: at.Add(time.Second), Model: "deepseek/deepseek-v4-flash", Restarted: true, + Sent: []delegate.Said{{Role: "user", Text: "the whole history, summarized"}}, Reply: "Carrying on from the summary."}, + delegate.Turn{Seq: 5, Started: at, Model: "deepseek/deepseek-v4-flash", Refused: "the run's dollar ceiling is reached"}, + ) + a, _ := programPageApp(t, programPage(programRow(), turns), 80, 40) + lines := programPageLines(a) + page := strings.Join(lines, "\n") + for _, said := range []struct{ name, words string }{ + {"deepseek-v4-flash", convFailedWord + " · upstream 503"}, + {"senior-dev", convRestartedWord}, + {"codeaf", taskPlanRefusedWord + " · the run's dollar ceiling is reached"}, + } { + if !saidBy(lines, said.name, said.words) { + t.Fatalf("the page does not have %s saying %q:\n%s", said.name, said.words, page) + } + } + if strings.Contains(page, "retry later") || strings.Contains(page, "the whole history, summarized") { + t.Fatalf("a failure or a summarized history drew more than its one line:\n%s", page) + } +} + +// A LONG RUN'S PAGE SAYS HOW MANY CALLS IT LEAVES OUT, the ceiling beside the +// spend when the page knows it, and at forty columns every name stands on a line +// of its own with its words hung under it — every row still inside the frame. +func TestAProgramsPageAtNarrowWidthAndWithEarlierCalls(t *testing.T) { + page := programPage(programRow(), programTurns()) + page.Program.Earlier, page.Program.Calls, page.Program.CeilingUSD = 142, 145, 5 + a, _ := programPageApp(t, page, 40, 40) + lines := programPageLines(a) + text := strings.Join(lines, "\n") + if !strings.Contains(text, "142 "+convEarlierWord) { + t.Fatalf("the page does not say how many earlier calls it leaves out:\n%s", text) + } + if !strings.HasPrefix(lines[1], "implement · $1.24 of $5.00") { + t.Fatalf("the pinned line is %q, want the ceiling beside the spend", lines[1]) + } + if !strings.Contains(text, "\n senior-dev\n") || !strings.Contains(text, "\n rewrite the auth") { + t.Fatalf("at forty columns the names do not stand on lines of their own:\n%s", text) + } + for i, line := range lines { + if cells := ansi.StringWidth(line); cells > 40 { + t.Fatalf("row %d is %d cells in a 40-cell frame: %q", i, cells, line) + } + } +} + +// THE RAIL ROW OF A PROGRAM'S RUN SAYS ITS STAGE AND WHAT IT HAS SPENT SO FAR, +// where it used to say only its clock. Both come off the run's plan row the +// surface already holds, never off a read the frame makes. +func TestTheRailRowOfAProgramsRunSaysItsStageAndSpend(t *testing.T) { + row := programRow() + row.ID = "7" + a, fake := planAppWith(t, []session.PlanTaskRow{row}, nil) + counted := &railPlanCounter{planFake: fake} + a.agent = counted + a.width, a.height = 120, 30 + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, session.TaskRunning, session.TaskNotice{StartedAt: programRunBegan})}) + node := a.tasks[7] + if node == nil { + t.Fatal("the run's row never reached the rail") + } + reads := counted.rows + under := plain(strings.Join(a.railUnder(node, 40), "\n")) + if !strings.Contains(under, "implement") || !strings.Contains(under, "$1.24") { + t.Fatalf("the rail row of a program's run reads %q, want its stage and its spend", under) + } + if counted.rows != reads || counted.pages != 0 { + t.Fatalf("drawing the rail row read the agent: rows %d→%d, pages %d", reads, counted.rows, counted.pages) + } + // A RUN NO PROGRAM WAS HANDED IS UNCHANGED. + row.Program, row.Stage = "", "" + fake.plan[0] = row + a.planRows = fake.plan + plainUnder := plain(strings.Join(a.railUnder(node, 40), "\n")) + if strings.Contains(plainUnder, "implement") || strings.Contains(plainUnder, "$1.24") { + t.Fatalf("an ordinary run's rail row reads %q, which is a program's", plainUnder) + } +} + +// A PROGRAM'S PLAN ROW DRAWS ITS STAGE, NEVER A COMMAND. The worker publishes a +// program's phase on the live row a bash worker publishes its command on, and +// behind the shell's `$` it read as a command somebody typed. +func TestAProgramsPlanRowDrawsItsStageAndNotACommand(t *testing.T) { + text := planTextFor(t, []session.PlanTaskRow{programRow()}) + if strings.Contains(text, "$ senior-dev") || strings.Contains(text, "running · running") { + t.Fatalf("a program's plan row drew its stage as a command:\n%s", text) + } + if !strings.Contains(text, "implement") { + t.Fatalf("a program's plan row does not name its stage:\n%s", text) + } +} + +// A PROGRAM'S RUN'S TAB OFFERS NO BOX EITHER. The run's tab routes its keys to +// the page's own keyboard, so a box drawn there for a program would be a promise +// that every key typed into it breaks — before the page's read comes back as +// much as after, because the row it opens on already names the program. +func TestAProgramsWorkTabOffersNoNoteBox(t *testing.T) { + row := programRow() + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) + a.width, a.height = 120, 28 + a.taskSheet.mine.plan = fake.plan + if cmd := a.openWorkTab(); cmd == nil { + t.Fatal("the run's tab did not open") + } else { + if text := plain(strings.Join(a.workTabFrame(a.width, a.height), "\n")); strings.Contains(text, taskPlanNoteWord) { + t.Fatalf("the program's tab offers a box before its page is read:\n%s", text) + } + drive(t, a, cmd()) + } + if text := plain(strings.Join(a.workTabFrame(a.width, a.height), "\n")); strings.Contains(text, taskPlanNoteWord) { + t.Fatalf("the program's tab offers a box:\n%s", text) + } + for _, r := range "a note" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(fake.noted) != 0 || !a.taskSheet.planNote.empty() { + t.Fatalf("typing on a program's tab wrote notes %v, box %q", fake.noted, a.taskSheet.planNote.String()) + } +} + +// A PROGRAM'S WORDS ARE ONE CLEAN ROW. What a program sends is a tool's raw +// output: the first line that says anything is drawn, with every escape +// sequence a terminal would obey taken out, a tab as a space, and no other +// control character left in it. +func TestAProgramsWordsAreOneCleanRow(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"\n\n \x1b[31mFAIL\x1b[0m\tpkg\x07 0.3s\nmore", "FAIL pkg 0.3s"}, + {"\x1b]0;a title\x07ok", "ok"}, + {"\r\n\r\nplain\r\n", "plain"}, + {"", ""}, + } { + if got := convHead(tc.in); got != tc.want { + t.Errorf("convHead(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// THE ARGUMENTS SAY WHAT A CALL WAS ABOUT, read forgivingly: the most telling +// argument, then the first string there is, a line cut inside a string read to +// its cut, and a line that is not an object drawn as written. +func TestACallsArgumentsAreReadForWhatTheCallWasAbout(t *testing.T) { + for _, tc := range []struct{ args, want string }{ + {`{"command":"go test ./...","description":"Runs tests"}`, "go test ./..."}, + {`{"description":"Runs tests","command":"go vet ./..."}`, "go vet ./..."}, + {`{"filePath":"internal/auth/middleware.go","oldString":"func M`, "internal/auth/middleware.go"}, + {`{"oldString":"a \"quoted\" line\nand more`, `a "quoted" line and more`}, + {`{"limit":5,"name":"x"}`, "x"}, + {`{"limit":5}`, ""}, + {`go test ./...`, "go test ./..."}, + {`{"url":"https://example.com/a:b"}`, "https://example.com/a:b"}, + } { + if got := convCallAbout(tc.args); got != tc.want { + t.Errorf("convCallAbout(%s) = %q, want %q", tc.args, got, tc.want) + } + } +} diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 80e79dd28..12e408d1d 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -681,7 +681,7 @@ func planRailLive(line tasksLine, width int, pal palette) string { if room < 1 { return "" } - if live := planLiveRow(line.item.plan.Live.Command, line.item.plan.LiveParts, room, pal); live != "" { + if live := planLiveLine(*line.item.plan, room, pal); live != "" { return lead + live } return "" @@ -1186,11 +1186,31 @@ func planEnded(row session.PlanTaskRow) bool { // than a chat turn. func (a *app) taskPlanKey(msg tea.KeyPressMsg) tea.Cmd { key := msg.String() + // A PROGRAM'S PAGE IS READ, NEVER TYPED INTO. It has no box, so it takes the + // reading keys and the way back below exactly as every page takes them, `x` + // for the stop its run's own task has, and `enter` only into a part it + // lists; every other key is nothing, rather than a note no program reads or + // a letter aimed at a box that is not there. + if a.taskPlanIsProgram() { + switch key { + case stopRaiseKey: + return a.taskPlanStop(a.taskSheet.plan.Row) + case "enter": + if a.taskSheet.planAt >= 0 && a.taskSheet.planAt < len(a.taskSheet.plan.Children) { + old := a.taskSheet.plan + return a.taskSheetPlanFrom(old.Children[a.taskSheet.planAt].ID, &old) + } + return nil + case "esc", "left", taskSheetKey, "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown", "ctrl+o": + default: + return nil + } + } // The caret's own chords first, the route every box on this surface takes // (place_tasks.go's filter, the conversation's composer). - if editorMotion(&a.taskSheet.planNote, key) || + if !a.taskPlanIsProgram() && (editorMotion(&a.taskSheet.planNote, key) || editorUndo(&a.taskSheet.planNote, key) || - editorWordKill(&a.taskSheet.planNote, key) { + editorWordKill(&a.taskSheet.planNote, key)) { a.touch() return nil } @@ -1244,7 +1264,7 @@ func (a *app) taskPlanKey(msg tea.KeyPressMsg) tea.Cmd { a.taskPlanScroll(taskSheetRows) return nil case "ctrl+o": - if len(planBriefRows(a.taskSheet.plan.Description, a.bodyWidth())) > briefFoldLines { + if a.taskPlanBriefFolds() { a.taskSheet.planBriefFull = !a.taskSheet.planBriefFull a.taskSheet.detailTop = 0 a.taskSheet.planStick = false @@ -1277,9 +1297,37 @@ func (a *app) taskPlanKey(msg tea.KeyPressMsg) tea.Cmd { return nil } -// taskPlanHead is what the page spends above its body: the task's title, the -// air under it and the rule — three rows, the card's own head. -const taskPlanHead = 3 +// taskPlanBriefFolds reports whether the open page's brief is long enough for +// `ctrl+o` to fold: measured at the width a program's conversation draws it at +// on a program's page ([app.taskConversationFolds]), and at the body's width on +// every other. +func (a *app) taskPlanBriefFolds() bool { + if a.taskPlanIsProgram() { + return a.taskConversationFolds() + } + return len(planBriefRows(a.taskSheet.plan.Description, a.bodyWidth())) > briefFoldLines +} + +// taskPlanHeadRows is what the page spends above its body: the task's title, +// the line under it, and the rule — the card's own head. It is drawn and +// counted by this one function, so the frame, the window and the scroll cannot +// disagree about where the body starts. +// +// ON A PROGRAM'S PAGE THE LINE UNDER THE TITLE IS PINNED: where the program is, +// what it has spent, how many calls it has made and how long it has been going +// ([app.taskPlanPinned]). The page opens stuck to its bottom edge and follows +// the conversation down, so a figure drawn as the body's first line — where +// every other page draws its telemetry — is a figure that scrolls away the +// moment there is more than a screen of it. On every other page, and on a +// program's page with nothing yet to say, that line is the air it always was. +func (a *app) taskPlanHeadRows(width int) []string { + pal := a.pal + under := "" + if pinned := a.taskPlanPinned(a.taskSheet.plan, width); pinned != "" { + under = pal.dim(pinned) + } + return []string{fit(pal.bold(pal.ink(a.taskSheet.plan.Row.Title)), width), under, pal.dim(rule(width))} +} // taskPlanFoot is what the page spends under its body: the closing rule, the // note composer, the one sentence saying when a note is read, and the key line, @@ -1287,22 +1335,39 @@ const taskPlanHead = 3 // into and the sentence that says what happens to what is typed in it. const taskPlanFoot = 4 -// taskPlanWindow is the page's body, the rows it is drawn in and the rows its -// foot spends, resolved from the frame once: the draw and the scroll both read -// the bottom off this, so the two cannot disagree about where the bottom is. -func (a *app) taskPlanWindow(width, height int) ([]string, int, int) { +// taskPlanProgramFoot is a program's page's foot: the closing rule and the key +// line, and no box. A program reads no note — nothing a person types on its +// page would ever reach it — so the box, and the sentence promising that a +// worker reads a note at its next step, are absent there rather than false. +const taskPlanProgramFoot = 2 + +// taskPlanFootRows is how many rows the open page spends under its body. +func (a *app) taskPlanFootRows() int { + if a.taskPlanIsProgram() { + return taskPlanProgramFoot + } + return taskPlanFoot +} + +// taskPlanWindow is the page's head, its body, the rows the body is drawn in and +// the rows its foot spends, resolved from the frame once: the draw and the scroll +// both read the bottom off this, so the two cannot disagree about where the +// bottom is — and the head is counted here, never assumed, so a pinned line is +// a row the body gives up rather than a row drawn over it. +func (a *app) taskPlanWindow(width, height int) ([]string, []string, int, int) { if height < 1 { height = 1 } - foot := taskPlanFoot - if height-taskPlanHead-foot < 1 { + head := a.taskPlanHeadRows(width) + foot := a.taskPlanFootRows() + if height-len(head)-foot < 1 { foot = 0 } - room := height - taskPlanHead - foot + room := height - len(head) - foot if room < 1 { room = 1 } - return a.taskPlanBody(width - 2), room, foot + return head, a.taskPlanBody(width - 2), room, foot } // taskPlanTopFor resolves the page's scroll position, sticking to the live edge @@ -1331,7 +1396,7 @@ func (a *app) taskPlanTopFor(count, room int) int { // resumes following without pressing anything. func (a *app) taskPlanScroll(delta int) { width, height := a.size() - body, room, _ := a.taskPlanWindow(width, height) + _, body, room, _ := a.taskPlanWindow(width, height) bottom := len(body) - room if bottom < 0 { bottom = 0 @@ -1353,21 +1418,31 @@ func (a *app) taskPlanScroll(delta int) { // frame, one rule, one foot — so the two pages of this place read as one. The // one thing the card has not got and this page has is the note composer: the box // a person types into, in the foot, under the rule. +// +// A PROGRAM'S PAGE HAS NO BOX, so it has no caret either: its foot is the rule +// and the keys ([taskPlanProgramFoot]), and a blinking bar over nothing a person +// can type into is a cursor pointing at a key that does not exist — the card's +// own law (place_sessions.go's ownFrame hides it for the same reason). func (a *app) taskPlanFrame(width, height int) ([]string, int, int) { pal := a.pal if height < 1 { height = 1 } + program := a.taskPlanIsProgram() + if program { + a.caret = false + } lines := make([]string, 0, height) add := func(text string) { lines = append(lines, text) } - add(fit(pal.bold(pal.ink(a.taskSheet.plan.Row.Title)), width)) - add("") - add(pal.dim(rule(width))) - // THE FOOT IS THE LAST FOUR ROWS, and a frame too short for the body under - // it gives the body up rather than the way out — the page's own trim, and the - // one [app.taskPlanWindow] resolves so the draw and the scroll agree ([app.taskPlanScroll]). - body, room, foot := a.taskPlanWindow(width, height) + // THE HEAD AND THE FOOT ARE THE PAGE'S OWN ROWS, and a frame too short for + // the body between them gives the body up rather than the way out — the + // page's own trim, and the one [app.taskPlanWindow] resolves so the draw and + // the scroll agree ([app.taskPlanScroll]). + head, body, room, foot := a.taskPlanWindow(width, height) + for _, row := range head { + add(row) + } // THE PAGE RESOLVES ITS OFFSET, it does not hold it: a stuck page reads the // bottom where the body now is, so a step appended between frames is on // screen at the next draw ([app.taskPlanTopFor]). @@ -1392,24 +1467,26 @@ func (a *app) taskPlanFrame(width, height int) ([]string, int, int) { legend = append(legend, " "+pal.dim(a.pageMsg)) } add(placeNoteRule(legend, width, pal)) - if a.taskSheet.planNote.empty() { - add(" " + pal.dim(fit(prompt+taskPlanNoteWord, width-1))) - } else { - add(" " + fit(prompt+a.taskSheet.planNote.String(), width-1)) - } - caretX, caretY = ansi.StringWidth(prompt)+1, len(lines)-1 - // AND WHEN THE WORKER READS IT, under the box that writes it: the worker - // is a separate loop, so a note waits in the store until it asks for its - // next step — the one thing a person needs to know about the box they are - // typing into (taskPlanPickupWord). - // - // A TASK THAT HAS ENDED TAKES NO NEXT STEP, so the sentence is absent - // there rather than false. Its row stays, empty, because the foot's - // height is fixed and the caret is placed against it. - if planEnded(a.taskSheet.plan.Row) { - add("") - } else { - add(" " + pal.dim(fit(taskPlanPickupWord, width-1))) + if !program { + if a.taskSheet.planNote.empty() { + add(" " + pal.dim(fit(prompt+taskPlanNoteWord, width-1))) + } else { + add(" " + fit(prompt+a.taskSheet.planNote.String(), width-1)) + } + caretX, caretY = ansi.StringWidth(prompt)+1, len(lines)-1 + // AND WHEN THE WORKER READS IT, under the box that writes it: the + // worker is a separate loop, so a note waits in the store until it + // asks for its next step — the one thing a person needs to know about + // the box they are typing into (taskPlanPickupWord). + // + // A TASK THAT HAS ENDED TAKES NO NEXT STEP, so the sentence is absent + // there rather than false. Its row stays, empty, because the foot's + // height is fixed and the caret is placed against it. + if planEnded(a.taskSheet.plan.Row) { + add("") + } else { + add(" " + pal.dim(fit(taskPlanPickupWord, width-1))) + } } add(" " + paintHint(hintFit(a.taskPlanKeys(), width-2), pal, pal.dim)) } @@ -1427,7 +1504,11 @@ func (a *app) taskPlanFrame(width, height int) ([]string, int, int) { // by [hintFit], so `esc back` is kept last and the clause a narrow frame drops // first is the scroll. func (a *app) taskPlanKeys() string { - parts := []string{"↑↓ scroll", "enter send"} + parts := []string{"↑↓ scroll"} + // A PROGRAM'S PAGE SENDS NOTHING, so its key line offers no send. + if !a.taskPlanIsProgram() { + parts = append(parts, "enter send") + } parts = append(parts, a.tasksPlanKeyWords(a.taskSheet.plan.Row)...) parts = append(parts, taskCardBackWord) return strings.Join(parts, railSep) @@ -1442,6 +1523,11 @@ func (a *app) taskPlanBody(width int) []string { if width < 1 { width = 1 } + // A PROGRAM'S PAGE IS ITS CONVERSATION, drawn where every other page draws + // its steps (taskconversation.go). + if a.taskPlanIsProgram() { + return a.taskProgramBody(width) + } page, pal := a.taskSheet.plan, a.pal var out []string add := func(text string) { out = append(out, text) } @@ -1518,29 +1604,7 @@ func (a *app) taskPlanBody(width int) []string { } if len(page.Notes) > 0 { section("notes") - for _, note := range page.Notes { - // AN AUTHOR IS DRAWN ONLY AS A WORD A PERSON WOULD RECOGNISE. `you` is - // one. Every other author the store holds is an id of its own, the - // run's number or a worker's handle, and this page has no word for the - // kind of task that left the note; a page headed `1 · now` or - // `2ytmh2 · now` names nobody. The moment is kept and the id is never - // drawn, which is the owner's ruling on this surface: no internal name - // on a person's screen. - who := "" - if note.Person { - who = "you" - } - when := sinceAt(note.At, a.now()) - switch { - case who != "" && when != "": - add(pal.dim(who + railSep + when)) - case who != "": - add(pal.dim(who)) - case when != "": - add(pal.dim(when)) - } - addWrapped(note.Body, pal.ink) - } + out = append(out, a.taskPlanNoteRows(page.Notes, width)...) } if len(page.Steps) > 0 || !page.Live.Empty() { section("steps") @@ -1637,6 +1701,43 @@ func (a *app) taskPlanBody(width int) []string { return out } +// taskPlanNoteRows is every note on a task as the page draws them under its +// `notes` heading: each one's author and moment on a dim line, then its words. +func (a *app) taskPlanNoteRows(notes []session.PlanTaskNote, width int) []string { + pal := a.pal + var out []string + for _, note := range notes { + // AN AUTHOR IS DRAWN ONLY AS A WORD A PERSON WOULD RECOGNISE. `you` is + // one. Every other author the store holds is an id of its own, the run's + // number or a worker's handle, and this page has no word for the kind of + // task that left the note; a page headed `1 · now` or `2ytmh2 · now` names + // nobody. The moment is kept and the id is never drawn, which is the + // owner's ruling on this surface: no internal name on a person's screen. + who := "" + if note.Person { + who = "you" + } + when := sinceAt(note.At, a.now()) + switch { + case who != "" && when != "": + out = append(out, pal.dim(who+railSep+when)) + case who != "": + out = append(out, pal.dim(who)) + case when != "": + out = append(out, pal.dim(when)) + } + for _, para := range strings.Split(note.Body, "\n") { + if strings.TrimSpace(para) == "" { + continue + } + for _, line := range wrap(strings.TrimSpace(para), width) { + out = append(out, pal.ink(line)) + } + } + } + return out +} + func planBriefLines(text string, width int) []string { var lines []string for _, para := range strings.Split(text, "\n") { diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 89ac2b7a2..5c8265938 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -119,6 +119,12 @@ func (a *app) workTabFrame(width, height int) []string { } out = append(out, a.pal.dim(who+railSep)+a.pal.ink(note.Body)) } + // A PROGRAM'S RUN TAKES NO NOTE, so its tab offers no box: nothing typed + // there would reach the program, and the keys it would have typed are the + // page's reading keys and nothing else ([app.taskPlanKey]). + if a.taskPlanIsProgram() { + return out + } text := a.taskSheet.planNote.String() if strings.TrimSpace(text) == "" { text = taskPlanNoteWord From aede274e18e48b79f8f255b99f41b268746013ae Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:13:05 -0400 Subject: [PATCH 022/195] tui3: the manual says the program's record keeps more of each call, not all of it What was true: the new section on a program's task page said the whole of every call is kept in the task's own record. The conversation log keeps each message cut at a cap and the newest messages of a long call, so that claimed more than the record holds. What is true now: the section says the task's own record keeps more of every call than the page draws, which is what the log does. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/worker-harness.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 2f0e98dff..40fc28cc9 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -214,7 +214,7 @@ it asked for behind that tool's mark. A call codeaf refused is one line from `co line, `◐`, the model and its seconds, gone when the call returns. 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 whole of every call is kept in the task's own record. +line such as `…142 earlier calls`; the task's own record keeps more of every call. The page has no note box: a program reads no note, so nothing typed there would reach it. While the run goes, `x` stops it. On the side list the run's row says the stage and the spend so far. From 0187ca473ba4b23d95393cb8d36fe365966201e0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:14:58 -0400 Subject: [PATCH 023/195] delegate: the program record carries the run's ceiling, so the page can show it What was true: a program's task page had a place for the run's dollar ceiling beside its spend, but nothing wrote the ceiling down where a page could read it after the run started, so the page never drew it. What is true now: delegate.ProgramRecord carries CeilingUSD, written with the hello, and the page reads it into PlanProgram.CeilingUSD; zero still draws nothing. Co-Authored-By: Claude Opus 5.5 --- internal/delegate/conversation.go | 5 +++++ internal/session/plandb_program.go | 11 +++++------ internal/session/plandb_program_test.go | 5 ++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go index 251d62310..354a4b5b9 100644 --- a/internal/delegate/conversation.go +++ b/internal/delegate/conversation.go @@ -36,6 +36,11 @@ const ProgramFile = "delegate-program.json" 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"` } // WriteProgram writes the record, whole, making the folder when it is not diff --git a/internal/session/plandb_program.go b/internal/session/plandb_program.go index 2387586b6..ffc58e6bb 100644 --- a/internal/session/plandb_program.go +++ b/internal/session/plandb_program.go @@ -66,11 +66,10 @@ type PlanProgram struct { // whole log and not over Turns, so it stays the run's own figure however // long the run has gone. Calls int - // CeilingUSD is the dollar ceiling the run handed the program, when the page - // knows it, and zero when it does not — which today is always: the ceiling - // is worked out when the run starts (task_run_belt.go's beltRunSpec) and is - // written down nowhere a page can read it afterwards. The page draws it - // beside the spend the day a record carries it, and nothing before. + // CeilingUSD is the dollar ceiling the run handed the program, off the + // program record the worker writes at the hello (delegate.ProgramRecord), + // and zero when the run set none or the program has not said hello yet — + // which the page draws as no ceiling at all rather than as $0.00. CeilingUSD float64 } @@ -162,7 +161,7 @@ func planProgramPage(dir, id, carried string, copies planRunCopies) *PlanProgram if !known && len(all) == 0 { return nil } - program := &PlanProgram{Name: record.Name} + program := &PlanProgram{Name: record.Name, CeilingUSD: record.CeilingUSD} if len(record.Stages) > 0 { program.Stages = append([]string(nil), record.Stages...) } diff --git a/internal/session/plandb_program_test.go b/internal/session/plandb_program_test.go index 2bef12c7e..bf9bfe5c6 100644 --- a/internal/session/plandb_program_test.go +++ b/internal/session/plandb_program_test.go @@ -29,7 +29,7 @@ func programPageFixture(t *testing.T) (*Agent, string) { path := filepath.Join(dir, planStoreFilename) seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha", Description: "rewrite the auth middleware"}) folder := plandb.TaskDir(dir, "alpha") - if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev", Stages: []string{"intake", "implement", "verification"}}); err != nil { + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev", Stages: []string{"intake", "implement", "verification"}, CeilingUSD: 5}); err != nil { t.Fatalf("write the program record: %v", err) } began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) @@ -97,6 +97,9 @@ func TestAProgramsPageCarriesItsConversationStageAndLiveStep(t *testing.T) { if last := program.Turns[2]; !last.InFlight() || len(last.Sent) != 1 || last.Sent[0].Tool != "read" { t.Fatalf("the call in flight reads %+v, want it open with what the program sent", last) } + if program.CeilingUSD != 5 { + t.Fatalf("ceiling = %v, want the 5 the program record carries", program.CeilingUSD) + } if program.Calls != 2 || program.Earlier != 0 { t.Fatalf("calls = %d, earlier = %d; want 2 calls that reached a model and nothing earlier", program.Calls, program.Earlier) } From 63e725943b64a30c2b404dfbdd4126599c3ff8d8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:44:08 -0400 Subject: [PATCH 024/195] seniordev: senior-dev's engine is copied in as it was, before it is adapted senior-dev lived in its own repository (swe-pro-go at the tag codeaf-absorb, 6103488) and ran as its own binary with its own key. Its packages are now under internal/seniordev at the same sub-paths, and the product half of its command (the pipeline, the solo run, verification, the workspace recorders, the engine adapters, the durable sessions and their tests) is the package internal/seniordev/app. This commit is the copy and only the copy, so the adaptation that follows can be read as a diff: the module path is rewritten, `package main` is `package app`, and every Go file carries a !windows constraint (the two stat files keep their darwin and linux ones). Left out entirely: internal/afield (the control plane), internal/attribution (OpenRouter's attribution headers), the tree-sitter marker file, and the command's CLI glue (main, args, the parser, cli_run, serve, cpbridge, trace) and their tests. It does not build on its own yet; the next commit makes it. Co-Authored-By: Claude Opus 5.5 --- internal/seniordev/app/budget_helpers.go | 13 + internal/seniordev/app/catalog_test.go | 75 + internal/seniordev/app/compaction_events.go | 29 + internal/seniordev/app/compaction_pin.go | 192 +++ internal/seniordev/app/compaction_pin_test.go | 162 ++ .../seniordev/app/compaction_policy_test.go | 106 ++ internal/seniordev/app/config.go | 459 ++++++ internal/seniordev/app/config_live_test.go | 210 +++ internal/seniordev/app/durable_sessions.go | 1012 ++++++++++++ .../seniordev/app/durable_sessions_test.go | 808 ++++++++++ internal/seniordev/app/engine_backend.go | 427 ++++++ internal/seniordev/app/engine_client.go | 516 +++++++ internal/seniordev/app/engine_compaction.go | 236 +++ .../seniordev/app/engine_compaction_test.go | 239 +++ .../seniordev/app/engine_contract_test.go | 268 ++++ internal/seniordev/app/engine_prompt_test.go | 233 +++ internal/seniordev/app/engine_router.go | 42 + internal/seniordev/app/engine_store.go | 103 ++ internal/seniordev/app/events.go | 156 ++ .../seniordev/app/events_agent_summary.go | 250 +++ .../app/events_agent_summary_test.go | 276 ++++ .../seniordev/app/events_contract_test.go | 147 ++ internal/seniordev/app/full_verification.go | 76 + .../seniordev/app/full_verification_run.go | 344 +++++ .../seniordev/app/full_verification_test.go | 180 +++ internal/seniordev/app/gitrepo_test.go | 57 + internal/seniordev/app/ignore.go | 197 +++ .../seniordev/app/model_request_events.go | 236 +++ .../app/model_request_events_test.go | 304 ++++ .../app/netpolicy_visibility_test.go | 19 + internal/seniordev/app/patch_contract.go | 24 + internal/seniordev/app/patch_contract_test.go | 38 + internal/seniordev/app/pipeline.go | 425 ++++++ internal/seniordev/app/pipeline_run.go | 62 + internal/seniordev/app/pipeline_smoke_test.go | 340 +++++ internal/seniordev/app/prompt_in_place.go | 108 ++ .../seniordev/app/question_autoreject_test.go | 58 + .../seniordev/app/router_cancellation_test.go | 40 + .../seniordev/app/run_error_classify_test.go | 45 + internal/seniordev/app/runtime.go | 572 +++++++ .../seniordev/app/runtime_compaction_test.go | 677 ++++++++ internal/seniordev/app/runtime_retry_test.go | 260 ++++ internal/seniordev/app/runtime_test.go | 122 ++ internal/seniordev/app/solo.go | 771 ++++++++++ internal/seniordev/app/solo_finalize.go | 236 +++ internal/seniordev/app/solo_prompt.go | 161 ++ internal/seniordev/app/solo_ship.go | 219 +++ internal/seniordev/app/solo_test.go | 978 ++++++++++++ internal/seniordev/app/statctime_darwin.go | 13 + internal/seniordev/app/statctime_linux.go | 13 + internal/seniordev/app/step_records.go | 119 ++ internal/seniordev/app/step_records_test.go | 142 ++ internal/seniordev/app/testsupport_test.go | 213 +++ internal/seniordev/app/tier_test.go | 176 +++ .../seniordev/app/verification_deadtree.go | 107 ++ .../app/verification_deadtree_test.go | 95 ++ .../app/verification_timeout_test.go | 171 +++ internal/seniordev/app/workspace_git.go | 52 + internal/seniordev/app/workspace_recorder.go | 88 ++ .../seniordev/app/workspace_recorder_git.go | 344 +++++ .../app/workspace_recorder_snapshot.go | 494 ++++++ .../seniordev/app/workspace_recorder_test.go | 365 +++++ .../seniordev/app/worktree_fingerprint.go | 225 +++ internal/seniordev/baked/agents/coder.md | 71 + internal/seniordev/baked/registry.go | 105 ++ internal/seniordev/baked/tier.go | 70 + internal/seniordev/baked/tier_test.go | 79 + internal/seniordev/bus/bus.go | 296 ++++ internal/seniordev/bus/bus_test.go | 136 ++ internal/seniordev/bus/event.go | 66 + internal/seniordev/config/config.go | 584 +++++++ internal/seniordev/config/env.go | 153 ++ internal/seniordev/config/helpers.go | 87 ++ internal/seniordev/config/loader_test.go | 43 + internal/seniordev/config/orderedjson.go | 65 + internal/seniordev/core/filesystem.go | 502 ++++++ internal/seniordev/core/filesystem_test.go | 102 ++ internal/seniordev/core/npm.go | 402 +++++ internal/seniordev/core/npm_test.go | 137 ++ internal/seniordev/core/spawner.go | 701 +++++++++ internal/seniordev/core/spawner_test.go | 165 ++ internal/seniordev/engine/calc/calc.go | 69 + internal/seniordev/engine/calc/cost_test.go | 100 ++ internal/seniordev/engine/calc/overflow.go | 250 +++ .../seniordev/engine/calc/overflow_test.go | 205 +++ internal/seniordev/engine/calc/usage.go | 400 +++++ .../engine/msgmodel/convertmodelmessages.go | 390 +++++ internal/seniordev/engine/msgmodel/cursor.go | 64 + internal/seniordev/engine/msgmodel/events.go | 47 + internal/seniordev/engine/msgmodel/filter.go | 135 ++ .../seniordev/engine/msgmodel/filter_test.go | 114 ++ .../seniordev/engine/msgmodel/fromerror.go | 203 +++ .../msgmodel/fromerror_openrouter502_test.go | 91 ++ internal/seniordev/engine/msgmodel/message.go | 246 +++ .../seniordev/engine/msgmodel/msgmodel.go | 172 +++ .../engine/msgmodel/msgmodel_test.go | 410 +++++ .../engine/msgmodel/openrouter_inband.go | 58 + internal/seniordev/engine/msgmodel/parts.go | 243 +++ .../seniordev/engine/msgmodel/rawobject.go | 260 ++++ internal/seniordev/engine/msgmodel/settle.go | 124 ++ internal/seniordev/engine/msgmodel/storage.go | 160 ++ .../seniordev/engine/msgmodel/storage_test.go | 199 +++ .../engine/msgmodel/tomodelmessages.go | 485 ++++++ .../seniordev/engine/msgmodel/toolstate.go | 189 +++ .../seniordev/engine/msgmodel/uimessage.go | 173 +++ .../seniordev/engine/msgmodel/usertext.go | 18 + internal/seniordev/engine/orclient/body.go | 332 ++++ .../engine/orclient/cancellation_test.go | 148 ++ internal/seniordev/engine/orclient/client.go | 700 +++++++++ .../seniordev/engine/orclient/client_test.go | 1135 ++++++++++++++ internal/seniordev/engine/orclient/convert.go | 766 ++++++++++ .../orclient/convert_tool_result_test.go | 344 +++++ .../orclient/convert_user_content_test.go | 108 ++ .../seniordev/engine/orclient/helpers_test.go | 40 + internal/seniordev/engine/orclient/jsonval.go | 483 ++++++ .../seniordev/engine/orclient/lowercase.go | 74 + .../seniordev/engine/orclient/orclient.go | 91 ++ .../engine/orclient/orclient_test.go | 516 +++++++ internal/seniordev/engine/orclient/parts.go | 565 +++++++ .../seniordev/engine/orclient/reasoning.go | 330 ++++ internal/seniordev/engine/orclient/routing.go | 331 ++++ .../seniordev/engine/orclient/routing_test.go | 108 ++ .../engine/orclient/sampling_body_test.go | 56 + internal/seniordev/engine/orclient/sse.go | 161 ++ internal/seniordev/engine/orclient/stream.go | 601 ++++++++ .../seniordev/engine/orclient/toolcall.go | 440 ++++++ .../seniordev/engine/orclient/transform.go | 435 ++++++ internal/seniordev/engine/orclient/wire.go | 943 ++++++++++++ .../engine/retrysched/contextoverflow.go | 70 + .../engine/retrysched/contextoverflow_test.go | 37 + .../seniordev/engine/retrysched/errors.go | 127 ++ .../engine/retrysched/errors_test.go | 60 + .../seniordev/engine/retrysched/retrysched.go | 41 + .../engine/retrysched/retrysched_test.go | 60 + .../engine/steploop/compacted_after_test.go | 49 + internal/seniordev/engine/steploop/doc.go | 32 + internal/seniordev/engine/steploop/helpers.go | 164 ++ .../seniordev/engine/steploop/helpers_test.go | 20 + .../engine/steploop/invalid_tool_test.go | 118 ++ internal/seniordev/engine/steploop/loop.go | 232 +++ .../seniordev/engine/steploop/processor.go | 779 ++++++++++ .../seniordev/engine/steploop/reminders.go | 91 ++ .../engine/steploop/steploop_test.go | 763 ++++++++++ internal/seniordev/engine/steploop/types.go | 208 +++ internal/seniordev/format/format.go | 256 ++++ internal/seniordev/format/format_test.go | 261 ++++ internal/seniordev/format/formatter.go | 300 ++++ internal/seniordev/id/id.go | 197 +++ internal/seniordev/id/id_test.go | 42 + internal/seniordev/jsonutil/jsonutil.go | 35 + internal/seniordev/modelsdev/models.go | 444 ++++++ internal/seniordev/modelsdev/models_test.go | 297 ++++ .../seniordev/modelsdev/testdata/catalog.json | 53 + internal/seniordev/netpolicy/blackhole.go | 128 ++ .../seniordev/netpolicy/blackhole_test.go | 79 + internal/seniordev/netpolicy/netpolicy.go | 168 ++ .../seniordev/netpolicy/netpolicy_test.go | 100 ++ .../seniordev/patch/diff_contract_test.go | 46 + internal/seniordev/patch/io_test.go | 72 + internal/seniordev/patch/patch.go | 1038 +++++++++++++ internal/seniordev/permission/permission.go | 619 ++++++++ .../seniordev/permission/permission_test.go | 75 + internal/seniordev/project/context.go | 114 ++ internal/seniordev/project/store.go | 270 ++++ internal/seniordev/project/store_test.go | 57 + internal/seniordev/question/question.go | 254 +++ internal/seniordev/question/question_test.go | 264 ++++ internal/seniordev/question/schema.go | 281 ++++ .../seniordev/router/adaptive/adaptive.go | 1111 ++++++++++++++ .../router/adaptive/adaptive_contract_test.go | 181 +++ .../router/adaptive/adaptive_test.go | 107 ++ .../router/adaptive/cancellation_test.go | 53 + .../seniordev/router/adaptive/tier_test.go | 130 ++ .../seniordev/router/state/adaptivewire.go | 82 + .../router/state/adaptivewire_test.go | 141 ++ internal/seniordev/router/state/state.go | 198 +++ internal/seniordev/router/state/state_test.go | 245 +++ .../session/compaction/additive_test.go | 376 +++++ .../session/compaction/controller.go | 70 + .../session/compaction/controller_test.go | 54 + internal/seniordev/session/compaction/core.go | 424 ++++++ .../seniordev/session/compaction/core_test.go | 278 ++++ .../seniordev/session/compaction/evidence.go | 24 + .../seniordev/session/compaction/service.go | 1356 +++++++++++++++++ .../session/compaction/service_test.go | 957 ++++++++++++ .../seniordev/session/compaction/surgery.go | 124 ++ internal/seniordev/session/compaction/tail.go | 198 +++ .../seniordev/session/compaction/tail_test.go | 180 +++ .../session/compaction/transcript.go | 123 ++ .../session/compaction/transcript_test.go | 138 ++ .../evidenceharvest/evidenceharvest.go | 423 +++++ .../evidenceharvest/evidenceharvest_test.go | 85 ++ .../session/fullverification/discovery.go | 884 +++++++++++ .../fullverification/discovery_test.go | 598 ++++++++ .../fullverification/noop_evidence_test.go | 44 + .../script_body_discovery_test.go | 141 ++ .../session/instruction/instruction.go | 500 ++++++ .../session/llmcall/cancellation_test.go | 74 + internal/seniordev/session/llmcall/llmcall.go | 312 ++++ .../seniordev/session/llmcall/llmcall_test.go | 107 ++ .../seniordev/session/llmcall/tier_test.go | 78 + .../seniordev/session/loopguard/loopguard.go | 338 ++++ .../session/loopguard/loopguard_test.go | 231 +++ .../session/outputoffload/outputoffload.go | 347 +++++ .../seniordev/session/overflow/overflow.go | 37 + .../session/projectors/busy_retry.go | 165 ++ .../session/projectors/busy_retry_test.go | 144 ++ .../seniordev/session/projectors/database.go | 60 + .../session/projectors/database_test.go | 124 ++ .../session/projectors/projectors.go | 583 +++++++ .../session/projectors/projectors_test.go | 76 + .../seniordev/session/projectors/schema.go | 115 ++ .../session/projectors/schema_test.go | 157 ++ .../seniordev/session/runbudget/runbudget.go | 151 ++ .../session/sessioncore/sessioncore.go | 512 +++++++ .../session/sessioncore/sessioncore_test.go | 189 +++ internal/seniordev/session/system/system.go | 81 + internal/seniordev/storage/storage.go | 524 +++++++ internal/seniordev/storage/storage_test.go | 271 ++++ internal/seniordev/tool/apply_patch.go | 307 ++++ internal/seniordev/tool/apply_patch.txt | 33 + .../seniordev/tool/apply_patch_description.go | 8 + internal/seniordev/tool/apply_patch_test.go | 101 ++ internal/seniordev/tool/bash.go | 308 ++++ internal/seniordev/tool/bash_clock_test.go | 32 + internal/seniordev/tool/descriptions.go | 38 + internal/seniordev/tool/edit.go | 869 +++++++++++ internal/seniordev/tool/edit_test.go | 90 ++ internal/seniordev/tool/glob.go | 138 ++ internal/seniordev/tool/glob_description.go | 10 + internal/seniordev/tool/glob_test.go | 125 ++ internal/seniordev/tool/grep.go | 219 +++ internal/seniordev/tool/grep_description.go | 11 + internal/seniordev/tool/grep_test.go | 130 ++ .../seniordev/tool/instance_context_test.go | 124 ++ .../seniordev/tool/mutation_feedback_test.go | 82 + .../seniordev/tool/netpolicy_gate_test.go | 109 ++ internal/seniordev/tool/path.go | 71 + internal/seniordev/tool/question.go | 161 ++ internal/seniordev/tool/question.txt | 10 + .../seniordev/tool/question_description.go | 8 + internal/seniordev/tool/question_test.go | 264 ++++ internal/seniordev/tool/read.go | 493 ++++++ internal/seniordev/tool/read_test.go | 302 ++++ internal/seniordev/tool/registry.go | 609 ++++++++ .../seniordev/tool/registry_policy_test.go | 207 +++ internal/seniordev/tool/registry_worktree.go | 16 + internal/seniordev/tool/ripgrep.go | 92 ++ internal/seniordev/tool/ripgrep_fallback.go | 485 ++++++ .../tool/ripgrep_fallback_equivalence_test.go | 176 +++ .../seniordev/tool/ripgrep_fallback_test.go | 307 ++++ internal/seniordev/tool/settings.go | 148 ++ internal/seniordev/tool/shell_env_signal.go | 158 ++ .../seniordev/tool/shell_feedback_test.go | 229 +++ internal/seniordev/tool/shell_scan.go | 547 +++++++ internal/seniordev/tool/shell_scan_test.go | 50 + internal/seniordev/tool/shell_scratch.go | 247 +++ internal/seniordev/tool/shell_settings.go | 52 + internal/seniordev/tool/submit.go | 138 ++ internal/seniordev/tool/submit_test.go | 162 ++ internal/seniordev/tool/testsupport_test.go | 25 + internal/seniordev/tool/tool_test.go | 412 +++++ internal/seniordev/tool/web_common.go | 316 ++++ internal/seniordev/tool/web_descriptions.go | 11 + internal/seniordev/tool/webfetch.go | 244 +++ internal/seniordev/tool/webfetch.txt | 12 + internal/seniordev/tool/webfetch_html.go | 216 +++ internal/seniordev/tool/webfetch_test.go | 373 +++++ internal/seniordev/tool/websearch.go | 390 +++++ internal/seniordev/tool/websearch.txt | 14 + internal/seniordev/tool/websearch_test.go | 272 ++++ internal/seniordev/tool/write.go | 106 ++ internal/seniordev/tool/write_test.go | 68 + internal/seniordev/util/eagercommit.go | 74 + internal/seniordev/util/error.go | 230 +++ internal/seniordev/util/filesystem.go | 199 +++ internal/seniordev/util/gitexclude.go | 69 + internal/seniordev/util/gitutils_test.go | 79 + internal/seniordev/util/localcontext.go | 48 + internal/seniordev/util/namederror.go | 43 + internal/seniordev/util/process.go | 334 ++++ internal/seniordev/util/process_test.go | 71 + internal/seniordev/util/record.go | 20 + internal/seniordev/util/util_test.go | 50 + 284 files changed, 65945 insertions(+) create mode 100644 internal/seniordev/app/budget_helpers.go create mode 100644 internal/seniordev/app/catalog_test.go create mode 100644 internal/seniordev/app/compaction_events.go create mode 100644 internal/seniordev/app/compaction_pin.go create mode 100644 internal/seniordev/app/compaction_pin_test.go create mode 100644 internal/seniordev/app/compaction_policy_test.go create mode 100644 internal/seniordev/app/config.go create mode 100644 internal/seniordev/app/config_live_test.go create mode 100644 internal/seniordev/app/durable_sessions.go create mode 100644 internal/seniordev/app/durable_sessions_test.go create mode 100644 internal/seniordev/app/engine_backend.go create mode 100644 internal/seniordev/app/engine_client.go create mode 100644 internal/seniordev/app/engine_compaction.go create mode 100644 internal/seniordev/app/engine_compaction_test.go create mode 100644 internal/seniordev/app/engine_contract_test.go create mode 100644 internal/seniordev/app/engine_prompt_test.go create mode 100644 internal/seniordev/app/engine_router.go create mode 100644 internal/seniordev/app/engine_store.go create mode 100644 internal/seniordev/app/events.go create mode 100644 internal/seniordev/app/events_agent_summary.go create mode 100644 internal/seniordev/app/events_agent_summary_test.go create mode 100644 internal/seniordev/app/events_contract_test.go create mode 100644 internal/seniordev/app/full_verification.go create mode 100644 internal/seniordev/app/full_verification_run.go create mode 100644 internal/seniordev/app/full_verification_test.go create mode 100644 internal/seniordev/app/gitrepo_test.go create mode 100644 internal/seniordev/app/ignore.go create mode 100644 internal/seniordev/app/model_request_events.go create mode 100644 internal/seniordev/app/model_request_events_test.go create mode 100644 internal/seniordev/app/netpolicy_visibility_test.go create mode 100644 internal/seniordev/app/patch_contract.go create mode 100644 internal/seniordev/app/patch_contract_test.go create mode 100644 internal/seniordev/app/pipeline.go create mode 100644 internal/seniordev/app/pipeline_run.go create mode 100644 internal/seniordev/app/pipeline_smoke_test.go create mode 100644 internal/seniordev/app/prompt_in_place.go create mode 100644 internal/seniordev/app/question_autoreject_test.go create mode 100644 internal/seniordev/app/router_cancellation_test.go create mode 100644 internal/seniordev/app/run_error_classify_test.go create mode 100644 internal/seniordev/app/runtime.go create mode 100644 internal/seniordev/app/runtime_compaction_test.go create mode 100644 internal/seniordev/app/runtime_retry_test.go create mode 100644 internal/seniordev/app/runtime_test.go create mode 100644 internal/seniordev/app/solo.go create mode 100644 internal/seniordev/app/solo_finalize.go create mode 100644 internal/seniordev/app/solo_prompt.go create mode 100644 internal/seniordev/app/solo_ship.go create mode 100644 internal/seniordev/app/solo_test.go create mode 100644 internal/seniordev/app/statctime_darwin.go create mode 100644 internal/seniordev/app/statctime_linux.go create mode 100644 internal/seniordev/app/step_records.go create mode 100644 internal/seniordev/app/step_records_test.go create mode 100644 internal/seniordev/app/testsupport_test.go create mode 100644 internal/seniordev/app/tier_test.go create mode 100644 internal/seniordev/app/verification_deadtree.go create mode 100644 internal/seniordev/app/verification_deadtree_test.go create mode 100644 internal/seniordev/app/verification_timeout_test.go create mode 100644 internal/seniordev/app/workspace_git.go create mode 100644 internal/seniordev/app/workspace_recorder.go create mode 100644 internal/seniordev/app/workspace_recorder_git.go create mode 100644 internal/seniordev/app/workspace_recorder_snapshot.go create mode 100644 internal/seniordev/app/workspace_recorder_test.go create mode 100644 internal/seniordev/app/worktree_fingerprint.go create mode 100644 internal/seniordev/baked/agents/coder.md create mode 100644 internal/seniordev/baked/registry.go create mode 100644 internal/seniordev/baked/tier.go create mode 100644 internal/seniordev/baked/tier_test.go create mode 100644 internal/seniordev/bus/bus.go create mode 100644 internal/seniordev/bus/bus_test.go create mode 100644 internal/seniordev/bus/event.go create mode 100644 internal/seniordev/config/config.go create mode 100644 internal/seniordev/config/env.go create mode 100644 internal/seniordev/config/helpers.go create mode 100644 internal/seniordev/config/loader_test.go create mode 100644 internal/seniordev/config/orderedjson.go create mode 100644 internal/seniordev/core/filesystem.go create mode 100644 internal/seniordev/core/filesystem_test.go create mode 100644 internal/seniordev/core/npm.go create mode 100644 internal/seniordev/core/npm_test.go create mode 100644 internal/seniordev/core/spawner.go create mode 100644 internal/seniordev/core/spawner_test.go create mode 100644 internal/seniordev/engine/calc/calc.go create mode 100644 internal/seniordev/engine/calc/cost_test.go create mode 100644 internal/seniordev/engine/calc/overflow.go create mode 100644 internal/seniordev/engine/calc/overflow_test.go create mode 100644 internal/seniordev/engine/calc/usage.go create mode 100644 internal/seniordev/engine/msgmodel/convertmodelmessages.go create mode 100644 internal/seniordev/engine/msgmodel/cursor.go create mode 100644 internal/seniordev/engine/msgmodel/events.go create mode 100644 internal/seniordev/engine/msgmodel/filter.go create mode 100644 internal/seniordev/engine/msgmodel/filter_test.go create mode 100644 internal/seniordev/engine/msgmodel/fromerror.go create mode 100644 internal/seniordev/engine/msgmodel/fromerror_openrouter502_test.go create mode 100644 internal/seniordev/engine/msgmodel/message.go create mode 100644 internal/seniordev/engine/msgmodel/msgmodel.go create mode 100644 internal/seniordev/engine/msgmodel/msgmodel_test.go create mode 100644 internal/seniordev/engine/msgmodel/openrouter_inband.go create mode 100644 internal/seniordev/engine/msgmodel/parts.go create mode 100644 internal/seniordev/engine/msgmodel/rawobject.go create mode 100644 internal/seniordev/engine/msgmodel/settle.go create mode 100644 internal/seniordev/engine/msgmodel/storage.go create mode 100644 internal/seniordev/engine/msgmodel/storage_test.go create mode 100644 internal/seniordev/engine/msgmodel/tomodelmessages.go create mode 100644 internal/seniordev/engine/msgmodel/toolstate.go create mode 100644 internal/seniordev/engine/msgmodel/uimessage.go create mode 100644 internal/seniordev/engine/msgmodel/usertext.go create mode 100644 internal/seniordev/engine/orclient/body.go create mode 100644 internal/seniordev/engine/orclient/cancellation_test.go create mode 100644 internal/seniordev/engine/orclient/client.go create mode 100644 internal/seniordev/engine/orclient/client_test.go create mode 100644 internal/seniordev/engine/orclient/convert.go create mode 100644 internal/seniordev/engine/orclient/convert_tool_result_test.go create mode 100644 internal/seniordev/engine/orclient/convert_user_content_test.go create mode 100644 internal/seniordev/engine/orclient/helpers_test.go create mode 100644 internal/seniordev/engine/orclient/jsonval.go create mode 100644 internal/seniordev/engine/orclient/lowercase.go create mode 100644 internal/seniordev/engine/orclient/orclient.go create mode 100644 internal/seniordev/engine/orclient/orclient_test.go create mode 100644 internal/seniordev/engine/orclient/parts.go create mode 100644 internal/seniordev/engine/orclient/reasoning.go create mode 100644 internal/seniordev/engine/orclient/routing.go create mode 100644 internal/seniordev/engine/orclient/routing_test.go create mode 100644 internal/seniordev/engine/orclient/sampling_body_test.go create mode 100644 internal/seniordev/engine/orclient/sse.go create mode 100644 internal/seniordev/engine/orclient/stream.go create mode 100644 internal/seniordev/engine/orclient/toolcall.go create mode 100644 internal/seniordev/engine/orclient/transform.go create mode 100644 internal/seniordev/engine/orclient/wire.go create mode 100644 internal/seniordev/engine/retrysched/contextoverflow.go create mode 100644 internal/seniordev/engine/retrysched/contextoverflow_test.go create mode 100644 internal/seniordev/engine/retrysched/errors.go create mode 100644 internal/seniordev/engine/retrysched/errors_test.go create mode 100644 internal/seniordev/engine/retrysched/retrysched.go create mode 100644 internal/seniordev/engine/retrysched/retrysched_test.go create mode 100644 internal/seniordev/engine/steploop/compacted_after_test.go create mode 100644 internal/seniordev/engine/steploop/doc.go create mode 100644 internal/seniordev/engine/steploop/helpers.go create mode 100644 internal/seniordev/engine/steploop/helpers_test.go create mode 100644 internal/seniordev/engine/steploop/invalid_tool_test.go create mode 100644 internal/seniordev/engine/steploop/loop.go create mode 100644 internal/seniordev/engine/steploop/processor.go create mode 100644 internal/seniordev/engine/steploop/reminders.go create mode 100644 internal/seniordev/engine/steploop/steploop_test.go create mode 100644 internal/seniordev/engine/steploop/types.go create mode 100644 internal/seniordev/format/format.go create mode 100644 internal/seniordev/format/format_test.go create mode 100644 internal/seniordev/format/formatter.go create mode 100644 internal/seniordev/id/id.go create mode 100644 internal/seniordev/id/id_test.go create mode 100644 internal/seniordev/jsonutil/jsonutil.go create mode 100644 internal/seniordev/modelsdev/models.go create mode 100644 internal/seniordev/modelsdev/models_test.go create mode 100644 internal/seniordev/modelsdev/testdata/catalog.json create mode 100644 internal/seniordev/netpolicy/blackhole.go create mode 100644 internal/seniordev/netpolicy/blackhole_test.go create mode 100644 internal/seniordev/netpolicy/netpolicy.go create mode 100644 internal/seniordev/netpolicy/netpolicy_test.go create mode 100644 internal/seniordev/patch/diff_contract_test.go create mode 100644 internal/seniordev/patch/io_test.go create mode 100644 internal/seniordev/patch/patch.go create mode 100644 internal/seniordev/permission/permission.go create mode 100644 internal/seniordev/permission/permission_test.go create mode 100644 internal/seniordev/project/context.go create mode 100644 internal/seniordev/project/store.go create mode 100644 internal/seniordev/project/store_test.go create mode 100644 internal/seniordev/question/question.go create mode 100644 internal/seniordev/question/question_test.go create mode 100644 internal/seniordev/question/schema.go create mode 100644 internal/seniordev/router/adaptive/adaptive.go create mode 100644 internal/seniordev/router/adaptive/adaptive_contract_test.go create mode 100644 internal/seniordev/router/adaptive/adaptive_test.go create mode 100644 internal/seniordev/router/adaptive/cancellation_test.go create mode 100644 internal/seniordev/router/adaptive/tier_test.go create mode 100644 internal/seniordev/router/state/adaptivewire.go create mode 100644 internal/seniordev/router/state/adaptivewire_test.go create mode 100644 internal/seniordev/router/state/state.go create mode 100644 internal/seniordev/router/state/state_test.go create mode 100644 internal/seniordev/session/compaction/additive_test.go create mode 100644 internal/seniordev/session/compaction/controller.go create mode 100644 internal/seniordev/session/compaction/controller_test.go create mode 100644 internal/seniordev/session/compaction/core.go create mode 100644 internal/seniordev/session/compaction/core_test.go create mode 100644 internal/seniordev/session/compaction/evidence.go create mode 100644 internal/seniordev/session/compaction/service.go create mode 100644 internal/seniordev/session/compaction/service_test.go create mode 100644 internal/seniordev/session/compaction/surgery.go create mode 100644 internal/seniordev/session/compaction/tail.go create mode 100644 internal/seniordev/session/compaction/tail_test.go create mode 100644 internal/seniordev/session/compaction/transcript.go create mode 100644 internal/seniordev/session/compaction/transcript_test.go create mode 100644 internal/seniordev/session/evidenceharvest/evidenceharvest.go create mode 100644 internal/seniordev/session/evidenceharvest/evidenceharvest_test.go create mode 100644 internal/seniordev/session/fullverification/discovery.go create mode 100644 internal/seniordev/session/fullverification/discovery_test.go create mode 100644 internal/seniordev/session/fullverification/noop_evidence_test.go create mode 100644 internal/seniordev/session/fullverification/script_body_discovery_test.go create mode 100644 internal/seniordev/session/instruction/instruction.go create mode 100644 internal/seniordev/session/llmcall/cancellation_test.go create mode 100644 internal/seniordev/session/llmcall/llmcall.go create mode 100644 internal/seniordev/session/llmcall/llmcall_test.go create mode 100644 internal/seniordev/session/llmcall/tier_test.go create mode 100644 internal/seniordev/session/loopguard/loopguard.go create mode 100644 internal/seniordev/session/loopguard/loopguard_test.go create mode 100644 internal/seniordev/session/outputoffload/outputoffload.go create mode 100644 internal/seniordev/session/overflow/overflow.go create mode 100644 internal/seniordev/session/projectors/busy_retry.go create mode 100644 internal/seniordev/session/projectors/busy_retry_test.go create mode 100644 internal/seniordev/session/projectors/database.go create mode 100644 internal/seniordev/session/projectors/database_test.go create mode 100644 internal/seniordev/session/projectors/projectors.go create mode 100644 internal/seniordev/session/projectors/projectors_test.go create mode 100644 internal/seniordev/session/projectors/schema.go create mode 100644 internal/seniordev/session/projectors/schema_test.go create mode 100644 internal/seniordev/session/runbudget/runbudget.go create mode 100644 internal/seniordev/session/sessioncore/sessioncore.go create mode 100644 internal/seniordev/session/sessioncore/sessioncore_test.go create mode 100644 internal/seniordev/session/system/system.go create mode 100644 internal/seniordev/storage/storage.go create mode 100644 internal/seniordev/storage/storage_test.go create mode 100644 internal/seniordev/tool/apply_patch.go create mode 100644 internal/seniordev/tool/apply_patch.txt create mode 100644 internal/seniordev/tool/apply_patch_description.go create mode 100644 internal/seniordev/tool/apply_patch_test.go create mode 100644 internal/seniordev/tool/bash.go create mode 100644 internal/seniordev/tool/bash_clock_test.go create mode 100644 internal/seniordev/tool/descriptions.go create mode 100644 internal/seniordev/tool/edit.go create mode 100644 internal/seniordev/tool/edit_test.go create mode 100644 internal/seniordev/tool/glob.go create mode 100644 internal/seniordev/tool/glob_description.go create mode 100644 internal/seniordev/tool/glob_test.go create mode 100644 internal/seniordev/tool/grep.go create mode 100644 internal/seniordev/tool/grep_description.go create mode 100644 internal/seniordev/tool/grep_test.go create mode 100644 internal/seniordev/tool/instance_context_test.go create mode 100644 internal/seniordev/tool/mutation_feedback_test.go create mode 100644 internal/seniordev/tool/netpolicy_gate_test.go create mode 100644 internal/seniordev/tool/path.go create mode 100644 internal/seniordev/tool/question.go create mode 100644 internal/seniordev/tool/question.txt create mode 100644 internal/seniordev/tool/question_description.go create mode 100644 internal/seniordev/tool/question_test.go create mode 100644 internal/seniordev/tool/read.go create mode 100644 internal/seniordev/tool/read_test.go create mode 100644 internal/seniordev/tool/registry.go create mode 100644 internal/seniordev/tool/registry_policy_test.go create mode 100644 internal/seniordev/tool/registry_worktree.go create mode 100644 internal/seniordev/tool/ripgrep.go create mode 100644 internal/seniordev/tool/ripgrep_fallback.go create mode 100644 internal/seniordev/tool/ripgrep_fallback_equivalence_test.go create mode 100644 internal/seniordev/tool/ripgrep_fallback_test.go create mode 100644 internal/seniordev/tool/settings.go create mode 100644 internal/seniordev/tool/shell_env_signal.go create mode 100644 internal/seniordev/tool/shell_feedback_test.go create mode 100644 internal/seniordev/tool/shell_scan.go create mode 100644 internal/seniordev/tool/shell_scan_test.go create mode 100644 internal/seniordev/tool/shell_scratch.go create mode 100644 internal/seniordev/tool/shell_settings.go create mode 100644 internal/seniordev/tool/submit.go create mode 100644 internal/seniordev/tool/submit_test.go create mode 100644 internal/seniordev/tool/testsupport_test.go create mode 100644 internal/seniordev/tool/tool_test.go create mode 100644 internal/seniordev/tool/web_common.go create mode 100644 internal/seniordev/tool/web_descriptions.go create mode 100644 internal/seniordev/tool/webfetch.go create mode 100644 internal/seniordev/tool/webfetch.txt create mode 100644 internal/seniordev/tool/webfetch_html.go create mode 100644 internal/seniordev/tool/webfetch_test.go create mode 100644 internal/seniordev/tool/websearch.go create mode 100644 internal/seniordev/tool/websearch.txt create mode 100644 internal/seniordev/tool/websearch_test.go create mode 100644 internal/seniordev/tool/write.go create mode 100644 internal/seniordev/tool/write_test.go create mode 100644 internal/seniordev/util/eagercommit.go create mode 100644 internal/seniordev/util/error.go create mode 100644 internal/seniordev/util/filesystem.go create mode 100644 internal/seniordev/util/gitexclude.go create mode 100644 internal/seniordev/util/gitutils_test.go create mode 100644 internal/seniordev/util/localcontext.go create mode 100644 internal/seniordev/util/namederror.go create mode 100644 internal/seniordev/util/process.go create mode 100644 internal/seniordev/util/process_test.go create mode 100644 internal/seniordev/util/record.go create mode 100644 internal/seniordev/util/util_test.go diff --git a/internal/seniordev/app/budget_helpers.go b/internal/seniordev/app/budget_helpers.go new file mode 100644 index 000000000..20dadc884 --- /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 000000000..165b96489 --- /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: "../../internal/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: &openRouterBackend{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 000000000..699e0bd27 --- /dev/null +++ b/internal/seniordev/app/compaction_events.go @@ -0,0 +1,29 @@ +//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{}, +) + +type seniorDevCompactionDecisionSink struct{ bus *bus.Bus } + +func newSeniorDevCompactionDecisionSink(instance *bus.Bus) compaction.DecisionSink { + if instance == nil { + return nil + } + return seniorDevCompactionDecisionSink{bus: instance} +} + +func (sink seniorDevCompactionDecisionSink) CompactionDecision( + decision compaction.CompactionDecision, +) { + sink.bus.Publish(seniorDevCompactionDecisionEvent, decision) +} + +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 000000000..dd879bc9a --- /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 *openRouterBackend) 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 *openRouterBackend) 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 *openRouterBackend) 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 *openRouterBackend) 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 *openRouterBackend) 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 000000000..2a2c49a4d --- /dev/null +++ b/internal/seniordev/app/compaction_pin_test.go @@ -0,0 +1,162 @@ +//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) (*openRouterBackend, *bytes.Buffer) { + t.Helper() + cfg, err := newSeniorDevConfig(info) + if err != nil { + t.Fatal(err) + } + var events bytes.Buffer + backend := &openRouterBackend{ + apiKey: "mock-only", 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 *openRouterBackend, 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{ + Fetcher: backend.client.Do, 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 000000000..f66a2d79e --- /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 := &openRouterBackend{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 000000000..7628ee121 --- /dev/null +++ b/internal/seniordev/app/config.go @@ -0,0 +1,459 @@ +//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 := validateConfiguredRouting(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, + 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 +} + +// providerRouting resolves the OpenRouter `provider` routing block for one +// turn: the provider-wide `providerRouting`, then the model's, then the +// agent's, each level overriding only the fields it sets. Nil when nothing is +// configured, so the request carries no `provider` key at all and OpenRouter +// applies its own default routing. +func (cfg *seniorDevConfig) providerRouting(agent, providerID, modelID string) (*orclient.ProviderRouting, error) { + if cfg == nil { + return nil, nil + } + var merged *orclient.ProviderRouting + for _, level := range []struct { + name string + value any + }{ + {"provider." + providerID, cfg.provider(providerID)["providerRouting"]}, + {"provider." + providerID + ".models." + modelID, cfg.model(providerID, modelID)["providerRouting"]}, + {"agent." + agent, cfg.agent(agent)["providerRouting"]}, + } { + parsed, err := parseConfiguredRouting(level.value) + if err != nil { + return nil, fmt.Errorf("%s.providerRouting: %w", level.name, err) + } + merged = merged.Merge(parsed) + } + return merged, nil +} + +func parseConfiguredRouting(value any) (*orclient.ProviderRouting, error) { + if value == nil { + return nil, nil + } + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + return orclient.ParseProviderRouting(data) +} + +// validateConfiguredRouting parses every providerRouting block at load time +// so a misspelled or out-of-range rule fails the run up front instead of +// silently routing with OpenRouter's defaults. +func validateConfiguredRouting(info configpkg.Info) error { + for providerID, rawProvider := range objectValue(info["provider"]) { + provider := objectValue(rawProvider) + if _, err := parseConfiguredRouting(provider["providerRouting"]); err != nil { + return fmt.Errorf("provider %q providerRouting: %w", providerID, err) + } + for modelID, rawModel := range objectValue(provider["models"]) { + if _, err := parseConfiguredRouting(objectValue(rawModel)["providerRouting"]); err != nil { + return fmt.Errorf("provider %q model %q providerRouting: %w", providerID, modelID, err) + } + } + } + for name, raw := range objectValue(info["agent"]) { + if _, err := parseConfiguredRouting(objectValue(raw)["providerRouting"]); err != nil { + return fmt.Errorf("agent %q providerRouting: %w", name, err) + } + } + 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 *openRouterBackend) { + if cfg == nil || backend == nil { + return + } + backend.config = cfg + options := objectValue(cfg.provider("openrouter")["options"]) + if value, ok := options["apiKey"].(string); ok && value != "" { + backend.apiKey = value + } + if value, ok := options["baseURL"].(string); ok && value != "" { + backend.endpoint = openRouterEndpoint(value) + } + 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 "openrouter", 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 000000000..2cba1ede1 --- /dev/null +++ b/internal/seniordev/app/config_live_test.go @@ -0,0 +1,210 @@ +//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": { + "apiKey": "configured-key", + "baseURL": "https://router.example/api/v1", + "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 := &openRouterBackend{} + 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.apiKey != "configured-key" || backend.baseURL() != "https://router.example/api/v1" || + 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 := seniorDevOpenRouterHeadersWithConfig( + backend.apiKey, "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) + } +} + +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/durable_sessions.go b/internal/seniordev/app/durable_sessions.go new file mode 100644 index 000000000..cc366d954 --- /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 000000000..a8df688c1 --- /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, &openRouterBackend{apiKey: "test", 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, &openRouterBackend{ + apiKey: "test", 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, &openRouterBackend{ + apiKey: "test", 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 000000000..fd9b16ed6 --- /dev/null +++ b/internal/seniordev/app/engine_backend.go @@ -0,0 +1,427 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/attribution" + "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 *openRouterBackend) 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 *openRouterBackend) runEngine( + ctx context.Context, request turn, +) (turnResult, error) { + if backend.apiKey == "" { + return turnResult{}, errors.New("OPENROUTER_API_KEY is not set in the environment") + } + 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...) + if instruction := attribution.CommitPromptInstruction(); instruction != "" { + parts = append(parts, instruction) + } + 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 000000000..d2c36df01 --- /dev/null +++ b/internal/seniordev/app/engine_client.go @@ -0,0 +1,516 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "runtime" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/attribution" + "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 *openRouterBackend + 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)) + routing, err := models.backend.config.providerRouting(models.agent, providerID, modelID) + if err != nil { + return llmcall.Model{}, err + } + if object := routing.Object(); object != nil { + // The typed block is authoritative over any ad-hoc `options.provider`. + options.SetObject("provider", object) + } + 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 +} + +func normalizeModelRef(providerID, modelID string) (string, string) { + if providerID == "" { + if before, after, ok := strings.Cut(modelID, "/"); ok && before == "openrouter" { + providerID, modelID = before, after + } + } + if providerID == "" { + providerID = "openrouter" + } + if providerID == "openrouter" { + modelID = strings.TrimPrefix(modelID, "openrouter/") + } + return providerID, modelID +} + +type seniorDevClientFactory struct { + backend *openRouterBackend + 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.baseURL(), + Headers: seniorDevOpenRouterHeadersWithConfig( + factory.backend.apiKey, 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, + } + if factory.backend.client != nil { + client.Fetcher = factory.backend.client.Do + } + return seniorDevStreamClient{ + client: client, model: projection, agent: factory.agent, + bypassToolFilter: factory.bypassToolFilter, + backend: factory.backend, sessionID: factory.sessionID, + }, nil +} + +type seniorDevStreamClient struct { + backend *openRouterBackend + 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 +} + +func seniorDevOpenRouterHeadersWithConfig( + apiKey, sessionID string, configured []orclient.HeaderPair, +) []orclient.HeaderPair { + provider := []orclient.HeaderPair{{Name: "Authorization", Value: "Bearer " + apiKey}} + for _, pair := range attribution.OpenRouterHeaderPairs() { + provider = append(provider, orclient.HeaderPair{Name: pair[0], Value: pair[1]}) + } + provider = append(provider, configured...) + return orclient.BuildHeaders(orclient.HeaderInputs{ + Provider: provider, + 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 *openRouterBackend + 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 *openRouterBackend, + 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 +} + +func (backend *openRouterBackend) baseURL() string { + endpoint := strings.TrimRight(backend.endpoint, "/") + if endpoint == "" { + return "https://openrouter.ai/api/v1" + } + endpoint = strings.TrimSuffix(endpoint, "/chat/completions") + return strings.TrimRight(endpoint, "/") +} + +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 000000000..e533f045f --- /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/attribution" + "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" +) + +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 *openRouterBackend, + 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 := attribution.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 000000000..1509a062b --- /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 000000000..708a1c3cd --- /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 := &openRouterBackend{apiKey: "test", 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 := &openRouterBackend{apiKey: "test", 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(), &openRouterBackend{apiKey: "test", 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 := &openRouterBackend{apiKey: "test", 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 000000000..86bc1418d --- /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 := &openRouterBackend{apiKey: "test", 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 000000000..5ad215df7 --- /dev/null +++ b/internal/seniordev/app/engine_router.go @@ -0,0 +1,42 @@ +//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 *openRouterBackend) 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, + }) + } + }, + }) + 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 000000000..0987981a5 --- /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 000000000..8c3caaa0c --- /dev/null +++ b/internal/seniordev/app/events.go @@ -0,0 +1,156 @@ +//go:build !windows + +// This file is the NDJSON event stream: the stage events senior-dev emits on +// stdout and the bus payloads it forwards there unchanged. +package app + +import ( + "encoding/json" + "io" + "sync" + "time" + + "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"` + TraceID string `json:"trace_id,omitempty"` + Step uint64 `json:"step,omitempty"` + Occurrence uint64 `json:"occurrence,omitempty"` + Title string `json:"title,omitempty"` + ElapsedMS int64 `json:"elapsed_ms,omitempty"` + // `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. + Command string `json:"command,omitempty"` + Observation string `json:"observation,omitempty"` +} + +type eventWriter struct { + mu sync.Mutex + encoder *json.Encoder + hook func(event) + trace *runTrace + 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{} +} + +func newEventWriter(output io.Writer) *eventWriter { + return &eventWriter{ + encoder: json.NewEncoder(output), + summary: newAgentSummary(), + steps: map[string]struct{}{}, + } +} + +func (writer *eventWriter) setHook(hook func(event)) { + if writer == nil { + return + } + writer.mu.Lock() + writer.hook = hook + writer.mu.Unlock() +} + +// enableTrace mirrors semantic run events as structured records on notes. +// stdout remains the exhaustive NDJSON event stream; notes is stderr in the +// shipped binary, so the readable trace goes wherever stderr goes. +func (writer *eventWriter) enableTrace(notes io.Writer, runID string) { + if writer == nil || notes == nil { + return + } + writer.mu.Lock() + writer.trace = newRunTrace(notes, runID) + writer.mu.Unlock() +} + +func (writer *eventWriter) emit(value event) { + if writer == nil || writer.encoder == nil { + return + } + if value.Timestamp == 0 { + value.Timestamp = time.Now().UnixMilli() + } + writer.mu.Lock() + if writer.trace != nil { + value = writer.trace.event(value) + } + _ = writer.encoder.Encode(value) + if writer.hook != nil { + writer.hook(value) + } + writer.mu.Unlock() +} + +// emitUntraced writes a record to stdout without mirroring it into the stderr +// trace. `spend` and `step` exist for a reader consuming stdout; the trace +// already carries its own tool and cost records, and duplicating them there +// would bury the semantic trace under one entry per tool call. +func (writer *eventWriter) emitUntraced(value event) { + if writer == nil || writer.encoder == nil { + return + } + if value.Timestamp == 0 { + value.Timestamp = time.Now().UnixMilli() + } + writer.mu.Lock() + _ = writer.encoder.Encode(value) + if writer.hook != nil { + writer.hook(value) + } + writer.mu.Unlock() +} + +// busEvent writes the instance-bus payload without wrapping or renaming it: +// every such line has exactly the Bus.Payload shape {id,type,properties}. +func (writer *eventWriter) busEvent(value bus.Payload) { + if writer == nil || writer.encoder == 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() + _ = writer.encoder.Encode(value) + if writer.trace != nil { + writer.trace.busEvent(value) + } + if isStep { + if _, seen := writer.steps[step.key]; seen { + isStep = false + } else { + writer.steps[step.key] = struct{}{} + } + } + 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 stream already published. + if isStep { + writer.emitUntraced(event{ + Type: "step", Command: step.command, Observation: step.observation, + }) + } + // The running total, after the message that moved it. A reader enforcing a + // dollar ceiling while the run is alive reads this and nothing else: the + // agent-summary and terminal totals arrive only once the run is over. + if completed { + total := spend + writer.emitUntraced(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}) +} diff --git a/internal/seniordev/app/events_agent_summary.go b/internal/seniordev/app/events_agent_summary.go new file mode 100644 index 000000000..16819c06d --- /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 000000000..f85caccf5 --- /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 000000000..52c5bf38d --- /dev/null +++ b/internal/seniordev/app/events_contract_test.go @@ -0,0 +1,147 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore" +) + +func TestQuestionToolEventsReachStdoutAsBusPayloads(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 TestPipelineStreamsBusEventsToStdout(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) + } + } +} + +func TestRunFormatAcceptsDefaultAndJSONOnly(t *testing.T) { + for _, format := range []string{"default", "json"} { + t.Run("accept_"+format, func(t *testing.T) { + args, err := parseArgs([]string{"run", "--format", format, "work"}) + if err != nil { + t.Fatalf("parseArgs rejected format %q: %v", format, err) + } + if args.Format != format { + t.Fatalf("format = %q, want %q", args.Format, format) + } + }) + } + + for _, format := range []string{"ndjson", "text", "pretty", "yaml"} { + t.Run("reject_"+format, func(t *testing.T) { + _, err := parseArgs([]string{"run", "--format", format, "work"}) + if err == nil || !strings.Contains(err.Error(), "default or json") { + t.Fatalf("parseArgs format %q error = %v, want default/json rejection", format, err) + } + }) + } + + args, err := parseArgs([]string{"run", "work"}) + if err != nil { + t.Fatal(err) + } + if args.Format != "json" { + t.Fatalf("default format = %q, want json", args.Format) + } +} + +func TestTUIFailsLoudlyBeforePipelineStartup(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runCLI( + context.Background(), []string{"run", "--tui", "work"}, nil, + &stdout, &stderr, + ) + var exit *cliExitError + if !errors.As(err, &exit) || exit.code != 1 { + t.Fatalf("--tui error = %#v, want cli exit code 1", err) + } + if stdout.Len() != 0 { + t.Fatalf("--tui stdout = %q, want empty", stdout.String()) + } + const message = "--tui is not supported" + if !strings.Contains(stderr.String(), message) || + !strings.Contains(stderr.String(), "headless NDJSON event stream") { + t.Fatalf("--tui stderr = %q, want clear unsupported/headless message", stderr.String()) + } +} diff --git a/internal/seniordev/app/full_verification.go b/internal/seniordev/app/full_verification.go new file mode 100644 index 000000000..541e174f3 --- /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 000000000..8ed8db204 --- /dev/null +++ b/internal/seniordev/app/full_verification_run.go @@ -0,0 +1,344 @@ +//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 { + 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()) + } + 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 + 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 000000000..579718887 --- /dev/null +++ b/internal/seniordev/app/full_verification_test.go @@ -0,0 +1,180 @@ +//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) + } +} diff --git a/internal/seniordev/app/gitrepo_test.go b/internal/seniordev/app/gitrepo_test.go new file mode 100644 index 000000000..7796aa055 --- /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 000000000..25dcdb662 --- /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 000000000..a2a57f59d --- /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 000000000..cfa3f3b11 --- /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 := &openRouterBackend{apiKey: "not-a-real-key", 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 := &openRouterBackend{apiKey: "test", 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 := &openRouterBackend{apiKey: "test", 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 := &openRouterBackend{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 000000000..36b5ca6fb --- /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 000000000..d5bcaa74a --- /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 000000000..1d2a7170d --- /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 000000000..fbdef6e49 --- /dev/null +++ b/internal/seniordev/app/pipeline.go @@ -0,0 +1,425 @@ +//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 + CPBridge *cpBridge + 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 + cpBridge *cpBridge + cpURL string + cpEnabled bool + // 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, cpBridge: deps.CPBridge, + 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(), + // Whether this run mirrored onto a control plane, and the URL it + // probed to decide. A standalone run is a legitimate shape, so the + // contract says which one happened rather than leaving it inferable + // only from the absence of other evidence. + "control_plane": map[string]any{ + "enabled": runner.cpEnabled, + "url": runner.cpURL, + }, + // 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 000000000..e26b8299a --- /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 000000000..18d6ab712 --- /dev/null +++ b/internal/seniordev/app/pipeline_smoke_test.go @@ -0,0 +1,340 @@ +//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/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" event -- 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 + invocation := &cliInvocation{ + events: newEventWriter(&out), + runner: newPipeline(cliArgs{}, t.TempDir(), pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }), + } + defer invocation.runner.runtime.Close() + invocation.persistTerminalResult(result) + + 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 000000000..7de31f6e4 --- /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 000000000..8be570406 --- /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 000000000..bed39f04c --- /dev/null +++ b/internal/seniordev/app/router_cancellation_test.go @@ -0,0 +1,40 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "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) + } + } +} 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 000000000..161ef4065 --- /dev/null +++ b/internal/seniordev/app/run_error_classify_test.go @@ -0,0 +1,45 @@ +//go:build !windows + +package app + +import ( + "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(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(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(runner, passResult, nil) + if err != nil || result.Status != "pass" { + t.Fatalf("nil error result = %#v err = %v", result, err) + } +} diff --git a/internal/seniordev/app/runtime.go b/internal/seniordev/app/runtime.go new file mode 100644 index 000000000..55a39c155 --- /dev/null +++ b/internal/seniordev/app/runtime.go @@ -0,0 +1,572 @@ +//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/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 + } + // Provider routing changes which upstream serves the turn, so a run + // that sets it must be readable from the stream alone. The block has + // already been validated at config load, so the error is spent. + routing, err := runtime.config.providerRouting(configured.Agent, configured.ProviderID, configured.ModelID) + if err == nil && !routing.IsZero() { + data["provider_routing"] = routing + } + 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.(*openRouterBackend); 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.(*openRouterBackend) + 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.(*openRouterBackend); 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) + } + 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...) +} + +type openRouterBackend struct { + apiKey string + variant string + client *http.Client + endpoint string + 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) +} + +func defaultBackend(variant string) backend { + endpoint := "" + if base := os.Getenv("OPENROUTER_BASE_URL"); base != "" { + endpoint = openRouterEndpoint(base) + } + return &openRouterBackend{ + apiKey: os.Getenv("OPENROUTER_API_KEY"), variant: variant, + endpoint: endpoint, + // 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{}, + } +} + +func openRouterEndpoint(base string) string { + base = strings.TrimRight(base, "/") + base = strings.TrimSuffix(base, "/api/v1") + return base + "/api/v1/chat/completions" +} diff --git a/internal/seniordev/app/runtime_compaction_test.go b/internal/seniordev/app/runtime_compaction_test.go new file mode 100644 index 000000000..825a6aad7 --- /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 := &openRouterBackend{ + apiKey: "test", 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, &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{apiKey: "test", 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, &openRouterBackend{ + apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 000000000..d455ac5e0 --- /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 := &openRouterBackend{apiKey: "test", 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 := &openRouterBackend{apiKey: "test", 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 := &openRouterBackend{apiKey: "test", 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 := &openRouterBackend{ + apiKey: "test", 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 000000000..ab3f98b23 --- /dev/null +++ b/internal/seniordev/app/runtime_test.go @@ -0,0 +1,122 @@ +//go:build !windows + +package app + +import ( + "context" + "reflect" + "slices" + "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 TestOpenRouterEndpoint(t *testing.T) { + // Both bare proxy roots and already-versioned roots produce one + // /api/v1 segment before chat/completions. + for _, test := range []struct { + input string + want string + }{ + {input: "http://proxy", want: "http://proxy/api/v1/chat/completions"}, + {input: "http://proxy/", want: "http://proxy/api/v1/chat/completions"}, + {input: "http://proxy/api/v1", want: "http://proxy/api/v1/chat/completions"}, + {input: "http://proxy/api/v1/", want: "http://proxy/api/v1/chat/completions"}, + } { + t.Run(test.input, func(t *testing.T) { + if got := openRouterEndpoint(test.input); got != test.want { + t.Fatalf("openRouterEndpoint(%q) = %q, want %q", test.input, got, test.want) + } + }) + } +} + +func TestDefaultBackendHasNoHTTPClientWallClockTimeout(t *testing.T) { + configured, ok := defaultBackend("").(*openRouterBackend) + if !ok { + t.Fatalf("defaultBackend type = %T, want *openRouterBackend", defaultBackend("")) + } + if configured.client == nil { + t.Fatal("default backend has no HTTP client") + } + if configured.client.Timeout != 0 { + t.Fatalf("default HTTP client timeout = %s, want disabled", configured.client.Timeout) + } +} + +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 000000000..ca64aa9b3 --- /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("submit", "frozen", 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 000000000..5cf678c08 --- /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", "verified", 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 000000000..465415824 --- /dev/null +++ b/internal/seniordev/app/solo_prompt.go @@ -0,0 +1,161 @@ +//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.` + +// 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 000000000..9d5259a9f --- /dev/null +++ b/internal/seniordev/app/solo_ship.go @@ -0,0 +1,219 @@ +//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 unverified: %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: + // 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 verified: %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 { + data["verification_failing"] = countFailingEntrypoints(*verification) + data["verification_timed_out"] = verification.TimedOut + data["verification_commands"] = len(verification.Commands) + } + // 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 000000000..3abea22c9 --- /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/statctime_darwin.go b/internal/seniordev/app/statctime_darwin.go new file mode 100644 index 000000000..1b8ba88b4 --- /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 000000000..bedf4fc7b --- /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_records.go b/internal/seniordev/app/step_records.go new file mode 100644 index 000000000..758462a8d --- /dev/null +++ b/internal/seniordev/app/step_records.go @@ -0,0 +1,119 @@ +//go:build !windows + +package app + +import ( + "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, and what came +// back. Every byte of it is already on stdout inside the `message.part.updated` +// payload for the same call — this adds no information to the stream, it +// rearranges information the stream already carries. +// +// 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. +type stepRecord struct { + key string + command string + observation string +} + +// 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") + record := stepRecord{key: "tool:" + stringAt(part, "callID") + ":" + status} + if argument := toolArgument(mapAt(state, "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 +} + +// 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 +} diff --git a/internal/seniordev/app/step_records_test.go b/internal/seniordev/app/step_records_test.go new file mode 100644 index 000000000..34c954ab4 --- /dev/null +++ b/internal/seniordev/app/step_records_test.go @@ -0,0 +1,142 @@ +//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") + } +} diff --git a/internal/seniordev/app/testsupport_test.go b/internal/seniordev/app/testsupport_test.go new file mode 100644 index 000000000..0cd7cd664 --- /dev/null +++ b/internal/seniordev/app/testsupport_test.go @@ -0,0 +1,213 @@ +//go:build !windows + +package app + +// Shared fixtures for the pipeline tests. + +import ( + "context" + "fmt" + + "path/filepath" + "strings" + "sync" + "testing" + + "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" + +// 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 000000000..2e80924ca --- /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 000000000..34a4cb926 --- /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 000000000..0d08a7152 --- /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 000000000..ebcc53c94 --- /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 000000000..f3f132a38 --- /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 000000000..6e64c11a7 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder.go @@ -0,0 +1,88 @@ +//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 the default and +// the only recorder chosen by inspecting the workspace; --in-place is an +// explicit choice, never inferred. Inference would be wrong in the case that +// matters most: a run inside a real repository that must not touch its +// history is indistinguishable, from the filesystem, from one that should. +func newWorkspaceRecorder( + args cliArgs, workspace string, note func(string), +) workspaceRecorder { + if args.InPlace { + return newSnapshotRecorder(workspace, note) + } + return newGitRecorder(workspace, note) +} diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go new file mode 100644 index 000000000..c6f021859 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -0,0 +1,344 @@ +//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/attribution" + "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 := attribution.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") == "" { + return fmt.Errorf("workspace is not a git repository: %s", 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") + } + 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 000000000..3f3884ac0 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder_snapshot.go @@ -0,0 +1,494 @@ +//go:build !windows + +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "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. +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 err + } + 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 hashErr + } + 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 +} + +// 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 000000000..b02bdc899 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder_test.go @@ -0,0 +1,365 @@ +//go:build !windows + +package app + +import ( + "context" + "os" + "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_CP_URL", deadControlPlaneURL(t)) + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + args := []string{ + "run", "--in-place", "--dir", workspace, "--high", "provider/high", + "Implement the thing.", + } + backend := &coderOnlyBackend{} + var stdout, stderr strings.Builder + if err := runCLI(context.Background(), args, backend, &stdout, &stderr); err != nil { + t.Fatalf("in-place run failed: %v\n%s", err, stderr.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(stdout.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_CP_URL", deadControlPlaneURL(t)) + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + args := []string{ + "run", "--in-place", "--dir", workspace, "--high", "provider/high", + "Implement the thing.", + } + backend := &coderOnlyBackend{} + var stdout, stderr strings.Builder + if err := runCLI(context.Background(), args, backend, &stdout, &stderr); err != nil { + t.Fatalf("run without a repository failed: %v\n%s", err, stderr.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 the workspace must still be a repository. The default +// path is unchanged, and this is what says so. +func TestDefaultRunStillRequiresARepository(t *testing.T) { + workspace := t.TempDir() + t.Setenv("SENIOR_DEV_CP_URL", deadControlPlaneURL(t)) + args := []string{ + "run", "--dir", workspace, "--high", "provider/high", "Implement the thing.", + } + err := runCLI(context.Background(), args, &coderOnlyBackend{}, &strings.Builder{}, &strings.Builder{}) + if err == nil { + t.Fatal("a non-repository workspace was accepted without --in-place") + } + if !strings.Contains(err.Error(), "not a git repository") { + t.Fatalf("error does not name the cause: %v", 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") + } +} diff --git a/internal/seniordev/app/worktree_fingerprint.go b/internal/seniordev/app/worktree_fingerprint.go new file mode 100644 index 000000000..574c2fba2 --- /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 000000000..83ee5f4e1 --- /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 000000000..209bf78ea --- /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 000000000..5a6afe103 --- /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 000000000..8355b7858 --- /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 000000000..013548803 --- /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 000000000..4c353fc00 --- /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 000000000..90b0211e0 --- /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 000000000..06123b9da --- /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 000000000..b04df29e3 --- /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 000000000..ef32d598e --- /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 000000000..54665f7bd --- /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 000000000..d3a9419ae --- /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 000000000..bfe96e113 --- /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 000000000..3085c812d --- /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 000000000..cfb5725c5 --- /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 000000000..3de92af19 --- /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 000000000..20f1cfea3 --- /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 000000000..bc08cd01b --- /dev/null +++ b/internal/seniordev/core/spawner_test.go @@ -0,0 +1,165 @@ +//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) + } + if got.Cwd != root || got.Env != "value" { + t.Fatalf("helper: %+v", got) + } +} + +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/engine/calc/calc.go b/internal/seniordev/engine/calc/calc.go new file mode 100644 index 000000000..843df4a66 --- /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 000000000..561c74586 --- /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 000000000..3e7718464 --- /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 000000000..d82eb76ff --- /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 000000000..8097a6716 --- /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 000000000..d7eea3b20 --- /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 000000000..b9718a6bd --- /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 000000000..65cd6f0a9 --- /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 000000000..5e0235783 --- /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 000000000..8b830b692 --- /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 000000000..adfe88ac1 --- /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 000000000..b95f8882c --- /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 000000000..59dd66308 --- /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 000000000..b373c4db4 --- /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 000000000..a1198f608 --- /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 000000000..8896e31a6 --- /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 000000000..b20e0bc93 --- /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 000000000..fd99a7897 --- /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 000000000..3689b18f3 --- /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 000000000..9a25c34da --- /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 000000000..6fc8933fe --- /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 000000000..bb6005e92 --- /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 000000000..bb46495e4 --- /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 000000000..fbaded592 --- /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 000000000..bed401327 --- /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 000000000..5472350bd --- /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 `/` OpenRouter model id. + ModelID string + + Prompt []msgmodel.ModelMessage + MaxOutputTokens *float64 + + // Sampling parameters. A nil field is omitted from the body, so the + // serving provider's default applies; the caller decides what to set. + Temperature *float64 + TopP *float64 + TopK *float64 + MinP *float64 + Seed *float64 + FrequencyPenalty *float64 + PresencePenalty *float64 + RepetitionPenalty *float64 + + Tools []Tool + ToolChoice *ToolChoice + + // OpenRouterOptions is the merged provider option bag from config (base, + // model, agent and variant options, in that order). `cacheControl` is + // split out of it before the spread. + OpenRouterOptions *Object + + // Compatibility selects whether `stream_options` is emitted. + Compatibility string +} + +// BuildRequestBody returns the bytes POSTed to /chat/completions: the model and +// sampling parameters, the converted messages, the tool definitions, then any +// provider options from config (which may override a base field), then the +// streaming flags. +func BuildRequestBody(p RequestParams) ([]byte, error) { + body, err := baseArgs(p) + if err != nil { + return nil, err + } + + // Provider options are applied last with shallow-spread semantics. A + // cacheControl entry is renamed to the wire field cache_control unless the + // options already carry one. + var cacheControl json.RawMessage + if p.OpenRouterOptions != nil { + for _, m := range p.OpenRouterOptions.members { + if m.Key == "cacheControl" { + raw, err := marshalJSONValue(m.Value) + if err != nil { + return nil, err + } + cacheControl = raw + continue + } + body.set(m.Key, m.Value) + } + } + if cacheControl != nil && (p.OpenRouterOptions == nil || !p.OpenRouterOptions.Has("cache_control")) { + if err := body.Set("cache_control", cacheControl); err != nil { + return nil, err + } + } + + body.SetBool("stream", true) + if p.Compatibility == CompatibilityStrict { + streamOptions := NewObject() + streamOptions.SetBool("include_usage", true) + body.SetObject("stream_options", streamOptions) + } + return body.MarshalJSON() +} + +// baseArgs writes the request fields this client sets itself. A nil sampling +// parameter is omitted so the serving provider's default applies. +func baseArgs(p RequestParams) (*Object, error) { + base := NewObject() + base.SetString("model", p.ModelID) + base.SetNumberPtr("max_tokens", p.MaxOutputTokens) + base.SetNumberPtr("temperature", p.Temperature) + base.SetNumberPtr("top_p", p.TopP) + base.SetNumberPtr("frequency_penalty", p.FrequencyPenalty) + base.SetNumberPtr("presence_penalty", p.PresencePenalty) + base.SetNumberPtr("seed", p.Seed) + base.SetNumberPtr("top_k", p.TopK) + base.SetNumberPtr("min_p", p.MinP) + base.SetNumberPtr("repetition_penalty", p.RepetitionPenalty) + + messages, err := ConvertToOpenRouterChatMessages(p.Prompt) + if err != nil { + return nil, err + } + base.SetArray("messages", messages) + + if len(p.Tools) == 0 { + return base, nil + } + mapped := make([]*Object, 0, len(p.Tools)) + for _, tool := range p.Tools { + if tool.Type != "function" { + continue + } + entry := NewObject() + entry.SetString("type", "function") + fn := NewObject() + fn.SetString("name", tool.Name) + fn.SetString("description", tool.Description) + if len(tool.InputSchema) > 0 { + if err := fn.Set("parameters", tool.InputSchema); err != nil { + return nil, err + } + } + entry.SetObject("function", fn) + if eager, ok := openrouterNamespaceField(tool.ProviderOptions, "eager_input_streaming"); ok && !bytes.Equal(eager, []byte("null")) { + if err := entry.Set("eager_input_streaming", eager); err != nil { + return nil, err + } + } + mapped = append(mapped, entry) + } + base.SetArray("tools", mapped) + if p.ToolChoice != nil { + choice, err := chatCompletionToolChoice(*p.ToolChoice) + if err != nil { + return nil, err + } + base.set("tool_choice", choice) + } + return base, nil +} + +// chatCompletionToolChoice maps a ToolChoice to the wire `tool_choice` value. +func chatCompletionToolChoice(tc ToolChoice) (jsonValue, error) { + switch tc.Type { + case "auto", "none", "required": + return stringValue(tc.Type), nil + case "tool": + o := NewObject() + o.SetString("type", "function") + fn := NewObject() + fn.SetString("name", tc.ToolName) + o.SetObject("function", fn) + return o.value(), nil + } + rendered := NewObject() + rendered.SetString("type", tc.Type) + if tc.ToolName != "" { + rendered.SetString("toolName", tc.ToolName) + } + encoded, _ := rendered.MarshalJSON() + return jsonValue{}, &InvalidArgumentError{ + Argument: "toolChoice", + Message: "Invalid tool choice type: " + string(encoded), + } +} + +// InvalidArgumentError reports an unusable request parameter. +type InvalidArgumentError struct { + Argument string + Message string +} + +func (e *InvalidArgumentError) Error() string { return e.Message } + +// ── headers ─────────────────────────────────────────────────────────────── + +// HeaderPair is one final header, lowercase-named. +type HeaderPair struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// HeaderInputs are the header contributors, in the order they are combined. +type HeaderInputs struct { + // Provider is the provider-level header set before its user-agent + // suffix: Authorization, X-OpenRouter-Title, HTTP-Referer, then the + // configured provider headers (HTTP-Referer + X-Title). + Provider []HeaderPair + // ProviderUserAgentSuffix is appended to the user agent first. + ProviderUserAgentSuffix string + // Call is the per-call header set. + Call []HeaderPair + // UtilsUserAgentSuffix / RuntimeUserAgentSuffix are appended to the user + // agent last. + UtilsUserAgentSuffix string + RuntimeUserAgentSuffix string +} + +// BuildHeaders reproduces the whole merge, which is worth doing as one function +// because the precedence is genuinely surprising: `X-Title` and +// `X-OpenRouter-Title` are BOTH on the wire, `HTTP-Referer` is set three times +// with the call-level value winning, and `User-Agent`/`user-agent` collide only +// after normalizeHeaders lowercases them. +// +// The pipeline is: +// +// provider = withUserAgentSuffix(providerHeaders, providerSuffix) +// combined = {...provider, ...call} // case-SENSITIVE spread +// withType = {"Content-Type": "application/json", ...combined} +// final = withUserAgentSuffix(withType, utilsSuffix, runtimeSuffix) +// +// withUserAgentSuffix lowercases every name, joins the non-empty user-agent +// parts with a space, and returns the pairs sorted by name. +func BuildHeaders(in HeaderInputs) []HeaderPair { + provider := withUserAgentSuffix(in.Provider, in.ProviderUserAgentSuffix) + + combined := append([]HeaderPair{}, provider...) + combined = spreadHeaders(combined, in.Call) + + withType := append([]HeaderPair{{Name: "Content-Type", Value: "application/json"}}, nil...) + withType = spreadHeaders(withType, combined) + + return withUserAgentSuffix(withType, in.UtilsUserAgentSuffix, in.RuntimeUserAgentSuffix) +} + +// spreadHeaders is the `{...a, ...b}` object spread: case-SENSITIVE, later +// wins, new keys appended. +func spreadHeaders(target, source []HeaderPair) []HeaderPair { + out := append([]HeaderPair{}, target...) + for _, s := range source { + replaced := false + for i := range out { + if out[i].Name == s.Name { + out[i].Value = s.Value + replaced = true + break + } + } + if !replaced { + out = append(out, s) + } + } + return out +} + +func withUserAgentSuffix(headers []HeaderPair, suffixes ...string) []HeaderPair { + normalized := []HeaderPair{} + for _, h := range headers { + lower := strings.ToLower(h.Name) + replaced := false + for i := range normalized { + if normalized[i].Name == lower { + normalized[i].Value = h.Value + replaced = true + break + } + } + if !replaced { + normalized = append(normalized, HeaderPair{Name: lower, Value: h.Value}) + } + } + + current := "" + for _, h := range normalized { + if h.Name == "user-agent" { + current = h.Value + break + } + } + parts := make([]string, 0, len(suffixes)+1) + if current != "" { + parts = append(parts, current) + } + for _, s := range suffixes { + if s != "" { + parts = append(parts, s) + } + } + ua := strings.Join(parts, " ") + + set := false + for i := range normalized { + if normalized[i].Name == "user-agent" { + normalized[i].Value = ua + set = true + break + } + } + if !set { + normalized = append(normalized, HeaderPair{Name: "user-agent", Value: ua}) + } + + sort.SliceStable(normalized, func(i, j int) bool { + return normalized[i].Name < normalized[j].Name + }) + return normalized +} diff --git a/internal/seniordev/engine/orclient/cancellation_test.go b/internal/seniordev/engine/orclient/cancellation_test.go new file mode 100644 index 000000000..58488377a --- /dev/null +++ b/internal/seniordev/engine/orclient/cancellation_test.go @@ -0,0 +1,148 @@ +//go:build !windows + +package orclient + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestCallerCancellationNeutralBeforeHeadersAndOnUndrainedClose(t *testing.T) { + for _, closeOnly := range []bool{false, true} { + t.Run(map[bool]string{false: "before-headers", true: "undrained-close"}[closeOnly], func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + cause := errors.New("caller gave up") + router := &spyRouter{inflight: 1} + client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + if !closeOnly { + cancel(cause) + return nil, context.Cause(req.Context()) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil + }} + stream, err := client.DoStream(ctx, RequestParams{ModelID: "test"}) + if closeOnly { + if err != nil { + t.Fatal(err) + } + cancel(cause) + if err = stream.Close(); err != nil { + t.Fatal(err) + } + if err = stream.Close(); err != nil { + t.Fatal(err) + } + } else if !errors.Is(err, cause) { + t.Fatalf("err=%v", err) + } + if router.canceled != 1 || len(router.calls) != 0 || router.inflight != 0 { + t.Fatalf("canceled=%d calls=%d inflight=%d", router.canceled, len(router.calls), router.inflight) + } + }) + } +} + +func TestProviderFailureIsNotHiddenByLaterCallerCancellation(t *testing.T) { + for _, watchdog := range []bool{false, true} { + t.Run(map[bool]string{false: "http-502", true: "watchdog-first"}[watchdog], func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + router := &spyRouter{inflight: 1} + var fire func() + t.Cleanup(SetTimerFactoryForTesting(func(_ float64, fn func()) Timer { fire = fn; return &cancellationTestTimer{} })) + client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + if watchdog { + fire() + cancel(errors.New("caller gave up")) + return nil, context.Cause(req.Context()) + } + cancel(errors.New("caller gave up")) + return &http.Response{StatusCode: 502, Body: io.NopCloser(strings.NewReader("provider unavailable"))}, nil + }} + if _, err := client.DoStream(ctx, RequestParams{ModelID: "test"}); err == nil { + t.Fatal("provider failure suppressed") + } + if router.canceled != 0 || len(router.calls) != 1 || router.calls[0].err == nil || router.inflight != 0 { + t.Fatalf("canceled=%d calls=%+v inflight=%d", router.canceled, router.calls, router.inflight) + } + if watchdog && !adaptive.IsLikelyTimeout(router.calls[0].err) { + t.Fatal("watchdog timeout taxonomy changed") + } + }) + } +} + +type cancellationTestTimer struct{} + +func (*cancellationTestTimer) Stop() {} + +func TestCompletedSuccessWinsOverLaterCancellation(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + router := &spyRouter{inflight: 1} + client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n"))}, nil + }} + stream, err := client.DoStream(ctx, RequestParams{ModelID: "test"}) + if err != nil { + t.Fatal(err) + } + if _, err = stream.Parts(); err != nil { + t.Fatal(err) + } + cancel(errors.New("caller gave up")) + if err = stream.Close(); err != nil { + t.Fatal(err) + } + if err = stream.Close(); err != nil { + t.Fatal(err) + } + if router.canceled != 0 || len(router.calls) != 1 || router.calls[0].err != nil || router.inflight != 0 { + t.Fatalf("completed success reclassified: canceled=%d calls=%+v inflight=%d", router.canceled, router.calls, router.inflight) + } +} + +func TestActualParentDeadlineAndMidstreamAbortAreNeutral(t *testing.T) { + for _, midstream := range []bool{false, true} { + t.Run(map[bool]string{false: "deadline-before-headers", true: "deadline-in-stream"}[midstream], func(t *testing.T) { + ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Millisecond, errors.New("caller deadline")) + defer cancel() + router := &spyRouter{inflight: 1} + client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + if midstream { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil + } + <-req.Context().Done() + return nil, req.Context().Err() + }} + stream, err := client.DoStream(ctx, RequestParams{ModelID: "test"}) + if midstream { + if err != nil { + t.Fatal(err) + } + <-ctx.Done() + parts, readErr := stream.Parts() + if readErr != nil || len(parts) != 1 { + t.Fatalf("parts=%+v err=%v", parts, readErr) + } + if _, ok := parts[0].(AbortPart); !ok { + t.Fatalf("part=%T", parts[0]) + } + _ = stream.Close() + } else if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err=%v", err) + } + if router.canceled != 1 || len(router.calls) != 0 || router.inflight != 0 { + t.Fatalf("canceled=%d calls=%d inflight=%d", router.canceled, len(router.calls), router.inflight) + } + }) + } +} diff --git a/internal/seniordev/engine/orclient/client.go b/internal/seniordev/engine/orclient/client.go new file mode 100644 index 000000000..998b3a44d --- /dev/null +++ b/internal/seniordev/engine/orclient/client.go @@ -0,0 +1,700 @@ +//go:build !windows + +package orclient + +// One HTTP request, one stream. +// +// ── the four abort layers ───────────────────────────────────────────────── +// +// A model may stream useful reasoning for longer than ten minutes. Elapsed +// request age is therefore not evidence that it has stalled. senior-dev leaves the +// total-request deadline disabled by default and composes cancellation through +// the caller context plus a progress-sensitive reader watchdog: +// +// callerCtx ← run lifetime +// └─ ctxTotal = callerCtx ← default +// or context.WithTimeout(callerCtx, configured duration) ← explicit opt-in +// └─ ctxChunk = context.WithCancelCause(ctxTotal) ← inactivity +// └─ req = req.WithContext(ctxChunk) +// └─ fetcher may derive its own context +// +// - The caller context is the authoritative lifetime bound: the run can +// cancel every request at once, and Stream.Close cancels an abandoned +// request immediately. +// - TotalTimeoutMS > 0 is an explicit operator override. Its cause remains +// `errors.New("The operation timed out.")`, preserving timeout routing and +// retry classification for configured deployments. +// - The 120 s reader watchdog resets after every read, including reasoning +// tokens and keepalives. It aborts only when transport progress stops, with +// `errors.New("SSE read timed out")`. +// +// ── early teardown must cancel, not Close ──────────────────────────────── +// +// Closing a response body does not by itself cancel an in-flight request, so +// `Stream.Close` cancels the request context FIRST and treats `Body.Close()` +// as best-effort cleanup. That is what lets a stream be abandoned mid-way +// (for example when compaction is needed) without leaking a connection. +// +// ── router registration ────────────────────────────────────────────────── +// +// The route lease taken for a request is settled exactly once: a finished or +// failed stream registers its outcome, a caller-cancelled stream releases the +// lease without health credit, and a stream abandoned through Close before it +// finished is released the same way so the router's in-flight count never +// leaks. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "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/router/adaptive" +) + +// Timeout defaults. Total request age is not a stall signal; only the +// progress-sensitive reader watchdog is enabled by default. +const ( + // DefaultTimeoutMS disables the total-request deadline. A positive + // TotalTimeoutMS on Client opts back into one. + DefaultTimeoutMS float64 = -1 + // DefaultChunkTimeoutMS is the reader watchdog's inactivity bound. + DefaultChunkTimeoutMS float64 = 120_000 +) + +// Abort cause messages. Both are matched by `adaptive.IsLikelyTimeout` and +// `retrysched.IsTimeoutError`, which is the whole reason they are literals. +var ( + // ErrOperationTimedOut is the total-request deadline's cause. + ErrOperationTimedOut = errors.New("The operation timed out.") + // ErrSSEReadTimedOut is the reader watchdog's cause. + ErrSSEReadTimedOut = errors.New("SSE read timed out") +) + +// ── seams ───────────────────────────────────────────────────────────────── + +// Fetcher performs one HTTP round trip. The default is a plain +// http.DefaultClient; the CLI installs its own configured client per Client. +type Fetcher func(req *http.Request) (*http.Response, error) + +var fetcher Fetcher = http.DefaultClient.Do + +// SetFetcherForTesting swaps the fetch seam. Returns a restore func. +func SetFetcherForTesting(f Fetcher) func() { + seamMu.Lock() + prev := fetcher + fetcher = f + seamMu.Unlock() + return func() { + seamMu.Lock() + fetcher = prev + seamMu.Unlock() + } +} + +func currentFetcher() Fetcher { + seamMu.Lock() + defer seamMu.Unlock() + return fetcher +} + +// Timer / TimerFactory are the reader watchdog's timer seam, so tests can +// drive it on a virtual clock. +type Timer interface{ Stop() } + +type TimerFactory func(ms float64, fn func()) Timer + +// TimeoutContextFactory mirrors context.WithTimeoutCause for the layer-2 +// total-request deadline. Keeping this as a separate seam preserves the +// production context's real Deadline while allowing tests to fire the +// deadline without waiting on wall-clock time. +type TimeoutContextFactory func(context.Context, time.Duration, error) (context.Context, context.CancelFunc) + +type realTimer struct{ t *time.Timer } + +func (r realTimer) Stop() { r.t.Stop() } + +var timerFactory TimerFactory = func(ms float64, fn func()) Timer { + return realTimer{t: time.AfterFunc(time.Duration(ms)*time.Millisecond, fn)} +} + +var timeoutContextFactory TimeoutContextFactory = context.WithTimeoutCause + +// SetTimerFactoryForTesting swaps the watchdog timer. Returns a restore func. +func SetTimerFactoryForTesting(f TimerFactory) func() { + seamMu.Lock() + prev := timerFactory + timerFactory = f + seamMu.Unlock() + return func() { + seamMu.Lock() + timerFactory = prev + seamMu.Unlock() + } +} + +func currentTimerFactory() TimerFactory { + seamMu.Lock() + defer seamMu.Unlock() + return timerFactory +} + +// SetTimeoutContextFactoryForTesting swaps the total-request timeout seam. +// Returns a restore func. +func SetTimeoutContextFactoryForTesting(f TimeoutContextFactory) func() { + seamMu.Lock() + prev := timeoutContextFactory + timeoutContextFactory = f + seamMu.Unlock() + return func() { + seamMu.Lock() + timeoutContextFactory = prev + seamMu.Unlock() + } +} + +func currentTimeoutContextFactory() TimeoutContextFactory { + seamMu.Lock() + defer seamMu.Unlock() + return timeoutContextFactory +} + +// ── router ──────────────────────────────────────────────────────────────── + +// RouterRegistrar is the narrow slice of *adaptive.AdaptiveModelRouter this +// package uses to settle a route lease. +type RouterRegistrar interface { + Register(choice adaptive.RouteChoice, elapsedSeconds, completionTokens float64, err error) adaptive.AdaptiveRouteEvent + RegisterCanceled(choice adaptive.RouteChoice) +} + +// ── client ──────────────────────────────────────────────────────────────── + +// Client is one configured OpenRouter endpoint. +type Client struct { + // BaseURL defaults to https://openrouter.ai/api/v1. + BaseURL string + // Headers is BuildHeaders' output. + Headers []HeaderPair + // Compatibility selects whether `stream_options` is emitted; senior-dev uses + // "compatible". + Compatibility string + + // TotalTimeoutMS / ChunkTimeoutMS control the optional total-request bound + // and the progress-sensitive reader watchdog. Zero means the default; a + // negative value disables the corresponding mechanism. + TotalTimeoutMS float64 + ChunkTimeoutMS float64 + + // Fetcher overrides the package fetch seam for this client only. Embedders + // use it to retain their configured HTTP transport without mutating the + // process-wide testing seam. + Fetcher Fetcher + + // Router and RouteChoice drive the exactly-once registration. Both nil + // means no routing was performed and registration is skipped entirely. + Router RouterRegistrar + RouteChoice *adaptive.RouteChoice +} + +// Stream is one in-flight response. It is NOT safe for concurrent use; the one +// concurrency rule that matters is that Close may be called from another +// goroutine, which is exactly what an early teardown needs. +type Stream struct { + parts []StreamPart + next int + + translator *Translator + decoder *SSEDecoder + body io.ReadCloser + response *http.Response + + ctx context.Context + callerCtx context.Context + cancel context.CancelCauseFunc + stopTot context.CancelFunc + chunkMS float64 + + closeOnce sync.Once + bodyOnce sync.Once + bodyErr error + + closedByAPI atomic.Bool + + watchdogMu sync.Mutex + watchdog Timer + watchdogGeneration atomic.Uint64 + watchdogStopped atomic.Bool + + finished bool + failed error + + registerOnce sync.Once + register func(completionTokens float64, err error) + // release settles the route lease for a stream abandoned before it + // finished: no success, failure or latency sample is attributed. + release func() +} + +// Response exposes the HTTP response (status + headers) for the error taxonomy +// the caller layers on top. The body is owned by the Stream. +func (s *Stream) Response() *http.Response { return s.response } + +// DoStream issues the request and returns a Stream positioned before the first +// part, with the abort layers and the router bookkeeping installed. +func (c *Client) DoStream(ctx context.Context, params RequestParams) (*Stream, error) { + if params.Compatibility == "" { + params.Compatibility = c.Compatibility + } + body, err := BuildRequestBody(params) + if err != nil { + return nil, err + } + + base := c.BaseURL + if base == "" { + base = "https://openrouter.ai/api/v1" + } + base = strings.TrimRight(base, "/") + + routeStart := currentNow()() + stream := &Stream{ + translator: NewTranslator(), + chunkMS: c.chunkTimeout(), + callerCtx: ctx, + } + stream.register = func(completionTokens float64, failure error) { + if c.Router == nil || c.RouteChoice == nil { + return + } + if stream.callerCanceled(failure) { + c.Router.RegisterCanceled(*c.RouteChoice) + return + } + elapsed := (currentNow()() - routeStart) / 1000 + // Register invokes the configured event hook, so nothing is emitted here. + c.Router.Register(*c.RouteChoice, elapsed, completionTokens, failure) + } + stream.release = func() { + if c.Router != nil && c.RouteChoice != nil { + c.Router.RegisterCanceled(*c.RouteChoice) + } + } + + // Layer 2 total, then layers 2-chunk/3. + ctxTotal := ctx + if total := c.totalTimeout(); total > 0 { + ctxTotal, stream.stopTot = currentTimeoutContextFactory()(ctx, + time.Duration(total)*time.Millisecond, ErrOperationTimedOut) + } + ctxChunk, cancel := context.WithCancelCause(ctxTotal) + stream.ctx = ctxChunk + stream.cancel = cancel + + req, err := http.NewRequestWithContext(ctxChunk, http.MethodPost, base+"/chat/completions", bytes.NewReader(body)) + if err != nil { + stream.teardown() + stream.registerOnce.Do(func() { stream.register(0, err) }) + return nil, err + } + for _, h := range c.Headers { + // Assigned directly rather than through Set so the lowercase names + // BuildHeaders produced go out as-is; HTTP header names are + // case-insensitive on the wire. + req.Header[h.Name] = []string{h.Value} + } + + // Start the inactivity clock before dispatch so a connection that never + // produces response headers cannot occupy the rest of the run. Once headers + // arrive, the same watchdog is reset and follows every response-body read. + stream.armWatchdog() + fetch := c.Fetcher + if fetch == nil { + fetch = currentFetcher() + } + resp, err := fetch(req) + if err != nil { + stream.teardown() + stream.registerOnce.Do(func() { stream.register(0, err) }) + return nil, err + } + stream.response = resp + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Error classification is the caller's; this layer only has to make + // the status classifiable and release the route. + payload, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + stream.teardown() + body := string(payload) + failure := retrysched.NewProviderError( + statusMessage(resp, payload), float64(resp.StatusCode), + retrysched.HeaderPairs(resp.Header), &body, + ) + stream.registerOnce.Do(func() { stream.register(0, failure) }) + return nil, failure + } + if resp.Body == nil { + stream.teardown() + failure := errors.New("Empty response body") + stream.registerOnce.Do(func() { stream.register(0, failure) }) + return nil, failure + } + + stream.body = resp.Body + stream.bumpWatchdog() + stream.decoder = NewSSEDecoder(watchdogReader{stream: stream, body: resp.Body}) + return stream, nil +} + +func (c *Client) totalTimeout() float64 { + if c.TotalTimeoutMS == 0 { + return DefaultTimeoutMS + } + return c.TotalTimeoutMS +} + +func (c *Client) chunkTimeout() float64 { + if c.ChunkTimeoutMS == 0 { + return DefaultChunkTimeoutMS + } + return c.ChunkTimeoutMS +} + +func statusMessage(resp *http.Response, payload []byte) string { + // Decode `{error:{message}}` and fall back to the status line. An empty + // payload becomes " (no body)", which the overflow classifier + // recognises. + if len(bytes.TrimSpace(payload)) == 0 { + return fmt.Sprintf("%d (no body)", resp.StatusCode) + } + if chunk := ParseChunk(string(payload)); chunk.Success && chunk.Value != nil && chunk.Value.ErrorField != nil { + if obj, err := ParseObject(chunk.Value.ErrorField); err == nil { + if msg, ok := obj.Get("message"); ok { + return rawString(msg) + } + } + } + if resp.Status != "" { + return resp.Status + } + return fmt.Sprintf("%d", resp.StatusCode) +} + +// ── the drain ───────────────────────────────────────────────────────────── + +// Next returns the next stream part. It returns io.EOF exactly once, after the +// `finish` part. +// +// An abort — from any of the four layers — surfaces as `{type:"abort", +// reason:getErrorMessage(cause)}` and the stream then ends cleanly. The cause +// still goes to router registration, where its exact message is what the +// cooldown and retry classifiers substring-match. +func (s *Stream) Next() (StreamPart, error) { + if s.next < len(s.parts) { + p := s.parts[s.next] + s.next++ + return p, nil + } + if s.failed != nil { + return nil, s.failed + } + if s.finished { + return nil, io.EOF + } + if s.closedByAPI.Load() { + s.finished = true + return nil, io.EOF + } + + for { + ev, err := s.decoder.Next() + if err != nil { + if s.closedByAPI.Load() { + s.finished = true + s.stopAndClose() + return nil, io.EOF + } + if cause := s.abortCause(); cause != nil { + s.parts = []StreamPart{AbortPart{Reason: cause.Error(), HasReason: true}} + s.next = 1 + s.finished = true + s.finishRegister(cause) + s.stopAndClose() + return s.parts[0], nil + } + var registerFailure error + if err != io.EOF { + // A mid-stream READ error surfaces as an `error` PART at + // flush, never as a returned error. + s.translator.SetStreamError(readErrorValue(err)) + registerFailure = err + } + s.parts = s.translator.Flush() + s.finished = true + s.finishRegister(registerFailure) + s.stopAndClose() + if len(s.parts) == 0 { + return nil, io.EOF + } + s.next = 1 + return s.parts[0], nil + } + if ev.Data == DoneSentinel { + continue + } + emitted, err := s.translator.Transform(ParseChunk(ev.Data)) + if len(emitted) > 0 { + s.parts = emitted + s.next = 1 + for _, part := range emitted { + if streamError, ok := part.(ErrorPart); ok { + // An in-band error releases the route immediately. A + // later finish/Close is suppressed by Once. + s.finishRegister(errorPartFailure(streamError.Error)) + break + } + } + if err != nil { + // A throw tears the stream down: the parts already enqueued + // are delivered, then the error. + s.failed = err + s.finishRegister(err) + s.stopAndClose() + } + return s.parts[0], nil + } + if err != nil { + s.failed = err + s.finishRegister(err) + s.stopAndClose() + return nil, err + } + } +} + +func errorPartFailure(raw []byte) error { + if obj, err := ParseObject(raw); err == nil { + if message, ok := obj.Get("message"); ok && rawString(message) != "" { + return streamProviderError{message: rawString(message), body: string(raw)} + } + if data, ok := obj.Get("data"); ok { + if nested, nestedErr := ParseObject(data); nestedErr == nil { + if message, ok := nested.Get("message"); ok && rawString(message) != "" { + return streamProviderError{message: rawString(message), body: string(raw)} + } + } + } + } + if len(raw) == 0 { + return errors.New("openrouter stream error") + } + return fmt.Errorf("openrouter stream error: %s", raw) +} + +// streamProviderError is an in-band error payload from a model stream. The +// raw body is exposed as detail so the router classifiers can match provider +// fields such as error_type. +type streamProviderError struct { + message string + body string +} + +func (failure streamProviderError) Error() string { return failure.message } +func (failure streamProviderError) ErrorDetail() string { return failure.body } + +// Parts drains the whole stream. Convenience for callers that do not need +// incremental delivery — and for tests. +func (s *Stream) Parts() ([]StreamPart, error) { + var out []StreamPart + for { + p, err := s.Next() + if err == io.EOF { + return out, nil + } + if err != nil { + return out, err + } + out = append(out, p) + } +} + +// finishRegister fires the exactly-once router registration with the stream's +// output token count (0 on failure). +func (s *Stream) finishRegister(failure error) { + s.registerOnce.Do(func() { + completion := float64(0) + if failure == nil { + for _, p := range s.parts { + finish, ok := p.(FinishPart) + if !ok { + continue + } + flat := calc.AsLanguageModelUsage(finish.Usage) + if flat.OutputTokens != nil { + completion = *flat.OutputTokens + } + } + } + s.register(completion, failure) + }) +} + +// Close abandons the stream. It cancels the request context first and only +// then closes the body as best-effort cleanup. Safe to call from another +// goroutine, and safe to call twice. +func (s *Stream) Close() error { + var err error + s.closeOnce.Do(func() { + // Preserve the cancellation origin before teardown manufactures a + // local Close cause. Closing a caller-aborted stream still owns its + // lease even when Next did not drain the abort part. + if cause := s.abortCause(); s.callerCanceled(cause) { + s.finishRegister(cause) + } + s.closedByAPI.Store(true) + s.teardown() + err = s.closeBody() + // A stream abandoned before it finished releases its route lease. + s.registerOnce.Do(func() { + if s.release != nil { + s.release() + } + }) + }) + return err +} + +func (s *Stream) callerCanceled(failure error) bool { + if failure == nil || s.callerCtx == nil || s.callerCtx.Err() == nil || s.ctx == nil { + return false + } + cause := context.Cause(s.callerCtx) + // A child watchdog/provider timeout that won the cancellation race must + // still count as a provider failure even if the parent cancels later. + return errors.Is(context.Cause(s.ctx), cause) && + (errors.Is(failure, cause) || errors.Is(failure, s.callerCtx.Err())) +} + +func (s *Stream) teardown() { + s.watchdogStopped.Store(true) + s.watchdogGeneration.Add(1) + s.watchdogMu.Lock() + if s.watchdog != nil { + s.watchdog.Stop() + s.watchdog = nil + } + s.watchdogMu.Unlock() + if s.cancel != nil { + s.cancel(context.Canceled) + } + if s.stopTot != nil { + s.stopTot() + } +} + +// armWatchdog starts the collapsed layer-2-chunk / layer-3 timer. +func (s *Stream) armWatchdog() { + if s.chunkMS <= 0 || s.watchdogStopped.Load() { + return + } + generation := s.watchdogGeneration.Add(1) + timer := currentTimerFactory()(s.chunkMS, func() { + if s.watchdogStopped.Load() || s.watchdogGeneration.Load() != generation { + return + } + if s.cancel != nil { + s.cancel(ErrSSEReadTimedOut) + } + }) + s.watchdogMu.Lock() + if s.watchdogStopped.Load() || s.watchdogGeneration.Load() != generation { + timer.Stop() + } else { + s.watchdog = timer + } + s.watchdogMu.Unlock() +} + +// bumpWatchdog re-arms the per-read timer on every read, so a slow but +// progressing stream never trips it. +func (s *Stream) bumpWatchdog() { + if s.chunkMS <= 0 || s.watchdogStopped.Load() { + return + } + s.watchdogMu.Lock() + if s.watchdog != nil { + s.watchdog.Stop() + s.watchdog = nil + } + s.watchdogMu.Unlock() + s.armWatchdog() +} + +// abortCause resolves the context cause to the manufactured abort reason — +// `The operation timed out.` (layer 2), `SSE read timed out` (layers 2-chunk/3), +// or whatever the caller cancelled its own context with (layer 1). +// +// A caller-initiated Close is NOT a failure: post-Close reads report the end +// of the stream. +func (s *Stream) abortCause() error { + if s.closedByAPI.Load() || s.ctx == nil || s.ctx.Err() == nil { + return nil + } + cause := context.Cause(s.ctx) + if cause == nil { + return s.ctx.Err() + } + return cause +} + +func (s *Stream) closeBody() error { + s.bodyOnce.Do(func() { + if s.body != nil { + s.bodyErr = s.body.Close() + } + }) + return s.bodyErr +} + +func (s *Stream) stopAndClose() { + s.teardown() + _ = s.closeBody() +} + +// watchdogReader resets the collapsed layer-2/3 timer for every underlying +// body read, including comment-only keepalives and partial SSE +// frames. Resetting only after a complete event would time out a healthy +// OpenRouter stream whose `: OPENROUTER PROCESSING` comments keep arriving. +type watchdogReader struct { + stream *Stream + body io.Reader +} + +func (r watchdogReader) Read(p []byte) (int, error) { + n, err := r.body.Read(p) + if n > 0 { + r.stream.bumpWatchdog() + } + return n, err +} + +// readErrorValue renders a mid-stream reader error as `{name, message}` so the +// error part carries the reason. +func readErrorValue(err error) []byte { + w := newObjectWriter() + w.str("name", "Error") + w.str("message", err.Error()) + out, encodeErr := w.done() + if encodeErr != nil { + return []byte(`{"name":"Error"}`) + } + return out +} diff --git a/internal/seniordev/engine/orclient/client_test.go b/internal/seniordev/engine/orclient/client_test.go new file mode 100644 index 000000000..9a4c27fd9 --- /dev/null +++ b/internal/seniordev/engine/orclient/client_test.go @@ -0,0 +1,1135 @@ +//go:build !windows + +package orclient + +// httptest-driven wire tests. +// +// Covered here: SSE framing against a real socket (CRLF, a frame split across +// two TCP writes, keepalive comments, `[DONE]`, a trailing frame with no +// terminating blank line), the reader watchdog and its EXACT abort message, +// the total-request timeout and its EXACT abort message, the early-teardown +// rule (cancel the context, do not merely close the body), goroutine +// cleanliness, and router registration exactly-once on success / failure / +// abandonment. + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// ── helpers ─────────────────────────────────────────────────────────────── + +// sseServer serves a handler over httptest and returns a Client pointed at it. +// The fetch seam is swapped to the test's own http.Client so the test +// exercises this package's plumbing against a local listener. +func sseServer(t *testing.T, handler http.HandlerFunc) (*Client, func()) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + // Some hermetic runners prohibit loopback sockets entirely. Keep the + // tests as real httptest servers wherever sockets exist, and report a + // capability skip rather than letting httptest panic in that sandbox. + t.Skipf("loopback sockets unavailable: %v", err) + } + srv := &httptest.Server{ + Listener: listener, + Config: &http.Server{Handler: handler}, + } + srv.Start() + httpClient := srv.Client() + restore := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + return httpClient.Do(req) + }) + c := &Client{BaseURL: srv.URL, Compatibility: CompatibilityCompatible} + return c, func() { + restore() + srv.Close() + } +} + +func writeSSE(w http.ResponseWriter, chunks ...string) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + for _, c := range chunks { + _, _ = io.WriteString(w, c) + if flusher != nil { + flusher.Flush() + } + } +} + +func minimalParams() RequestParams { + return RequestParams{ModelID: "vendor/model"} +} + +func partTypes(parts []StreamPart) []string { + out := make([]string, 0, len(parts)) + for _, p := range parts { + out = append(out, p.PartType()) + } + return out +} + +// ── framing ─────────────────────────────────────────────────────────────── + +func TestWireFramingOverHTTP(t *testing.T) { + cases := []struct { + name string + chunks []string + want []string + }{ + { + name: "lf frames", + chunks: []string{"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n", "data: [DONE]\n\n"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "crlf frames", + chunks: []string{"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\r\n\r\n", "data: [DONE]\r\n\r\n"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "frame split across two writes", + chunks: []string{ + "data: {\"choices\":[{\"delta\":", + "{\"content\":\"a\"}}]}\n\n", + "data: [DONE]\n\n", + }, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "keepalive comments are ignored", + chunks: []string{ + ": OPENROUTER PROCESSING\n\n", + ": OPENROUTER PROCESSING\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n", + "data: [DONE]\n\n", + }, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "trailing frame with no blank line is still dispatched", + chunks: []string{"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "multiple data lines in one frame join with a newline", + chunks: []string{"data: {\"choices\":[{\"delta\":\ndata: {\"content\":\"a\"}}]}\n\n", "data: [DONE]\n\n"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "empty body yields only finish", + chunks: []string{"data: [DONE]\n\n"}, + want: []string{PartTypeFinish}, + }, + { + name: "malformed frame becomes an error part, not a failure", + chunks: []string{"data: {not json\n\n", "data: [DONE]\n\n"}, + want: []string{PartTypeError, PartTypeFinish}, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, tc.chunks...) + }) + defer cleanup() + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("drain: %v", err) + } + got := partTypes(parts) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("part types:\n want %v\n got %v", tc.want, got) + } + }) + } +} + +func TestWireRequestBodyAndHeadersReachTheServer(t *testing.T) { + var gotBody []byte + var gotHeader http.Header + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotHeader = r.Header.Clone() + writeSSE(w, "data: [DONE]\n\n") + }) + defer cleanup() + + c.Headers = []HeaderPair{ + {Name: "authorization", Value: "Bearer KEY"}, + {Name: "content-type", Value: "application/json"}, + {Name: "x-session-affinity", Value: "ses_1"}, + } + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + + want := `{"model":"vendor/model","messages":[],"stream":true}` + if string(gotBody) != want { + t.Errorf("body:\n want %s\n got %s", want, gotBody) + } + if got := gotHeader.Get("X-Session-Affinity"); got != "ses_1" { + t.Errorf("x-session-affinity: got %q", got) + } + if got := gotHeader.Get("Authorization"); got != "Bearer KEY" { + t.Errorf("authorization: got %q", got) + } +} + +func TestWireClientFetcherOverridesPackageSeam(t *testing.T) { + packageCalls := 0 + restore := SetFetcherForTesting(func(*http.Request) (*http.Response, error) { + packageCalls++ + return nil, errors.New("package fetcher should not run") + }) + defer restore() + + clientCalls := 0 + client := &Client{ + BaseURL: "http://provider.invalid/api/v1", + Fetcher: func(request *http.Request) (*http.Response, error) { + clientCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + Request: request, + }, nil + }, + } + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatal(err) + } + if clientCalls != 1 || packageCalls != 0 { + t.Fatalf("client fetches=%d package fetches=%d", clientCalls, packageCalls) + } +} + +// ── abort layers ────────────────────────────────────────────────────────── + +func TestClientDefaultHasNoTotalDeadlineAndKeepsProgressWatchdog(t *testing.T) { + totalContexts := 0 + restoreTimeout := SetTimeoutContextFactoryForTesting(func(parent context.Context, _ time.Duration, _ error) (context.Context, context.CancelFunc) { + totalContexts++ + return context.WithCancel(parent) + }) + defer restoreTimeout() + + var armed []float64 + restoreTimer := SetTimerFactoryForTesting(func(ms float64, _ func()) Timer { + armed = append(armed, ms) + return fakeTimer{} + }) + defer restoreTimer() + + client := &Client{Fetcher: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + Request: req, + }, nil + }} + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + + if totalContexts != 0 { + t.Fatalf("default client installed %d total-request deadlines, want none", totalContexts) + } + if len(armed) == 0 { + t.Fatal("default client did not arm the progress-sensitive reader watchdog") + } + for _, ms := range armed { + if ms != DefaultChunkTimeoutMS { + t.Fatalf("reader watchdog = %vms, want %vms", ms, DefaultChunkTimeoutMS) + } + } +} + +func TestClientProgressWatchdogCoversResponseHeaders(t *testing.T) { + var fire func() + restoreTimer := SetTimerFactoryForTesting(func(_ float64, fn func()) Timer { + fire = fn + return fakeTimer{} + }) + defer restoreTimer() + + client := &Client{Fetcher: func(req *http.Request) (*http.Response, error) { + if fire == nil { + t.Fatal("reader watchdog was not armed before request dispatch") + } + fire() + <-req.Context().Done() + return nil, context.Cause(req.Context()) + }} + _, err := client.DoStream(context.Background(), minimalParams()) + if !errors.Is(err, ErrSSEReadTimedOut) { + t.Fatalf("DoStream error = %v, want %v", err, ErrSSEReadTimedOut) + } +} + +func TestWireChunkWatchdogProducesTheLayer3Message(t *testing.T) { + var fire func() + restoreTimer := SetTimerFactoryForTesting(func(_ float64, fn func()) Timer { + fire = fn + return fakeTimer{} + }) + defer restoreTimer() + + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.ChunkTimeoutMS = 30 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + fire() + parts, err := stream.Parts() + if err != nil { + t.Fatalf("an abort must close the stream cleanly: %v", err) + } + abort := requireAbortPart(t, parts) + if abort.Reason != "SSE read timed out" { + t.Errorf("abort message: want %q, got %q", "SSE read timed out", abort.Reason) + } + // The text is the contract: it is what the router's cooldown class and + // retrysched.IsTimeoutError match on. + abortErr := errors.New(abort.Reason) + if !adaptive.IsLikelyTimeout(abortErr) { + t.Error("adaptive.IsLikelyTimeout must classify the collapsed layer-2/3 abort as a timeout") + } + message := abort.Reason + if !retrysched.IsTimeoutError(retrysched.Err{Name: "APIError", Data: retrysched.ErrData{Message: &message}}) { + t.Error("retrysched.IsTimeoutError must classify the collapsed layer-2/3 abort as a timeout") + } +} + +func TestWireTotalTimeoutProducesTheAbortSignalMessage(t *testing.T) { + fire := make(chan context.CancelCauseFunc, 1) + restoreTimeout := SetTimeoutContextFactoryForTesting(func(parent context.Context, _ time.Duration, cause error) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancelCause(parent) + fire <- func(error) { cancel(cause) } + return ctx, func() { cancel(context.Canceled) } + }) + defer restoreTimeout() + + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.TotalTimeoutMS = 30 + c.ChunkTimeoutMS = -1 // disable the reader watchdog so layer 2 wins + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + (<-fire)(nil) + parts, err := stream.Parts() + if err != nil { + t.Fatalf("an abort must close the stream cleanly: %v", err) + } + abort := requireAbortPart(t, parts) + if abort.Reason != "The operation timed out." { + t.Errorf("abort message: want %q, got %q", "The operation timed out.", abort.Reason) + } + // This string contains "timed out", not "timeout"; the classifier must + // still fire. + if !adaptive.IsLikelyTimeout(errors.New(abort.Reason)) { + t.Error("adaptive.IsLikelyTimeout must classify the layer-2 abort as a timeout") + } +} + +func TestWireCallerCancellationSurfacesItsOwnCause(t *testing.T) { + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.ChunkTimeoutMS = -1 + ctx, cancel := context.WithCancelCause(context.Background()) + stream, err := c.DoStream(ctx, minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + callerCause := errors.New("caller gave up") + cancel(callerCause) + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("an abort must close the stream cleanly: %v", err) + } + abort := requireAbortPart(t, parts) + if abort.Reason != callerCause.Error() { + t.Fatalf("want the caller's own cause, got %q", abort.Reason) + } +} + +func requireAbortPart(t *testing.T, parts []StreamPart) AbortPart { + t.Helper() + for _, part := range parts { + if abort, ok := part.(AbortPart); ok { + return abort + } + } + t.Fatalf("expected an abort part, got %v", partTypes(parts)) + return AbortPart{} +} + +func TestWireCommentKeepalivesResetTheReadWatchdog(t *testing.T) { + var arms int + var stops int + var stopsMu sync.Mutex + restoreTimer := SetTimerFactoryForTesting(func(_ float64, _ func()) Timer { + stopsMu.Lock() + arms++ + stopsMu.Unlock() + return fakeTimerFunc(func() { + stopsMu.Lock() + stops++ + stopsMu.Unlock() + }) + }) + defer restoreTimer() + + restoreFetcher := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &chunkedBody{chunks: [][]byte{ + []byte(": OPENROUTER PROCESSING\n\n"), + []byte(": OPENROUTER PROCESSING\n\n"), + []byte(": OPENROUTER PROCESSING\n\n"), + []byte(": OPENROUTER PROCESSING\n\n"), + []byte("data: [DONE]\n\n"), + }}, + }, nil + }) + defer restoreFetcher() + + c := &Client{Compatibility: CompatibilityCompatible} + c.ChunkTimeoutMS = 30 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("keepalives must keep the read watchdog alive: %v", err) + } + for _, part := range parts { + if part.PartType() == PartTypeAbort { + t.Fatalf("keepalives must reset the underlying read timer, got %v", partTypes(parts)) + } + } + stopsMu.Lock() + defer stopsMu.Unlock() + if arms < 5 { + t.Fatalf("watchdog armed %d times, want initial arm plus one per underlying read", arms) + } + if stops < 4 { + t.Fatalf("watchdog stopped %d times, want at least one reset per keepalive", stops) + } +} + +// Early teardown must cancel the request context, not merely Close the +// body. The assertion is server-side: the handler must observe its request +// context finish, which closing the body alone would not guarantee. +func TestWireEarlyTeardownCancelsTheRequestContext(t *testing.T) { + serverSawCancel := make(chan struct{}) + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-r.Context().Done(): + close(serverSawCancel) + case <-release: + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.ChunkTimeoutMS = -1 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + // Read one part, then abandon, as the loop does when compaction is needed. + if _, err := stream.Next(); err != nil { + t.Fatalf("first part: %v", err) + } + if err := stream.Close(); err != nil && err != io.EOF { + t.Fatalf("Close: %v", err) + } + + select { + case <-serverSawCancel: + case <-time.After(2 * time.Second): + t.Fatal("Close must cancel the request context — the server never saw the request finish") + } +} + +func TestWireCloseIsIdempotentAndLeaksNoGoroutines(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, + "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n", + "data: [DONE]\n\n", + ) + }) + defer cleanup() + + before := runtime.NumGoroutine() + for i := 0; i < 20; i++ { + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + _ = stream.Close() + _ = stream.Close() + } + // Transport goroutine retirement is inherently scheduler-driven. Poll for + // up to 2s (20x the old 100ms grace) so CPU starvation cannot turn a slow + // cleanup into a false leak report. + deadline := time.Now().Add(2 * time.Second) + for { + runtime.GC() + after := runtime.NumGoroutine() + if after <= before+10 { + break + } + if time.Now().After(deadline) { + t.Errorf("goroutine leak: before %d, after %d", before, after) + break + } + runtime.Gosched() + } +} + +// ── HTTP error responses ────────────────────────────────────────────────── + +func TestWireNon2xxBecomesAClassifiableStatusError(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"error":{"message":"rate limit exceeded","code":429}}`) + }) + defer cleanup() + + _, err := c.DoStream(context.Background(), minimalParams()) + if err == nil { + t.Fatal("expected a 429 to fail the request") + } + if err.Error() != "rate limit exceeded" { + t.Errorf("message: want %q, got %q", "rate limit exceeded", err.Error()) + } + value := err + if !adaptive.IsLikelyRateLimit(value) { + t.Error("a 429 must classify as a rate limit for the router") + } +} + +func TestErrorClassificationThroughRetryAdapter(t *testing.T) { + type want struct { + rateLimit, incompatible, timeout, structured, transient, retryable bool + } + cases := []struct { + name string + err error + want want + }{ + {"layer 2 total timeout", ErrOperationTimedOut, want{timeout: true, retryable: true}}, + {"layer 3 read timeout", ErrSSEReadTimedOut, want{timeout: true, retryable: true}}, + {"http 429 status", retrysched.NewProviderError("request rejected", 429, nil, nil), want{rateLimit: true, retryable: true}}, + {"provider incompatibility", errors.New("unsupported parameter top_k"), want{incompatible: true, retryable: true}}, + {"structured failure", errors.New("invalid json parse"), want{structured: true, retryable: true}}, + {"http 503 status", retrysched.NewProviderError("request rejected", 503, nil, nil), want{transient: true, retryable: true}}, + {"ordinary application error", errors.New("tool execution failed"), want{}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + value := tc.err + got := want{ + rateLimit: adaptive.IsLikelyRateLimit(value), + incompatible: adaptive.IsLikelyProviderIncompatible(value), + timeout: adaptive.IsLikelyTimeout(value), + structured: adaptive.IsLikelyStructuredFailure(value), + transient: adaptive.IsLikelyTransientProviderError(value), + retryable: adaptive.IsRetryableRouteError(value), + } + if got != tc.want { + t.Errorf("classification:\n want %+v\n got %+v", tc.want, got) + } + }) + } +} + +// ── router registration exactly once ────────────────────────────────────── + +type spyRouter struct { + mu sync.Mutex + inflight int + canceled int + calls []struct { + completion float64 + err error + } +} + +func (s *spyRouter) RegisterCanceled(choice adaptive.RouteChoice) { + s.mu.Lock() + defer s.mu.Unlock() + s.canceled++ + if s.inflight > 0 { + s.inflight-- + } +} + +func (s *spyRouter) Register(choice adaptive.RouteChoice, elapsedSeconds, completionTokens float64, err error) adaptive.AdaptiveRouteEvent { + s.mu.Lock() + defer s.mu.Unlock() + s.calls = append(s.calls, struct { + completion float64 + err error + }{completionTokens, err}) + if s.inflight > 0 { + s.inflight-- + } + return adaptive.AdaptiveRouteEvent{} +} + +func (s *spyRouter) canceledCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.canceled +} + +func (s *spyRouter) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.calls) +} + +func (s *spyRouter) inFlight() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.inflight +} + +func TestWireRouterRegistersExactlyOnceOnSuccess(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, + "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":7,\"total_tokens\":17}}\n\n", + "data: [DONE]\n\n", + ) + }) + defer cleanup() + + spy := &spyRouter{} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + _ = stream.Close() + + if spy.count() != 1 { + t.Fatalf("register must fire exactly once, got %d", spy.count()) + } + if spy.calls[0].err != nil { + t.Errorf("a successful stream must register a nil error") + } + // The registered completion count is the provider's completion_tokens. + if spy.calls[0].completion != 7 { + t.Errorf("completion tokens: want 7, got %v", spy.calls[0].completion) + } +} + +func TestWireRouterRegistersExactlyOnceOnHTTPFailure(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":{"message":"boom"}}`) + }) + defer cleanup() + + spy := &spyRouter{} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + + if _, err := c.DoStream(context.Background(), minimalParams()); err == nil { + t.Fatal("expected a 500 to fail") + } + if spy.count() != 1 { + t.Fatalf("register must fire exactly once, got %d", spy.count()) + } + if spy.calls[0].err == nil { + t.Error("a failed request must register the error") + } +} + +func TestWireErrorPartRegistersFailureAndReleasesRoute(t *testing.T) { + // An in-band error part registers a failure exactly once before teardown; + // the Close that follows must not settle the lease a second time. + c, cleanup := sseServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + "data: {\"error\":{\"message\":\"provider exploded\"},\"choices\":[]}\n\n", + "data: [DONE]\n\n", + ) + }) + defer cleanup() + spy := &spyRouter{inflight: 1} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatal(err) + } + parts, err := stream.Parts() + if err != nil { + t.Fatal(err) + } + _ = stream.Close() + if len(parts) == 0 || parts[0].PartType() != PartTypeError { + t.Fatalf("parts = %v", partTypes(parts)) + } + if spy.count() != 1 || spy.inFlight() != 0 || spy.calls[0].err == nil || + spy.calls[0].completion != 0 { + t.Fatalf("router calls=%#v inflight=%d", spy.calls, spy.inFlight()) + } +} + +func TestWireErrorPartPreservesStatusForRouterCooldown(t *testing.T) { + // Structured stream error fields reach the router, so a numeric 503 is + // classified as transient and cools the route. + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{ + {ID: "provider/first"}, + {ID: "provider/second"}, + }, + }) + choice, err := router.Pick("coder", adaptive.ModelTierHigh) + if err != nil { + t.Fatal(err) + } + c := &Client{Fetcher: func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"error\":{\"message\":\"upstream unavailable\",\"status\":503},\"choices\":[]}\n\n" + + "data: [DONE]\n\n", + )), + Request: request, + }, nil + }} + c.Router = router + c.RouteChoice = &choice + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatal(err) + } + if _, err := stream.Parts(); err != nil { + t.Fatal(err) + } + next, err := router.Pick("coder", adaptive.ModelTierHigh) + if err != nil { + t.Fatal(err) + } + if next.Candidate.ID == choice.Candidate.ID { + t.Fatalf("503 route %q was not cooled; next choice = %#v", choice.Candidate.ID, next) + } +} + +// A stream abandoned through Close before it finished releases its route +// lease without attributing a success or a failure. +func TestWireAbandonedStreamReleasesRoute(t *testing.T) { + + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + spy := &spyRouter{inflight: 1} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + c.ChunkTimeoutMS = -1 + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Next(); err != nil { + t.Fatalf("first part: %v", err) + } + _ = stream.Close() + + if spy.count() != 0 { + t.Fatalf("abandoned stream registered %d outcomes, want 0", spy.count()) + } + if spy.canceledCount() != 1 || spy.inFlight() != 0 { + t.Fatalf("abandoned stream must release its lease: canceled=%d inflight=%d", spy.canceledCount(), spy.inFlight()) + } +} + +func TestWireNoRouteChoiceSkipsRegistration(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, "data: [DONE]\n\n") + }) + defer cleanup() + + spy := &spyRouter{} + c.Router = spy // RouteChoice deliberately nil + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + _ = stream.Close() + + if spy.count() != 0 { + t.Fatalf("no registration without a route choice, got %d", spy.count()) + } +} + +// ── mid-stream read error ───────────────────────────────────────────────── + +// A mid-stream reader error closes the stream cleanly; the error surfaces as +// an `error` PART at flush, never as a returned error. The httptest server +// aborts the connection mid-frame. +func TestWireMidStreamReadErrorBecomesAnErrorPart(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Content-Length", "512") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + // Returning early with an unsatisfied Content-Length makes the client + // see an unexpected EOF. + }) + defer cleanup() + + c.ChunkTimeoutMS = -1 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("a mid-stream read error must NOT be returned as an error: %v", err) + } + var sawError bool + var finish *FinishPart + for i := range parts { + if parts[i].PartType() == PartTypeError { + sawError = true + } + if f, ok := parts[i].(FinishPart); ok { + finish = &f + } + } + if !sawError { + t.Errorf("expected an error part, got %v", partTypes(parts)) + } + if finish == nil { + t.Fatal("expected a finish part") + } + if finish.FinishReason.Unified != FinishError { + t.Errorf("a captured stream error forces finishReason error, got %q", finish.FinishReason.Unified) + } +} + +// ── timer seam ──────────────────────────────────────────────────────────── + +func TestWireWatchdogUsesTheInjectableTimer(t *testing.T) { + var mu sync.Mutex + var armed []float64 + fire := make(chan func(), 8) + restore := SetTimerFactoryForTesting(func(ms float64, fn func()) Timer { + mu.Lock() + armed = append(armed, ms) + mu.Unlock() + select { + case fire <- fn: + default: + } + return fakeTimer{} + }) + defer restore() + + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, "data: [DONE]\n\n") + }) + defer cleanup() + + c.ChunkTimeoutMS = 4321 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(armed) == 0 { + t.Fatal("the reader watchdog must go through the injectable timer") + } + for _, ms := range armed { + if ms != 4321 { + t.Errorf("watchdog armed with %v ms, want 4321", ms) + } + } +} + +type fakeTimer struct{} + +func (fakeTimer) Stop() {} + +type fakeTimerFunc func() + +func (f fakeTimerFunc) Stop() { f() } + +// ── socket-free abort plumbing ──────────────────────────────────────────── + +type contextBody struct { + ctx context.Context + closed chan struct{} + closeOnce sync.Once +} + +type chunkedBody struct { + chunks [][]byte + next int +} + +func (b *chunkedBody) Read(p []byte) (int, error) { + if b.next >= len(b.chunks) { + return 0, io.EOF + } + chunk := b.chunks[b.next] + b.next++ + return copy(p, chunk), nil +} + +func (*chunkedBody) Close() error { return nil } + +func newContextBody(ctx context.Context) *contextBody { + return &contextBody{ctx: ctx, closed: make(chan struct{})} +} + +func (b *contextBody) Read([]byte) (int, error) { + select { + case <-b.ctx.Done(): + return 0, b.ctx.Err() + case <-b.closed: + return 0, io.EOF + } +} + +func (b *contextBody) Close() error { + b.closeOnce.Do(func() { close(b.closed) }) + return nil +} + +func TestClientTotalTimeoutEmitsAbortWithoutSocket(t *testing.T) { + fire := make(chan context.CancelCauseFunc, 1) + restoreTimeout := SetTimeoutContextFactoryForTesting(func(parent context.Context, _ time.Duration, cause error) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancelCause(parent) + fire <- func(error) { cancel(cause) } + return ctx, func() { cancel(context.Canceled) } + }) + defer restoreTimeout() + + restore := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: newContextBody(req.Context()), + Header: make(http.Header), + }, nil + }) + defer restore() + + client := &Client{TotalTimeoutMS: 5, ChunkTimeoutMS: -1} + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + (<-fire)(nil) + parts, err := stream.Parts() + if err != nil { + t.Fatalf("abort must close cleanly: %v", err) + } + if got := requireAbortPart(t, parts).Reason; got != ErrOperationTimedOut.Error() { + t.Errorf("want %q, got %q", ErrOperationTimedOut, got) + } +} + +func TestClientCloseCancelsRequestContextWithoutSocket(t *testing.T) { + var requestContext context.Context + restore := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + requestContext = req.Context() + return &http.Response{ + StatusCode: http.StatusOK, + Body: newContextBody(req.Context()), + Header: make(http.Header), + }, nil + }) + defer restore() + + client := &Client{TotalTimeoutMS: -1, ChunkTimeoutMS: -1} + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + select { + case <-requestContext.Done(): + default: + t.Fatal("Close must cancel the HTTP request context") + } +} + +// ── the error-part payload of a mid-stream failure ──────────────────────── + +func TestErrorPartPayloadShape(t *testing.T) { + got, err := ErrorPart{Error: readErrorValue(errors.New("connection reset"))}.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"type":"error","error":{"name":"Error","message":"connection reset"}}` + if string(got) != want { + t.Errorf("want %s, got %s", want, got) + } + var probe map[string]json.RawMessage + if err := json.Unmarshal(got, &probe); err != nil { + t.Fatalf("error part must be valid JSON: %v", err) + } +} diff --git a/internal/seniordev/engine/orclient/convert.go b/internal/seniordev/engine/orclient/convert.go new file mode 100644 index 000000000..99d6b3ce4 --- /dev/null +++ b/internal/seniordev/engine/orclient/convert.go @@ -0,0 +1,766 @@ +//go:build !windows + +package orclient + +// The `messages` field of the request body. +// +// Everything about this conversion is byte-order-sensitive: the wire body +// feeds OpenRouter's prompt cache, and the assistant +// `tool_calls[].function.arguments` string feeds Anthropic signature +// validation through DeterministicStringify. So every emitted object is an +// ordered Object, not a map, and every optional key is *absent* rather than +// null unless the wire wants an explicit null. +// +// `cache_control` is threaded through from provider options: it decides +// whether a single-text-part user message serialises as a bare string or as a +// one-element array. + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// ── getCacheControl ────────────────────────────────────────────────────── + +// getCacheControl reads `openrouter.cacheControl ?? openrouter.cache_control ?? +// anthropic.cacheControl ?? anthropic.cache_control` off a providerOptions bag. +func getCacheControl(providerOptions json.RawMessage) json.RawMessage { + if len(providerOptions) == 0 { + return nil + } + obj, err := ParseObject(providerOptions) + if err != nil { + return nil + } + for _, ns := range []string{"openrouter", "anthropic"} { + nsRaw, ok := obj.Get(ns) + if !ok { + continue + } + inner, err := ParseObject(nsRaw) + if err != nil { + continue + } + for _, key := range []string{"cacheControl", "cache_control"} { + if v, ok := inner.Get(key); ok && rawTruthy(v) { + return v + } + } + } + return nil +} + +// ── the duplicate tracker ──────────────────────────────────────────────── +// +// One tracker per convertToOpenRouterChatMessages CALL, shared across every +// assistant message in the prompt — so the same reasoning text appearing on two +// assistant turns is emitted once, on the first. + +type reasoningDuplicateTracker struct{ seen map[string]bool } + +func newReasoningDuplicateTracker() *reasoningDuplicateTracker { + return &reasoningDuplicateTracker{seen: map[string]bool{}} +} + +func (t *reasoningDuplicateTracker) upsert(d ReasoningDetail) bool { + key, ok := canonicalReasoningKey(d) + if !ok { + return false + } + if t.seen[key] { + return false + } + t.seen[key] = true + return true +} + +func canonicalReasoningKey(d ReasoningDetail) (string, bool) { + switch d.Type { + case ReasoningDetailSummary: + return d.Summary, true + case ReasoningDetailEncrypted: + if id := rawString(d.ID); id != "" { + return id, true + } + return d.Data, true + case ReasoningDetailText: + if text := rawString(d.Text); text != "" { + return text, true + } + if sig := rawString(d.Signature); sig != "" { + return sig, true + } + return "", false + } + return "", false +} + +// ── ConvertToOpenRouterChatMessages ─────────────────────────────────────── + +// ConvertToOpenRouterChatMessages converts the prompt into wire messages. +// The returned slice is what lands in the body's `messages` field. +func ConvertToOpenRouterChatMessages(prompt []msgmodel.ModelMessage) ([]*Object, error) { + messages := []*Object{} + tracker := newReasoningDuplicateTracker() + + // Images lifted out of tool results. The chat-completions schema types a + // tool message's content as text and specifies `image_url` for user + // messages, so the image travels in a user message instead. It cannot go + // out immediately: every tool message of an assistant turn has to stay + // contiguous and directly follow that turn, so the images buffer across a + // whole run of tool messages and flush as ONE user message when the run + // ends -- at the next non-tool message, or at the end of the prompt. + var pendingImages []msgmodel.ToolOutputContentMedia + flushImages := func() error { + if len(pendingImages) == 0 { + return nil + } + out, err := toolImageUserMessage(pendingImages) + if err != nil { + return err + } + pendingImages = nil + messages = append(messages, out) + return nil + } + + for _, msg := range prompt { + if msg.Role != "tool" { + if err := flushImages(); err != nil { + return nil, err + } + } + switch msg.Role { + case "system": + cacheControl := getCacheControl(msg.ProviderOptions) + text := NewObject() + text.SetString("type", "text") + content, _ := msg.Content.(string) + text.SetString("text", content) + if cacheControl != nil { + if err := text.Set("cache_control", cacheControl); err != nil { + return nil, err + } + } + out := NewObject() + out.SetString("role", "system") + out.set("content", jsonValue{Kind: kindArray, Array: []jsonValue{text.value()}}) + messages = append(messages, out) + + case "user": + out, err := convertUserMessage(msg) + if err != nil { + return nil, err + } + messages = append(messages, out) + + case "assistant": + out, err := convertAssistantMessage(msg, tracker) + if err != nil { + return nil, err + } + messages = append(messages, out) + + case "tool": + parts, _ := msg.Content.([]any) + for _, part := range parts { + tr, ok := part.(msgmodel.ToolResultContent) + if !ok { + // Anything that is not a tool result is skipped. + continue + } + content, images, err := getToolResultContent(tr) + if err != nil { + return nil, err + } + pendingImages = append(pendingImages, images...) + out := NewObject() + out.SetString("role", "tool") + out.SetString("tool_call_id", tr.ToolCallID) + out.set("content", content) + out.SetString("name", tr.ToolName) + cc := getCacheControl(msg.ProviderOptions) + if cc == nil { + cc = getCacheControl(tr.ProviderOptions) + } + if cc != nil { + if err := out.Set("cache_control", cc); err != nil { + return nil, err + } + } + messages = append(messages, out) + } + + default: + // Any other role is dropped entirely. + } + } + if err := flushImages(); err != nil { + return nil, err + } + return messages, nil +} + +// toolImageUserMessage carries the images named in a run of tool results, +// introduced by a line that ties them back to those results. Each image part +// is built by the user-message part converter, so it is the part the provider +// would see for the same file attached by hand. +func toolImageUserMessage(images []msgmodel.ToolOutputContentMedia) (*Object, error) { + parts := make([]jsonValue, 0, len(images)+1) + lead := NewObject() + lead.SetString("type", "text") + lead.SetString("text", toolImageUserMessageText) + parts = append(parts, lead.value()) + for _, image := range images { + part, err := convertUserPart(msgmodel.FileContent{ + Type: "file", + MediaType: image.MediaType, + Data: image.Data, + }, nil) + if err != nil { + return nil, err + } + parts = append(parts, part) + } + out := NewObject() + out.SetString("role", "user") + out.set("content", jsonValue{Kind: kindArray, Array: parts}) + return out, nil +} + +// toolImageUserMessageText introduces the relocated images. +const toolImageUserMessageText = "The images below are attachments from the preceding tool results, " + + "in the order those tools returned them." + +// contentParts is the only reader of ModelMessage.Content for user and +// assistant messages. It accepts the two shapes the runtime legitimately +// produces -- a []any of content parts (what ConvertToModelMessages builds) and +// a bare string (a hand-written prompt) -- and REFUSES everything else. +// +// A silent fallback here would turn any other shape into an empty message: a +// typed []TextContent slice, or an empty trailing user turn, would go out as +// `"content": []` and the model would see no transcript at all. An +// unconvertible message is a programming error and must fail here, at the +// wire. +func contentParts(msg msgmodel.ModelMessage) ([]any, error) { + switch content := msg.Content.(type) { + case []any: + if len(content) == 0 { + return nil, fmt.Errorf( + "orclient: %s message has no content parts", msg.Role, + ) + } + return content, nil + case string: + if content == "" { + return nil, fmt.Errorf("orclient: %s message has empty text content", msg.Role) + } + return []any{msgmodel.TextContent{Type: "text", Text: content}}, nil + case nil: + return nil, fmt.Errorf("orclient: %s message has nil content", msg.Role) + default: + return nil, fmt.Errorf( + "orclient: %s message content must be a string or []any of content parts, got %T (build it with msgmodel.UserText)", + msg.Role, msg.Content, + ) + } +} + +func convertUserMessage(msg msgmodel.ModelMessage) (*Object, error) { + parts, err := contentParts(msg) + if err != nil { + return nil, err + } + + // Single text part → bare string content, unless a cache_control applies. + if len(parts) == 1 { + if text, ok := parts[0].(msgmodel.TextContent); ok { + cc := getCacheControl(msg.ProviderOptions) + if cc == nil { + cc = getCacheControl(text.ProviderOptions) + } + out := NewObject() + out.SetString("role", "user") + if cc != nil { + part := NewObject() + part.SetString("type", "text") + part.SetString("text", text.Text) + if err := part.Set("cache_control", cc); err != nil { + return nil, err + } + out.set("content", jsonValue{Kind: kindArray, Array: []jsonValue{part.value()}}) + } else { + out.SetString("content", text.Text) + } + return out, nil + } + } + + messageCacheControl := getCacheControl(msg.ProviderOptions) + lastTextPartIndex := -1 + for i := len(parts) - 1; i >= 0; i-- { + if _, ok := parts[i].(msgmodel.TextContent); ok { + lastTextPartIndex = i + break + } + } + + contentParts := make([]jsonValue, 0, len(parts)) + for index, part := range parts { + var partProviderOptions json.RawMessage + isText := false + switch p := part.(type) { + case msgmodel.TextContent: + partProviderOptions = p.ProviderOptions + isText = true + case msgmodel.FileContent: + partProviderOptions = p.ProviderOptions + } + partCacheControl := getCacheControl(partProviderOptions) + cacheControl := partCacheControl + if isText && partCacheControl == nil && index == lastTextPartIndex { + cacheControl = messageCacheControl + } + + converted, err := convertUserPart(part, cacheControl) + if err != nil { + return nil, err + } + contentParts = append(contentParts, converted) + } + + out := NewObject() + out.SetString("role", "user") + out.set("content", jsonValue{Kind: kindArray, Array: contentParts}) + return out, nil +} + +func convertUserPart(part any, cacheControl json.RawMessage) (jsonValue, error) { + withCC := func(o *Object) (jsonValue, error) { + if cacheControl != nil { + if err := o.Set("cache_control", cacheControl); err != nil { + return jsonValue{}, err + } + } + return o.value(), nil + } + + switch p := part.(type) { + case msgmodel.TextContent: + o := NewObject() + o.SetString("type", "text") + o.SetString("text", p.Text) + return withCC(o) + + case msgmodel.FileContent: + switch { + case strings.HasPrefix(p.MediaType, "image/"): + o := NewObject() + o.SetString("type", "image_url") + inner := NewObject() + inner.SetString("url", buildFileDataURL(p.Data, p.MediaType, "image/jpeg")) + o.SetObject("image_url", inner) + return withCC(o) + case strings.HasPrefix(p.MediaType, "video/"): + o := NewObject() + o.SetString("type", "video_url") + inner := NewObject() + inner.SetString("url", buildFileDataURL(p.Data, p.MediaType, "video/mp4")) + o.SetObject("video_url", inner) + return withCC(o) + case strings.HasPrefix(p.MediaType, "audio/"): + audio, err := inputAudioData(p) + if err != nil { + return jsonValue{}, err + } + o := NewObject() + o.SetString("type", "input_audio") + o.SetObject("input_audio", audio) + return withCC(o) + } + fileName := "" + if opts, err := ParseObject(p.ProviderOptions); err == nil { + if nsRaw, ok := opts.Get("openrouter"); ok { + if ns, err := ParseObject(nsRaw); err == nil { + if v, ok := ns.Get("filename"); ok { + fileName = textOf(rawJSONValue(v)) + } + } + } + } + if fileName == "" && len(p.Filename) > 0 { + fileName = textOf(rawJSONValue(p.Filename)) + } + fileData := buildFileDataURL(p.Data, p.MediaType, "application/pdf") + o := NewObject() + o.SetString("type", "file") + inner := NewObject() + inner.SetString("filename", fileName) + inner.SetString("file_data", fileData) + o.SetObject("file", inner) + if isHTTPURL(fileData) { + // The http(s) branch returns WITHOUT cache_control. + return o.value(), nil + } + return withCC(o) + } + + // Anything else becomes an empty text part. + o := NewObject() + o.SetString("type", "text") + o.SetString("text", "") + return withCC(o) +} + +func convertAssistantMessage(msg msgmodel.ModelMessage, tracker *reasoningDuplicateTracker) (*Object, error) { + parts, err := contentParts(msg) + if err != nil { + return nil, err + } + + var text, reasoning strings.Builder + toolCalls := []jsonValue{} + for _, part := range parts { + switch p := part.(type) { + case msgmodel.TextContent: + text.WriteString(p.Text) + case msgmodel.ToolCallContent: + args, err := DeterministicStringify(p.Input) + if err != nil { + if errors.Is(err, errUndefinedStringify) { + // An absent input means no `arguments` key on the wire + // object. + args = nil + } else { + return nil, errors.New("orclient: tool call " + p.ToolCallID + ": " + err.Error()) + } + } + call := NewObject() + call.SetString("id", p.ToolCallID) + call.SetString("type", "function") + fn := NewObject() + fn.SetString("name", p.ToolName) + if args != nil { + fn.SetString("arguments", string(args)) + } + call.SetObject("function", fn) + toolCalls = append(toolCalls, call.value()) + case msgmodel.ReasoningContent: + reasoning.WriteString(p.Text) + case msgmodel.FileContent: + // File parts are not sent on assistant messages. + } + } + + messageDetails, messageDetailsPresent := openrouterReasoningDetails(msg.ProviderOptions) + annotations, _ := openrouterAnnotations(msg.ProviderOptions) + + candidate := messageDetails + haveCandidate := messageDetailsPresent + if !haveCandidate { + candidate, haveCandidate = findFirstReasoningDetails(parts) + } + + var finalDetails []ReasoningDetail + haveFinal := false + if haveCandidate { + valid := make([]ReasoningDetail, 0, len(candidate)) + for _, d := range candidate { + if d.Type != ReasoningDetailText { + valid = append(valid, d) + continue + } + format := rawString(d.Format) + if format == "" { + format = DefaultReasoningFormat + } + if format != "anthropic-claude-v1" && format != "google-gemini-v1" { + valid = append(valid, d) + continue + } + if rawTruthy(d.Signature) { + valid = append(valid, d) + } + } + unique := make([]ReasoningDetail, 0, len(valid)) + for _, d := range valid { + if tracker.upsert(d) { + unique = append(unique, d) + } + } + finalDetails = unique + haveFinal = true + } + + out := NewObject() + out.SetString("role", "assistant") + out.SetString("content", text.String()) + if len(toolCalls) > 0 { + out.set("tool_calls", jsonValue{Kind: kindArray, Array: toolCalls}) + } + // Reasoning text is sent only alongside non-empty reasoning details. + if reasoning.Len() > 0 && haveFinal && len(finalDetails) > 0 { + out.SetString("reasoning", reasoning.String()) + } + if haveFinal { + encoded, err := json.Marshal(finalDetails) + if err != nil { + return nil, err + } + if err := out.Set("reasoning_details", encoded); err != nil { + return nil, err + } + } + if annotations != nil { + if err := out.Set("annotations", annotations); err != nil { + return nil, err + } + } + if cc := getCacheControl(msg.ProviderOptions); cc != nil { + if err := out.Set("cache_control", cc); err != nil { + return nil, err + } + } + return out, nil +} + +// findFirstReasoningDetails looks at tool-call parts first, then reasoning +// parts, taking the first non-empty array found. +func findFirstReasoningDetails(parts []any) ([]ReasoningDetail, bool) { + for _, part := range parts { + p, ok := part.(msgmodel.ToolCallContent) + if !ok { + continue + } + if details, present := openrouterReasoningDetails(p.ProviderOptions); present && len(details) > 0 { + return details, true + } + } + for _, part := range parts { + p, ok := part.(msgmodel.ReasoningContent) + if !ok { + continue + } + if details, present := openrouterReasoningDetails(p.ProviderOptions); present && len(details) > 0 { + return details, true + } + } + return nil, false +} + +func openrouterReasoningDetails(providerOptions json.RawMessage) ([]ReasoningDetail, bool) { + raw, ok := openrouterNamespaceField(providerOptions, "reasoning_details") + if !ok { + return nil, false + } + details := ParseReasoningDetails(raw) + if details == nil { + // Not an array at all: treated as absent. + return nil, false + } + return details, true +} + +func openrouterAnnotations(providerOptions json.RawMessage) (json.RawMessage, bool) { + return openrouterNamespaceField(providerOptions, "annotations") +} + +func openrouterNamespaceField(providerOptions json.RawMessage, field string) (json.RawMessage, bool) { + if len(providerOptions) == 0 { + return nil, false + } + obj, err := ParseObject(providerOptions) + if err != nil { + return nil, false + } + nsRaw, ok := obj.Get("openrouter") + if !ok { + return nil, false + } + ns, err := ParseObject(nsRaw) + if err != nil { + return nil, false + } + v, ok := ns.Get(field) + if !ok { + return nil, false + } + return v, true +} + +// ── getToolResultContent ───────────────────────────────────────────────── + +// getToolResultContent returns the tool message's content and the images that +// content only names: the caller sends those on in a user message. +func getToolResultContent(tr msgmodel.ToolResultContent) (jsonValue, []msgmodel.ToolOutputContentMedia, error) { + switch tr.Output.Type { + case "text", "error-text": + s, _ := tr.Output.Value.(string) + return stringValue(s), nil, nil + case "json", "error-json": + encoded, err := json.Marshal(tr.Output.Value) + if err != nil { + return jsonValue{}, nil, err + } + return stringValue(string(encoded)), nil, nil + case "content": + items, _ := tr.Output.Value.([]any) + out := make([]jsonValue, 0, len(items)) + var images []msgmodel.ToolOutputContentMedia + for _, item := range items { + mapped, image, err := mapToolResultContentPart(item) + if err != nil { + return jsonValue{}, nil, err + } + out = append(out, mapped) + if image != nil { + images = append(images, *image) + } + } + return jsonValue{Kind: kindArray, Array: out}, images, nil + case "execution-denied": + reason, ok := tr.Output.Value.(string) + if !ok || reason == "" { + reason = "Tool execution denied" + } + return stringValue(reason), nil, nil + } + // An unknown output type serialises as null content. + return jsonValue{Kind: kindNull}, nil, nil +} + +// mapToolResultContentPart handles the two element shapes toModelOutput can +// produce: a text part and a `media` part. Anything else is stringified whole. +// An image is returned alongside its note so the caller can send it where the +// wire accepts an image; every other part returns a nil image. +func mapToolResultContentPart(item any) (jsonValue, *msgmodel.ToolOutputContentMedia, error) { + switch p := item.(type) { + case msgmodel.ToolOutputContentText: + o := NewObject() + o.SetString("type", "text") + o.SetString("text", p.Text) + return o.value(), nil, nil + case msgmodel.ToolOutputContentMedia: + mediaType := p.MediaType + if mediaType == "" { + mediaType = "unknown" + } + // A tool message's content is text on this schema, so an image is + // only announced here and sent as an `image_url` part on the user + // message that follows the tool run. + if strings.HasPrefix(p.MediaType, "image/") { + o := NewObject() + o.SetString("type", "text") + o.SetString("text", "[attachment: "+mediaType+" (sent as an image in the next user message)]") + return o.value(), &p, nil + } + // Any other media -- a PDF, say -- has no inline shape the model + // can read at all, and its base64 payload would cost a fortune in + // tokens for nothing. Name the attachment instead of sending it. + o := NewObject() + o.SetString("type", "text") + o.SetString("text", "[attachment: "+mediaType+" (content not sent)]") + return o.value(), nil, nil + } + encoded, err := json.Marshal(item) + if err != nil { + return jsonValue{}, nil, err + } + o := NewObject() + o.SetString("type", "text") + o.SetString("text", string(encoded)) + return o.value(), nil, nil +} + +// ── file url helpers ───────────────────────────────────────────────────── + +func buildFileDataURL(data, mediaType, defaultMediaType string) string { + if isHTTPURL(data) { + return data + } + if strings.HasPrefix(data, "data:") { + return data + } + mt := mediaType + if mt == "" { + mt = defaultMediaType + } + return "data:" + mt + ";base64," + data +} + +func isHTTPURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +// mimeToAudioFormat maps an audio mime subtype to the wire format name. +var mimeToAudioFormat = map[string]string{ + "mpeg": "mp3", + "mp3": "mp3", + "x-wav": "wav", + "wave": "wav", + "wav": "wav", + "ogg": "ogg", + "x-flac": "flac", + "flac": "flac", + "aac": "aac", + "x-m4a": "m4a", + "m4a": "m4a", + "mp4": "m4a", + "webm": "webm", + "opus": "opus", + "pcm": "pcm16", + "pcm16": "pcm16", + "L16": "pcm16", +} + +func inputAudioData(p msgmodel.FileContent) (*Object, error) { + fileData := buildFileDataURL(p.Data, p.MediaType, "audio/mpeg") + data := base64FromDataURL(fileData) + mediaType := p.MediaType + if mediaType == "" { + mediaType = "audio/mpeg" + } + rawFormat := strings.Replace(mediaType, "audio/", "", 1) + format, ok := mimeToAudioFormat[rawFormat] + if !ok { + return nil, fmt.Errorf("Unsupported audio format: %q", mediaType) + } + o := NewObject() + o.SetString("data", data) + o.SetString("format", format) + return o, nil +} + +// base64FromDataURL extracts the payload of a `data:;base64,` +// URL, falling back to the input when it does not match. +func base64FromDataURL(dataURL string) string { + if !strings.HasPrefix(dataURL, "data:") { + return dataURL + } + rest := dataURL[len("data:"):] + semi := strings.IndexByte(rest, ';') + if semi < 0 { + return dataURL + } + if !strings.HasPrefix(rest[semi:], ";base64,") { + return dataURL + } + payload := rest[semi+len(";base64,"):] + if payload == "" { + return dataURL + } + // `[^;]*` forbids a `;` before the `;base64,`. + if strings.ContainsRune(rest[:semi], ';') { + return dataURL + } + return payload +} diff --git a/internal/seniordev/engine/orclient/convert_tool_result_test.go b/internal/seniordev/engine/orclient/convert_tool_result_test.go new file mode 100644 index 000000000..52aedc268 --- /dev/null +++ b/internal/seniordev/engine/orclient/convert_tool_result_test.go @@ -0,0 +1,344 @@ +//go:build !windows + +package orclient + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// Long-enough payloads that a stringified copy is unmistakable in the output, +// and distinct enough from each other to pin the order images go out in. +const ( + firstPayload = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + secondPayload = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + thirdPayload = "Qk1GAAAAAAAAADYAAAAoAAAAAQAAAAEAAAABABgAAAAAABAAAAATCwAAEwsAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAA" +) + +// The wordings this converter uses. They are pinned here because they are the +// only thing telling the model that an image it was promised is elsewhere. +const ( + imageNoteWording = "[attachment: image/png (sent as an image in the next user message)]" + imageLeadWording = "The images below are attachments from the preceding tool results, " + + "in the order those tools returned them." +) + +type wireMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// convertPrompt converts a prompt and decodes each wire message's role and +// raw content. +func convertPrompt(t *testing.T, prompt ...msgmodel.ModelMessage) []wireMessage { + t.Helper() + messages, err := ConvertToOpenRouterChatMessages(prompt) + if err != nil { + t.Fatal(err) + } + out := make([]wireMessage, 0, len(messages)) + for _, message := range messages { + encoded, err := json.Marshal(message) + if err != nil { + t.Fatal(err) + } + var decoded wireMessage + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("message is not an object: %s", encoded) + } + out = append(out, decoded) + } + return out +} + +// roles is the message sequence a test asserts on. +func roles(messages []wireMessage) []string { + out := make([]string, 0, len(messages)) + for _, message := range messages { + out = append(out, message.Role) + } + return out +} + +func assertRoles(t *testing.T, messages []wireMessage, want ...string) { + t.Helper() + got := roles(messages) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("roles = %v, want %v", got, want) + } +} + +// partsOf decodes a message's content as an array of typed parts. +func partsOf(t *testing.T, message wireMessage) []json.RawMessage { + t.Helper() + var parts []json.RawMessage + if err := json.Unmarshal(message.Content, &parts); err != nil { + t.Fatalf("%s content is not a part array: %s", message.Role, message.Content) + } + return parts +} + +// toolResult is one `{type:"content"}` tool result carrying `items`. +func toolResult(callID string, items ...any) msgmodel.ToolResultContent { + return msgmodel.ToolResultContent{ + Type: "tool-result", + ToolCallID: callID, + ToolName: "read", + Output: msgmodel.ToolOutput{Type: "content", Value: items}, + } +} + +// toolMessage is a tool message carrying a single tool result. +func toolMessage(callID string, items ...any) msgmodel.ModelMessage { + return msgmodel.ModelMessage{Role: "tool", Content: []any{toolResult(callID, items...)}} +} + +func mediaPart(mediaType, data string) msgmodel.ToolOutputContentMedia { + return msgmodel.ToolOutputContentMedia{Type: "media", MediaType: mediaType, Data: data} +} + +func textPart(s string) msgmodel.ToolOutputContentText { + return msgmodel.ToolOutputContentText{Type: "text", Text: s} +} + +func assistantText(s string) msgmodel.ModelMessage { + return msgmodel.ModelMessage{Role: "assistant", Content: []any{ + msgmodel.TextContent{Type: "text", Text: s}, + }} +} + +// assertNoPayload fails when any base64 blob reached the given messages. +func assertNoPayload(t *testing.T, messages []wireMessage) { + t.Helper() + for _, message := range messages { + for _, payload := range []string{firstPayload, secondPayload, thirdPayload} { + if strings.Contains(string(message.Content), payload) { + t.Fatalf("base64 payload reached the %s message: %s", message.Role, message.Content) + } + } + } +} + +// userImagePart is the `image_url` part the user-message path builds for a +// file, which the tool-result path has to match exactly. +func userImagePart(t *testing.T, mediaType, data string) json.RawMessage { + t.Helper() + messages := convertPrompt(t, msgmodel.ModelMessage{Role: "user", Content: []any{ + msgmodel.FileContent{Type: "file", MediaType: mediaType, Data: data}, + }}) + parts := partsOf(t, messages[0]) + if len(parts) != 1 { + t.Fatalf("want one user part, got %d", len(parts)) + } + return parts[0] +} + +// An image is named in the tool result and delivered by the user message that +// follows it, because the wire accepts an image only on a user message. +func TestToolResultImageIsNamedAndSentInAFollowingUserMessage(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", + textPart("Image read successfully"), + mediaPart("image/png", firstPayload), + )) + assertRoles(t, messages, "tool", "user") + + toolParts := partsOf(t, messages[0]) + if len(toolParts) != 2 { + t.Fatalf("want two tool parts, got %d: %s", len(toolParts), messages[0].Content) + } + if want := `{"type":"text","text":"Image read successfully"}`; string(toolParts[0]) != want { + t.Fatalf("text part = %s, want %s", toolParts[0], want) + } + wantNote := `{"type":"text","text":"` + imageNoteWording + `"}` + if string(toolParts[1]) != wantNote { + t.Fatalf("note part = %s\nwant %s", toolParts[1], wantNote) + } + assertNoPayload(t, messages[:1]) + + userParts := partsOf(t, messages[1]) + if len(userParts) != 2 { + t.Fatalf("want a lead text and one image, got %d: %s", len(userParts), messages[1].Content) + } + var lead struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(userParts[0], &lead); err != nil { + t.Fatalf("lead part is not a text part: %s", userParts[0]) + } + if lead.Type != "text" || lead.Text != imageLeadWording { + t.Fatalf("lead part = %s, want text %q", userParts[0], imageLeadWording) + } + want := `{"type":"image_url","image_url":{"url":"data:image/png;base64,` + firstPayload + `"}}` + if string(userParts[1]) != want { + t.Fatalf("image part = %s\nwant %s", userParts[1], want) + } +} + +// The delivered image part must be byte-identical to the one the user-message +// path builds for the same file, so a tool result and an attached file look +// the same to the provider. +func TestToolResultImagePartMatchesUserImagePart(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", mediaPart("image/png", firstPayload))) + assertRoles(t, messages, "tool", "user") + got := partsOf(t, messages[1])[1] + want := userImagePart(t, "image/png", firstPayload) + if string(got) != string(want) { + t.Fatalf("tool image part = %s\nuser image part = %s", got, want) + } +} + +// Every image of a run reaches one user message, in the order the tools +// returned them -- whether they came from one tool result or several. +func TestToolRunImagesGoOutTogetherInOrder(t *testing.T) { + messages := convertPrompt(t, + msgmodel.ModelMessage{Role: "tool", Content: []any{ + toolResult("call_1", + textPart("Image read successfully"), + mediaPart("image/png", firstPayload), + mediaPart("image/gif", secondPayload), + ), + toolResult("call_2", + textPart("Image read successfully"), + mediaPart("image/bmp", thirdPayload), + ), + }}, + ) + assertRoles(t, messages, "tool", "tool", "user") + assertNoPayload(t, messages[:2]) + + userParts := partsOf(t, messages[2]) + if len(userParts) != 4 { + t.Fatalf("want a lead text and three images, got %d: %s", len(userParts), messages[2].Content) + } + for i, want := range []string{ + `{"type":"image_url","image_url":{"url":"data:image/png;base64,` + firstPayload + `"}}`, + `{"type":"image_url","image_url":{"url":"data:image/gif;base64,` + secondPayload + `"}}`, + `{"type":"image_url","image_url":{"url":"data:image/bmp;base64,` + thirdPayload + `"}}`, + } { + if string(userParts[i+1]) != want { + t.Fatalf("image %d = %s\nwant %s", i, userParts[i+1], want) + } + } +} + +// The tool messages of one assistant turn stay contiguous: the images wait for +// the end of the run and go out in a single user message after the last of +// them, before whatever follows. +func TestToolRunFlushesAfterTheLastToolMessage(t *testing.T) { + messages := convertPrompt(t, + assistantText("reading both"), + toolMessage("call_1", textPart("Image read successfully"), mediaPart("image/png", firstPayload)), + toolMessage("call_2", textPart("Image read successfully"), mediaPart("image/gif", secondPayload)), + assistantText("both read"), + ) + assertRoles(t, messages, "assistant", "tool", "tool", "user", "assistant") + + userParts := partsOf(t, messages[3]) + if len(userParts) != 3 { + t.Fatalf("want a lead text and two images, got %d: %s", len(userParts), messages[3].Content) + } + if !strings.Contains(string(userParts[1]), firstPayload) || + !strings.Contains(string(userParts[2]), secondPayload) { + t.Fatalf("images out of order: %s", messages[3].Content) + } +} + +// A run that ends the prompt flushes at the end, so the images are the last +// thing the model sees. +func TestToolRunAtTheEndOfThePromptFlushesLast(t *testing.T) { + messages := convertPrompt(t, + msgmodel.ModelMessage{Role: "user", Content: "read this"}, + assistantText("reading"), + toolMessage("call_1", textPart("Image read successfully"), mediaPart("image/png", firstPayload)), + ) + assertRoles(t, messages, "user", "assistant", "tool", "user") + if !strings.Contains(string(messages[3].Content), firstPayload) { + t.Fatalf("trailing user message carries no image: %s", messages[3].Content) + } +} + +// A run with nothing to relocate emits no user message at all. +func TestToolRunWithoutImagesEmitsNoUserMessage(t *testing.T) { + messages := convertPrompt(t, + assistantText("reading"), + toolMessage("call_1", textPart(" 1\thello\n")), + toolMessage("call_2", textPart(" 1\tworld\n")), + assistantText("done"), + ) + assertRoles(t, messages, "assistant", "tool", "tool", "assistant") +} + +// A PDF has no inline shape anywhere here: it is named, its payload never goes +// out, and nothing follows the tool message. +func TestToolResultPDFIsNamedAndSendsNoUserMessage(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", + textPart("PDF read successfully"), + mediaPart("application/pdf", firstPayload), + )) + assertRoles(t, messages, "tool") + parts := partsOf(t, messages[0]) + if len(parts) != 2 { + t.Fatalf("want two parts, got %d: %s", len(parts), messages[0].Content) + } + want := `{"type":"text","text":"[attachment: application/pdf (content not sent)]"}` + if string(parts[1]) != want { + t.Fatalf("pdf part = %s\nwant %s", parts[1], want) + } + assertNoPayload(t, messages) +} + +// A media part with no media type still gets named rather than stringified. +func TestToolResultMediaWithoutMediaTypeIsNamed(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", mediaPart("", firstPayload))) + assertRoles(t, messages, "tool") + parts := partsOf(t, messages[0]) + want := `{"type":"text","text":"[attachment: unknown (content not sent)]"}` + if string(parts[0]) != want { + t.Fatalf("part = %s\nwant %s", parts[0], want) + } + assertNoPayload(t, messages) +} + +// A text-only tool result is untouched by the media handling: one tool +// message, exactly these bytes, and nothing after it. +func TestToolResultTextOnlyIsUnchanged(t *testing.T) { + converted, err := ConvertToOpenRouterChatMessages([]msgmodel.ModelMessage{ + toolMessage("call_1", textPart(" 1\thello\n")), + }) + if err != nil { + t.Fatal(err) + } + if len(converted) != 1 { + t.Fatalf("want one message, got %d", len(converted)) + } + encoded, err := json.Marshal(converted[0]) + if err != nil { + t.Fatal(err) + } + want := `{"role":"tool","tool_call_id":"call_1",` + + `"content":[{"type":"text","text":" 1\thello\n"}],"name":"read"}` + if string(encoded) != want { + t.Fatalf("tool message = %s\nwant %s", encoded, want) + } +} + +// An element that is neither text nor media keeps the stringified fallback. +func TestToolResultUnknownElementIsStillStringified(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", map[string]any{"type": "widget", "n": 1})) + assertRoles(t, messages, "tool") + parts := partsOf(t, messages[0]) + var typed struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(parts[0], &typed); err != nil { + t.Fatal(err) + } + if typed.Type != "text" || !strings.Contains(typed.Text, `"widget"`) { + t.Fatalf("unknown element = %s", parts[0]) + } +} diff --git a/internal/seniordev/engine/orclient/convert_user_content_test.go b/internal/seniordev/engine/orclient/convert_user_content_test.go new file mode 100644 index 000000000..81669583c --- /dev/null +++ b/internal/seniordev/engine/orclient/convert_user_content_test.go @@ -0,0 +1,108 @@ +//go:build !windows + +package orclient + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// A user message whose content is anything but a string or a []any of content +// parts must be REFUSED at the wire, not sent as `"content": []`. +func TestConvertUserMessageRefusesUnconvertibleContent(t *testing.T) { + cases := map[string]any{ + "typed text slice": []msgmodel.TextContent{{Type: "text", Text: "hello"}}, + "empty part list": []any{}, + "nil": nil, + "empty string": "", + "number": 42, + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + _, err := ConvertToOpenRouterChatMessages([]msgmodel.ModelMessage{ + {Role: "user", Content: content}, + }) + if err == nil { + t.Fatalf("content %#v was converted instead of refused", content) + } + }) + } +} + +func TestConvertUserMessageAcceptsStringAndCanonicalParts(t *testing.T) { + const text = "\nHELLO TRANSCRIPT\n" + for name, msg := range map[string]msgmodel.ModelMessage{ + "bare string": {Role: "user", Content: text}, + "UserText": msgmodel.UserText(text), + } { + t.Run(name, func(t *testing.T) { + body, err := BuildRequestBody(RequestParams{ModelID: "m", Prompt: []msgmodel.ModelMessage{msg}}) + if err != nil { + t.Fatal(err) + } + want := `{"role":"user","content":"\nHELLO TRANSCRIPT\n"}` + if !strings.Contains(string(body), want) { + t.Fatalf("wire body = %s\nwant message %s", body, want) + } + }) + } +} + +// An assistant message with bare string content (the max-steps prompt is +// built that way) must reach the wire as text, not as an empty message. +func TestConvertAssistantMessageAcceptsStringContent(t *testing.T) { + body, err := BuildRequestBody(RequestParams{ModelID: "m", Prompt: []msgmodel.ModelMessage{ + {Role: "assistant", Content: "You have reached the step limit."}, + }}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"content":"You have reached the step limit."`) { + t.Fatalf("assistant text lost on the wire: %s", body) + } + if _, err := ConvertToOpenRouterChatMessages([]msgmodel.ModelMessage{ + {Role: "assistant", Content: []msgmodel.TextContent{{Type: "text", Text: "x"}}}, + }); err == nil { + t.Fatal("typed assistant content was converted instead of refused") + } +} + +// The whole request body, the way DoStream builds it: the transcript handed +// to the summarizer must be present verbatim in the bytes that leave. +func TestBuildRequestBodyCarriesTheSummaryTranscript(t *testing.T) { + const transcript = "[User]: Fix src/a.go\n[Assistant]: Reading the file first." + maximum := float64(1024) + body, err := BuildRequestBody(RequestParams{ + ModelID: "vendor/model", + MaxOutputTokens: &maximum, + Prompt: []msgmodel.ModelMessage{ + {Role: "system", Content: "You are a context serializer."}, + msgmodel.UserText("\n" + transcript + "\n\n\n## Working State"), + }, + }) + if err != nil { + t.Fatal(err) + } + var decoded struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("body is not the expected shape: %v\n%s", err, body) + } + if len(decoded.Messages) != 2 || decoded.Messages[1].Role != "user" { + t.Fatalf("wire messages = %#v", decoded.Messages) + } + var content string + if err := json.Unmarshal(decoded.Messages[1].Content, &content); err != nil { + t.Fatalf("user content is not a bare string: %s", decoded.Messages[1].Content) + } + if !strings.Contains(content, transcript) { + t.Fatalf("summary request lost its transcript on the wire:\n%s", body) + } +} diff --git a/internal/seniordev/engine/orclient/helpers_test.go b/internal/seniordev/engine/orclient/helpers_test.go new file mode 100644 index 000000000..8cf1ee57d --- /dev/null +++ b/internal/seniordev/engine/orclient/helpers_test.go @@ -0,0 +1,40 @@ +//go:build !windows + +package orclient + +import ( + "io" + "math/rand" + "strings" +) + +// Shared helpers for the tests in this package. + +// runSSE drives the translator over a raw SSE body the way Stream.Next does: +// decode a frame, drop `[DONE]`, parse, transform; flush at end of stream. +func runSSE(raw string, seed uint32) (parts []StreamPart, thrown error) { + restore := SetRandomForTesting(rand.New(rand.NewSource(int64(seed))).Float64) + defer restore() + + tr := NewTranslator() + dec := NewSSEDecoder(strings.NewReader(raw)) + for { + ev, err := dec.Next() + if err == io.EOF { + break + } + if err != nil { + break + } + if ev.Data == DoneSentinel { + continue + } + emitted, err := tr.Transform(ParseChunk(ev.Data)) + parts = append(parts, emitted...) + if err != nil { + // A throw out of the transform tears the stream down: no flush. + return parts, err + } + } + return append(parts, tr.Flush()...), nil +} diff --git a/internal/seniordev/engine/orclient/jsonval.go b/internal/seniordev/engine/orclient/jsonval.go new file mode 100644 index 000000000..9dbcd1b84 --- /dev/null +++ b/internal/seniordev/engine/orclient/jsonval.go @@ -0,0 +1,483 @@ +//go:build !windows + +package orclient + +// An insertion-ordered JSON value model. +// +// The request body, the messages inside it and the tool definitions are +// built with this model instead of map[string]any so that the same input +// always produces the same bytes: key order is the order the code writes +// keys in (or, for DeterministicStringify, sorted), which keeps prompt-cache +// keys stable across calls. Numbers parsed from JSON keep their original +// literal so a re-encoded document does not drift. + +import ( + "bytes" + "encoding/json" + "errors" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// jsonKind tags a jsonValue. +type jsonKind uint8 + +const ( + kindNull jsonKind = iota + kindBool + kindNumber + kindString + kindArray + kindObject +) + +// jsonValue is a JSON value with object key order preserved. +type jsonValue struct { + Kind jsonKind + Bool bool + Number float64 + // Literal is the number's original JSON text when it was parsed rather + // than computed; it is re-emitted verbatim. + Literal string + String string + Array []jsonValue + Object []jsonMember +} + +type jsonMember struct { + Key string + Value jsonValue +} + +// parseJSONValue decodes raw into the ordered model. +func parseJSONValue(raw []byte) (jsonValue, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + v, err := decodeJSONValue(dec) + if err != nil { + return jsonValue{}, err + } + if _, err := dec.Token(); err == nil { + return jsonValue{}, errors.New("unexpected trailing JSON content") + } + return v, nil +} + +func decodeJSONValue(dec *json.Decoder) (jsonValue, error) { + tok, err := dec.Token() + if err != nil { + return jsonValue{}, err + } + return decodeJSONValueFrom(dec, tok) +} + +func decodeJSONValueFrom(dec *json.Decoder, tok json.Token) (jsonValue, error) { + switch t := tok.(type) { + case nil: + return jsonValue{Kind: kindNull}, nil + case bool: + return jsonValue{Kind: kindBool, Bool: t}, nil + case string: + return jsonValue{Kind: kindString, String: t}, nil + case json.Number: + f, _ := strconv.ParseFloat(t.String(), 64) + return jsonValue{Kind: kindNumber, Number: f, Literal: t.String()}, nil + case json.Delim: + switch t { + case '[': + items := []jsonValue{} + for dec.More() { + item, err := decodeJSONValue(dec) + if err != nil { + return jsonValue{}, err + } + items = append(items, item) + } + if _, err := dec.Token(); err != nil { + return jsonValue{}, err + } + return jsonValue{Kind: kindArray, Array: items}, nil + case '{': + var members []jsonMember + index := map[string]int{} + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return jsonValue{}, err + } + key, ok := keyTok.(string) + if !ok { + return jsonValue{}, errors.New("expected object key") + } + value, err := decodeJSONValue(dec) + if err != nil { + return jsonValue{}, err + } + // A duplicate key keeps its first position and takes the + // later value. + if at, seen := index[key]; seen { + members[at].Value = value + continue + } + index[key] = len(members) + members = append(members, jsonMember{Key: key, Value: value}) + } + if _, err := dec.Token(); err != nil { + return jsonValue{}, err + } + return jsonValue{Kind: kindObject, Object: members}, nil + } + } + return jsonValue{}, errors.New("unexpected JSON token") +} + +// marshalJSONValue encodes a value compactly, without HTML escaping. +func marshalJSONValue(v jsonValue) ([]byte, error) { + var buf bytes.Buffer + if err := writeJSONValue(&buf, v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeJSONValue(buf *bytes.Buffer, v jsonValue) error { + switch v.Kind { + case kindNull: + buf.WriteString("null") + case kindBool: + if v.Bool { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case kindNumber: + if v.Literal != "" { + buf.WriteString(v.Literal) + } else { + b, err := json.Marshal(v.Number) + if err != nil { + return err + } + buf.Write(b) + } + case kindString: + b, err := jsonutil.Marshal(v.String) + if err != nil { + return err + } + buf.Write(b) + case kindArray: + buf.WriteByte('[') + for i, el := range v.Array { + if i > 0 { + buf.WriteByte(',') + } + if err := writeJSONValue(buf, el); err != nil { + return err + } + } + buf.WriteByte(']') + case kindObject: + buf.WriteByte('{') + for i, m := range v.Object { + if i > 0 { + buf.WriteByte(',') + } + k, err := jsonutil.Marshal(m.Key) + if err != nil { + return err + } + buf.Write(k) + buf.WriteByte(':') + if err := writeJSONValue(buf, m.Value); err != nil { + return err + } + } + buf.WriteByte('}') + } + return nil +} + +// ── DeterministicStringify ──────────────────────────────────────────────── + +// DeterministicStringify re-encodes a JSON document with every object's keys +// sorted, recursively. Assistant tool-call arguments go through it before +// they are sent back to the provider so the same call always serialises the +// same way. An empty input is reported rather than encoded, since the caller +// omits the field in that case. +func DeterministicStringify(raw json.RawMessage) ([]byte, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, errUndefinedStringify + } + v, err := parseJSONValue(raw) + if err != nil { + return nil, err + } + return marshalJSONValue(sortKeys(v)) +} + +var errUndefinedStringify = errors.New("deterministicStringify: undefined value") + +// sortKeys sorts object keys recursively; arrays recurse element-wise. +func sortKeys(v jsonValue) jsonValue { + switch v.Kind { + case kindArray: + out := make([]jsonValue, len(v.Array)) + for i, el := range v.Array { + out[i] = sortKeys(el) + } + return jsonValue{Kind: kindArray, Array: out} + case kindObject: + members := make([]jsonMember, len(v.Object)) + copy(members, v.Object) + sort.SliceStable(members, func(i, j int) bool { + return members[i].Key < members[j].Key + }) + for i := range members { + members[i].Value = sortKeys(members[i].Value) + } + return jsonValue{Kind: kindObject, Object: members} + default: + return v + } +} + +// ── ordered object builder ──────────────────────────────────────────────── + +// Object is an insertion-ordered JSON object. Setting an existing key +// replaces its value in place. +type Object struct { + members []jsonMember + index map[string]int +} + +// NewObject builds an empty ordered object. +func NewObject() *Object { return &Object{index: map[string]int{}} } + +// ParseObject decodes a JSON object literal into an ordered Object. Empty +// input yields an empty object. +func ParseObject(raw []byte) (*Object, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return NewObject(), nil + } + v, err := parseJSONValue(raw) + if err != nil { + return nil, err + } + if v.Kind != kindObject { + return nil, errors.New("orclient: expected a JSON object") + } + return objectFromValue(v), nil +} + +func (o *Object) set(key string, val jsonValue) { + if o.index == nil { + o.index = map[string]int{} + } + if at, ok := o.index[key]; ok { + o.members[at].Value = val + return + } + o.index[key] = len(o.members) + o.members = append(o.members, jsonMember{Key: key, Value: val}) +} + +// Set assigns raw JSON to key. +func (o *Object) Set(key string, raw json.RawMessage) error { + v, err := parseJSONValue(raw) + if err != nil { + return err + } + o.set(key, v) + return nil +} + +// SetString assigns a string value. +func (o *Object) SetString(key, value string) { + o.set(key, jsonValue{Kind: kindString, String: value}) +} + +// SetNumber assigns a numeric value. +func (o *Object) SetNumber(key string, value float64) { + o.set(key, jsonValue{Kind: kindNumber, Number: value}) +} + +// SetBool assigns a boolean value. +func (o *Object) SetBool(key string, value bool) { + o.set(key, jsonValue{Kind: kindBool, Bool: value}) +} + +// SetObject assigns a nested ordered object. +func (o *Object) SetObject(key string, value *Object) { + o.set(key, value.value()) +} + +// SetNumberPtr assigns a number, or leaves the key absent when value is nil. +func (o *Object) SetNumberPtr(key string, value *float64) { + if value == nil { + return + } + o.SetNumber(key, *value) +} + +// SetArray assigns an array of already-built ordered objects. +func (o *Object) SetArray(key string, items []*Object) { + vals := make([]jsonValue, 0, len(items)) + for _, it := range items { + vals = append(vals, it.value()) + } + o.set(key, jsonValue{Kind: kindArray, Array: vals}) +} + +// Has reports whether key is present. +func (o *Object) Has(key string) bool { + if o == nil || o.index == nil { + return false + } + _, ok := o.index[key] + return ok +} + +// Keys returns the keys in insertion order. +func (o *Object) Keys() []string { + if o == nil { + return nil + } + out := make([]string, 0, len(o.members)) + for _, m := range o.members { + out = append(out, m.Key) + } + return out +} + +// Len is the number of keys. +func (o *Object) Len() int { + if o == nil { + return 0 + } + return len(o.members) +} + +// Get returns the raw JSON of one key. +func (o *Object) Get(key string) (json.RawMessage, bool) { + if o == nil || o.index == nil { + return nil, false + } + at, ok := o.index[key] + if !ok { + return nil, false + } + b, err := marshalJSONValue(o.members[at].Value) + if err != nil { + return nil, false + } + return b, true +} + +func (o *Object) value() jsonValue { + if o == nil { + return jsonValue{Kind: kindObject} + } + members := make([]jsonMember, len(o.members)) + copy(members, o.members) + return jsonValue{Kind: kindObject, Object: members} +} + +// MarshalJSON writes the object in insertion order. +func (o *Object) MarshalJSON() ([]byte, error) { + return marshalJSONValue(o.value()) +} + +// Clone is a shallow copy (values are immutable in this model). +func (o *Object) Clone() *Object { + out := NewObject() + if o == nil { + return out + } + for _, m := range o.members { + out.set(m.Key, m.Value) + } + return out +} + +// MergeOptions deep-merges source into target: target's keys come first in +// their own order, source-only keys are appended in source order, and a key +// whose value is an object on both sides is merged recursively in place. +func MergeOptions(target, source *Object) *Object { + out := NewObject() + if target != nil { + for _, m := range target.members { + out.set(m.Key, m.Value) + } + } + if source == nil { + return out + } + for _, m := range source.members { + out.set(m.Key, m.Value) + } + for _, m := range source.members { + if target == nil || !target.Has(m.Key) { + continue + } + left := target.members[target.index[m.Key]].Value + if left.Kind != kindObject || m.Value.Kind != kindObject { + continue + } + out.set(m.Key, MergeOptions(objectFromValue(left), objectFromValue(m.Value)).value()) + } + return out +} + +func objectFromValue(v jsonValue) *Object { + o := NewObject() + for _, m := range v.Object { + o.set(m.Key, m.Value) + } + return o +} + +// stringValue is a small helper for building literal JSON in the body writer. +func stringValue(s string) jsonValue { return jsonValue{Kind: kindString, String: s} } + +func rawJSONValue(raw json.RawMessage) jsonValue { + v, err := parseJSONValue(raw) + if err != nil { + return jsonValue{Kind: kindNull} + } + return v +} + +// textOf renders a JSON scalar as plain text: strings verbatim, numbers and +// booleans as their literals, null as "null". Arrays join their elements +// with commas; objects have no useful text form. +func textOf(v jsonValue) string { + switch v.Kind { + case kindNull: + return "null" + case kindBool: + return strconv.FormatBool(v.Bool) + case kindNumber: + if v.Literal != "" { + return v.Literal + } + return strconv.FormatFloat(v.Number, 'f', -1, 64) + case kindString: + return v.String + case kindArray: + parts := make([]string, 0, len(v.Array)) + for _, el := range v.Array { + if el.Kind == kindNull { + parts = append(parts, "") + continue + } + parts = append(parts, textOf(el)) + } + return strings.Join(parts, ",") + default: + return "" + } +} diff --git a/internal/seniordev/engine/orclient/lowercase.go b/internal/seniordev/engine/orclient/lowercase.go new file mode 100644 index 000000000..ad76f0835 --- /dev/null +++ b/internal/seniordev/engine/orclient/lowercase.go @@ -0,0 +1,74 @@ +//go:build !windows + +package orclient + +// Full Unicode lowercasing. strings.ToLower uses simple rune mappings; the two +// unconditional rules from Unicode SpecialCasing.txt (dotted capital I, final +// sigma) are applied here as well, so a tool name in any script lowercases the +// way its speakers expect. Tool-call repair runs this on provider-controlled +// names. + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +func unicodeLower(s string) string { + ascii := true + for i := 0; i < len(s); i++ { + if s[i] >= utf8.RuneSelf { + ascii = false + break + } + } + if ascii { + return strings.ToLower(s) + } + + runes := []rune(s) + var b strings.Builder + b.Grow(len(s)) + for i, r := range runes { + switch { + case r == 0x0130: + b.WriteRune('i') + b.WriteRune(0x0307) + case r == 0x03A3 && isFinalSigma(runes, i): + b.WriteRune(0x03C2) + default: + b.WriteRune(unicode.ToLower(r)) + } + } + return b.String() +} + +func isFinalSigma(runes []rune, i int) bool { + j := i - 1 + for j >= 0 && isCaseIgnorable(runes[j]) { + j-- + } + if j < 0 || !isCased(runes[j]) { + return false + } + k := i + 1 + for k < len(runes) && isCaseIgnorable(runes[k]) { + k++ + } + return k >= len(runes) || !isCased(runes[k]) +} + +func isCased(r rune) bool { + return unicode.IsUpper(r) || unicode.IsLower(r) || unicode.IsTitle(r) || + unicode.Is(unicode.Other_Lowercase, r) || unicode.Is(unicode.Other_Uppercase, r) +} + +func isCaseIgnorable(r rune) bool { + switch r { + case '\'', 0x2019, 0x00AD, 0x02B9, 0x0385, 0x1FBF, 0x1FC1, 0x1FCD, 0x1FCE, + 0x1FCF, 0x1FDD, 0x1FDE, 0x1FDF, 0x1FED, 0x1FEE, 0x1FEF, 0x1FFD, 0x1FFE, 0x2027: + return true + } + return unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Cf, r) || + unicode.Is(unicode.Lm, r) || unicode.Is(unicode.Sk, r) +} diff --git a/internal/seniordev/engine/orclient/orclient.go b/internal/seniordev/engine/orclient/orclient.go new file mode 100644 index 000000000..cf2fe0f24 --- /dev/null +++ b/internal/seniordev/engine/orclient/orclient.go @@ -0,0 +1,91 @@ +//go:build !windows + +// Package orclient is the OpenRouter streaming client: it assembles the +// chat-completions request body and headers, decodes the SSE response into +// stream parts (text, reasoning, tool calls, usage, finish), validates and +// repairs tool calls against the registered tool set, and registers the +// outcome of each call with the adaptive router. +// +// One DoStream call is one HTTP request. Nothing here loops, retries, +// executes a tool or touches session state; the caller owns all four. Early +// teardown cancels the request context rather than merely closing the body, +// so an abandoned stream never holds its connection open. +package orclient + +import ( + "math/rand/v2" + "sync" + "time" +) + +// ── seams ───────────────────────────────────────────────────────────────── + +var seamMu sync.Mutex + +// random is the id generator's randomness source; generateId draws one +// number per character. +var random func() float64 = defaultRandom + +// nowMS is the millisecond clock. +var nowMS func() float64 = func() float64 { return float64(time.Now().UnixMilli()) } + +// SetRandomForTesting swaps the randomness source. Returns a restore func. +func SetRandomForTesting(f func() float64) func() { + seamMu.Lock() + prev := random + random = f + seamMu.Unlock() + return func() { + seamMu.Lock() + random = prev + seamMu.Unlock() + } +} + +// SetNowForTesting swaps the clock. Returns a restore func. +func SetNowForTesting(f func() float64) func() { + seamMu.Lock() + prev := nowMS + nowMS = f + seamMu.Unlock() + return func() { + seamMu.Lock() + nowMS = prev + seamMu.Unlock() + } +} + +func currentRandom() func() float64 { + seamMu.Lock() + defer seamMu.Unlock() + return random +} + +func currentNow() func() float64 { + seamMu.Lock() + defer seamMu.Unlock() + return nowMS +} + +// idAlphabet is the id character set. +const idAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// idSize is the id length. +const idSize = 16 + +// generateId mints a 16-character id from the alphabet above, drawing one +// random number per character. +func generateId() string { + draw := currentRandom() + out := make([]byte, 0, idSize) + for i := 0; i < idSize; i++ { + idx := int(draw() * float64(len(idAlphabet))) + if idx < 0 || idx >= len(idAlphabet) { + continue + } + out = append(out, idAlphabet[idx]) + } + return string(out) +} + +func defaultRandom() float64 { return rand.Float64() } diff --git a/internal/seniordev/engine/orclient/orclient_test.go b/internal/seniordev/engine/orclient/orclient_test.go new file mode 100644 index 000000000..0feb59ccc --- /dev/null +++ b/internal/seniordev/engine/orclient/orclient_test.go @@ -0,0 +1,516 @@ +//go:build !windows + +package orclient + +// Unit tests for the SSE frame decoder, the tool-call accumulator's index +// model, the ordered-JSON spread semantics, and the surrogate handling. + +import ( + "encoding/json" + "io" + "strings" + "testing" + "unicode/utf16" +) + +// ── SSE decoder ─────────────────────────────────────────────────────────── + +func decodeAll(t *testing.T, raw string) []SSEEvent { + t.Helper() + dec := NewSSEDecoder(strings.NewReader(raw)) + var out []SSEEvent + for { + ev, err := dec.Next() + if err == io.EOF { + return out + } + if err != nil { + t.Fatalf("decode: %v", err) + } + out = append(out, ev) + } +} + +func TestSSEDecoder(t *testing.T) { + cases := []struct { + name string + in string + want []string + }{ + {"lf", "data: a\n\ndata: b\n\n", []string{"a", "b"}}, + {"crlf", "data: a\r\n\r\ndata: b\r\n\r\n", []string{"a", "b"}}, + {"bare cr", "data: a\r\rdata: b\r\r", []string{"a", "b"}}, + {"no space after colon", "data:a\n\n", []string{"a"}}, + {"exactly one leading space stripped", "data: a\n\n", []string{" a"}}, + {"comment lines ignored", ": OPENROUTER PROCESSING\n\ndata: a\n\n", []string{"a"}}, + {"utf8 bom is stripped", "\uFEFFdata: a\n\n", []string{"a"}}, + {"comment inside a frame", "data: a\n: note\ndata: b\n\n", []string{"a\nb"}}, + {"multiple data lines join with newline", "data: a\ndata: b\n\n", []string{"a\nb"}}, + {"empty data field", "data:\n\n", []string{""}}, + {"blank line with no data dispatches nothing", "\n\n\n", nil}, + {"trailing frame without blank line", "data: a\n\ndata: b", []string{"a", "b"}}, + {"trailing frame without newline at all", "data: a", []string{"a"}}, + {"event and id fields do not dispatch", "event: x\nid: 7\ndata: a\n\n", []string{"a"}}, + {"unknown field ignored", "banana: x\ndata: a\n\n", []string{"a"}}, + {"retry field ignored", "retry: 500\ndata: a\n\n", []string{"a"}}, + {"DONE is just data", "data: [DONE]\n\n", []string{"[DONE]"}}, + {"empty input", "", nil}, + {"only comments", ": a\n\n: b\n\n", nil}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := decodeAll(t, tc.in) + if len(got) != len(tc.want) { + t.Fatalf("event count: want %d %v, got %d %v", len(tc.want), tc.want, len(got), got) + } + for i := range got { + if got[i].Data != tc.want[i] { + t.Errorf("event %d: want %q, got %q", i, tc.want[i], got[i].Data) + } + } + }) + } +} + +func TestSSEDecoderKeepsEventAndIDFields(t *testing.T) { + got := decodeAll(t, "event: message\nid: 42\ndata: a\n\ndata: b\n\n") + if len(got) != 2 { + t.Fatalf("want 2 events, got %d", len(got)) + } + if got[0].Event != "message" || got[0].ID != "42" { + t.Errorf("first event: %+v", got[0]) + } + // `lastEventId` persists across events; `event` resets. + if got[1].Event != "" || got[1].ID != "42" { + t.Errorf("second event: %+v", got[1]) + } +} + +// ── the tool-call accumulator's index model ────────────────────────────── + +// Only a non-negative integer index extends length; any other key is stored +// without being iterated. +func TestToolCallArrayIndexSemantics(t *testing.T) { + a := newToolCallArray() + if a.length != 0 { + t.Fatalf("fresh array length: %d", a.length) + } + + a.setKey("-1", &toolCallSlot{id: "ghost"}) + if a.length != 0 { + t.Errorf("a negative index must NOT extend length, got %d", a.length) + } + if a.get(-1) == nil { + t.Error("the slot must still be reachable at -1") + } + + a.setKey("2", &toolCallSlot{id: "third"}) + if a.length != 3 { + t.Errorf("index 2 must set length to 3, got %d", a.length) + } + + var visited []string + a.iterate(func(s *toolCallSlot) { + if s == nil { + visited = append(visited, "") + return + } + visited = append(visited, s.id) + }) + want := []string{"", "", "third"} + if strings.Join(visited, ",") != strings.Join(want, ",") { + t.Errorf("iteration must walk 0..length-1 including holes: want %v, got %v", want, visited) + } + + a.setKey("1", &toolCallSlot{id: "second"}) + if a.length != 3 { + t.Errorf("filling a hole must not shrink or grow length, got %d", a.length) + } + + a.setKey("1.5", &toolCallSlot{id: "fractional"}) + a.setKey("01", &toolCallSlot{id: "leading-zero"}) + a.setKey("4294967295", &toolCallSlot{id: "uint32-max"}) + if a.length != 3 { + t.Errorf("non-canonical array properties must not extend length, got %d", a.length) + } + for _, key := range []string{"1.5", "01", "4294967295"} { + if a.getKey(key) == nil { + t.Errorf("plain array property %q must remain addressable", key) + } + } +} + +// ── isParsableJson ──────────────────────────────────────────────────────── + +func TestIsParsableJSON(t *testing.T) { + cases := map[string]bool{ + ``: false, + `{}`: true, + `{"a":1}`: true, + `{`: false, + `{"a":`: false, + `[]`: true, + `null`: true, + `"str"`: true, + `42`: true, + `{"a":1}{"b":2}`: false, + ` {"a":1} `: true, + `{"__proto__":{}}`: false, // secureJsonParse rejects it + `{"constructor":{"prototype":{}}}`: false, + `{"constructor":{"x":1}}`: true, + } + for input, want := range cases { + if got := isParsableJSON(input); got != want { + t.Errorf("isParsableJSON(%q) = %v, want %v", input, got, want) + } + } +} + +// ── SanitizeSurrogates: WTF-8 input ────────────────────────────────────── + +// A lone surrogate is not reachable through encoding/json (which maps +// `\uD800` to U+FFFD on decode), but it IS through the WTF-8 byte sequence, +// which a byte-level splice or a non-strict decoder can produce. +func TestSanitizeSurrogatesWTF8(t *testing.T) { + // WTF-8 for U+D800 (a lone high surrogate). + loneHigh := string([]byte{0xED, 0xA0, 0x80}) + // WTF-8 for U+DC00 (a lone low surrogate). + loneLow := string([]byte{0xED, 0xB0, 0x80}) + + if got := SanitizeSurrogates("a" + loneHigh + "b"); got != "a�b" { + t.Errorf("lone high (WTF-8): got %q", got) + } + if got := SanitizeSurrogates("a" + loneLow + "b"); got != "a�b" { + t.Errorf("lone low (WTF-8): got %q", got) + } + // CESU-8: a well-formed PAIR encoded as two 3-byte sequences is a valid + // pair and must be returned unchanged. + pair := loneHigh + string([]byte{0xED, 0xB0, 0x80}) + if got := SanitizeSurrogates(pair); got != pair { + t.Errorf("a well-formed CESU-8 pair must be returned byte-identical: got %q", got) + } + // Two lone highs in a row: the first is unpaired, the second is too. + both := loneHigh + loneHigh + if got := SanitizeSurrogates(both); got != "��" { + t.Errorf("two lone highs: got %q", got) + } + // A genuine astral character survives untouched and does not take the slow + // path at all. + if got := SanitizeSurrogates("a😀b"); got != "a😀b" { + t.Errorf("astral char must be untouched: got %q", got) + } + // A string that cannot contain a surrogate is returned by identity. + plain := "hello, 世界" + if got := SanitizeSurrogates(plain); got != plain { + t.Errorf("plain text must be untouched: got %q", got) + } +} + +func TestUTF16UnitsRoundTrip(t *testing.T) { + for _, s := range []string{"", "abc", "😀", "a😀b", "日本語", "�"} { + units := utf16Units(s) + if got := string(utf16.Decode(units)); got != s { + t.Errorf("round trip of %q gave %q", s, got) + } + } +} + +// ── ordered JSON ────────────────────────────────────────────────────────── + +func TestObjectSpreadKeepsPositionOnOverwrite(t *testing.T) { + target := NewObject() + target.SetNumber("a", 1) + target.SetBool("usage", false) + target.SetNumber("z", 2) + + source := NewObject() + source.SetBool("usage", true) + source.SetString("new", "x") + + out := target.Clone() + for _, k := range source.Keys() { + v, _ := source.Get(k) + if err := out.Set(k, v); err != nil { + t.Fatalf("set: %v", err) + } + } + encoded, err := out.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"a":1,"usage":true,"z":2,"new":"x"}` + if string(encoded) != want { + t.Errorf("want %s, got %s", want, encoded) + } +} + +func TestInvalidToolChoiceErrorMatchesProvider(t *testing.T) { + _, err := BuildRequestBody(RequestParams{ + ModelID: "x/y", + Tools: []Tool{{Type: "function", Name: "bash"}}, + ToolChoice: &ToolChoice{Type: "future", ToolName: "bash"}, + }) + if err == nil { + t.Fatal("expected an invalid tool choice to fail") + } + if got, want := err.Error(), `Invalid tool choice type: {"type":"future","toolName":"bash"}`; got != want { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestMergeOptionsDeepMergeSemantics(t *testing.T) { + target, err := ParseObject([]byte(`{"a":{"x":1,"y":2},"b":1,"keep":true}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + source, err := ParseObject([]byte(`{"a":{"y":9,"z":3},"b":{"deep":1},"new":5}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + got, err := MergeOptions(target, source).MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + // `a` recurses in place; `b` is replaced wholesale (target's value is not a + // plain object); `new` is appended. + want := `{"a":{"x":1,"y":9,"z":3},"b":{"deep":1},"keep":true,"new":5}` + if string(got) != want { + t.Errorf("want %s, got %s", want, got) + } +} + +// ── DeterministicStringify edge cases ──────────────────────────────────── + +func TestDeterministicStringifyUndefined(t *testing.T) { + // An absent input is reported to the caller, which drops the key. + if _, err := DeterministicStringify(nil); err == nil { + t.Error("an absent input must be reported, not guessed at") + } + if _, err := DeterministicStringify(json.RawMessage(" ")); err == nil { + t.Error("a whitespace-only input must be reported") + } + if _, err := DeterministicStringify(json.RawMessage("{not json")); err == nil { + t.Error("an unparsable input must be reported") + } +} + +func TestDeterministicStringifySortsKeysRecursively(t *testing.T) { + got, err := DeterministicStringify(json.RawMessage(`{"zeta":1,"Alpha":{"y":[{"b":1,"a":2}],"x":0},"_x":3.50}`)) + if err != nil { + t.Fatalf("DeterministicStringify: %v", err) + } + want := `{"Alpha":{"x":0,"y":[{"a":2,"b":1}]},"_x":3.50,"zeta":1}` + if string(got) != want { + t.Errorf("want %s, got %s", want, got) + } +} + +// ── generateId ──────────────────────────────────────────────────────────── + +func TestGenerateIDDrawsOncePerCharacter(t *testing.T) { + draws := 0 + restore := SetRandomForTesting(func() float64 { + draws++ + return 0 + }) + defer restore() + + id := generateId() + if draws != idSize { + t.Errorf("generateId must draw exactly %d times, drew %d", idSize, draws) + } + if len(id) != idSize { + t.Errorf("id length: want %d, got %d (%q)", idSize, len(id), id) + } + if id != strings.Repeat("0", idSize) { + t.Errorf("random()==0 must select alphabet[0]: got %q", id) + } +} + +// ── finish-reason mapping ───────────────────────────────────────────────── + +func TestMapToUnifiedCoversTheWholeDomain(t *testing.T) { + cases := map[string]string{ + "stop": FinishStop, + "length": FinishLength, + "content_filter": FinishContentFilter, + "function_call": FinishToolCalls, + "tool_calls": FinishToolCalls, + // `error` falls through to `other`, NOT to `error`. The only sources + // of unified `error` are a parse failure, a top-level error payload, + // and a reader error. + "error": FinishOther, + "": FinishOther, + "banana": FinishOther, + "STOP": FinishOther, + "toolUse": FinishOther, + } + for raw, want := range cases { + if got := MapToUnified(raw); got != want { + t.Errorf("MapToUnified(%q) = %q, want %q", raw, got, want) + } + } + if got := MapOpenRouterFinishReason(nil); got.Unified != FinishOther || got.Raw != nil { + t.Errorf("a nil finish_reason must give {other, absent}, got %+v", got) + } +} + +// ── tool map ordering ───────────────────────────────────────────────────── + +func TestSortedToolMapSortsByName(t *testing.T) { + m := SortedToolMap( + ToolSpec{Name: "zebra"}, + ToolSpec{Name: "Apple"}, + ToolSpec{Name: "_hidden"}, + ToolSpec{Name: "invalid"}, + ToolSpec{Name: "apple"}, + ) + got := strings.Join(m.Names(), ",") + want := "Apple,_hidden,apple,invalid,zebra" + if got != want { + t.Errorf("tool map order:\n want %s\n got %s", want, got) + } + active := strings.Join(m.ActiveTools(), ",") + if strings.Contains(active, "invalid") { + t.Errorf("activeTools must exclude the invalid tool, got %s", active) + } +} + +// ── reasoning-details live reference ────────────────────────────────────── + +func TestReasoningDetailsViewIsLive(t *testing.T) { + acc := []ReasoningDetail{{Type: ReasoningDetailText, Text: rawStringLiteral("A")}} + view := detailsRef(&acc) + + first, err := view.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(first) != `[{"type":"reasoning.text","text":"A"}]` { + t.Fatalf("initial: %s", first) + } + + acc = append(acc, ReasoningDetail{Type: ReasoningDetailSummary, Summary: "LATE"}) + second, err := view.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `[{"type":"reasoning.text","text":"A"},{"type":"reasoning.summary","summary":"LATE"}]` + if string(second) != want { + t.Errorf("a view handed out earlier must see LATER appends:\n want %s\n got %s", want, second) + } + + detached := DetailsValue(acc...) + acc = append(acc, ReasoningDetail{Type: ReasoningDetailEncrypted, Data: "E"}) + third, err := detached.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(third) != want { + t.Errorf("a DETACHED view must not see later appends: got %s", third) + } +} + +func TestEmptyReasoningDetailsMarshalAsAnArrayNotNull(t *testing.T) { + got, err := DetailsValue().MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != "[]" { + t.Errorf("the empty case is meaningful and must be `[]`, got %s", got) + } + var nilView ReasoningDetailsView + got, err = nilView.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != "[]" { + t.Errorf("a zero view must also be `[]`, got %s", got) + } +} + +// ── orTruthy over nullish JSON ─────────────────────────────────────────── + +func TestOrTruthyFalsiness(t *testing.T) { + cases := []struct { + a, b, want string + }{ + {`"x"`, `"y"`, `"x"`}, + {`null`, `"y"`, `"y"`}, + {`""`, `"y"`, `"y"`}, + {``, `"y"`, `"y"`}, + {`null`, ``, ``}, + {`""`, `null`, `null`}, + {`false`, `"y"`, `"y"`}, + {`0`, `"y"`, `"y"`}, + } + for _, tc := range cases { + var a, b json.RawMessage + if tc.a != "" { + a = json.RawMessage(tc.a) + } + if tc.b != "" { + b = json.RawMessage(tc.b) + } + got := orTruthy(a, b) + gotStr := "" + if got != nil { + gotStr = string(got) + } + if gotStr != tc.want { + t.Errorf("orTruthy(%q, %q) = %q, want %q", tc.a, tc.b, gotStr, tc.want) + } + } +} + +// ── tool-call deltas without an index ───────────────────────────────────── + +func TestToolCallDeltaWithoutIndex(t *testing.T) { + // A first no-index delta with non-parsable args must land at slot 0 and be + // flushed rather than dropped. + t.Run("first no-index delta appends at slot 0 and flushes", func(t *testing.T) { + sse := "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"f\",\"arguments\":\"\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n" + parts, thrown := runSSE(sse, 1234) + if thrown != nil { + t.Fatalf("unexpected error: %v", thrown) + } + // The tool call must be present: tool-input-start, tool-input-delta, + // tool-input-end, tool-call, then finish. + hasToolCall := false + for _, p := range parts { + if p.PartType() == PartTypeToolCall { + hasToolCall = true + break + } + } + if !hasToolCall { + t.Error("tool-call part missing — no-index delta was dropped instead of appended at slot 0") + } + }) + + // A later no-index delta still targets the last slot (length-1), merging into it. + t.Run("later no-index delta merges into last slot", func(t *testing.T) { + sse := "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"f\",\"arguments\":\"{\\\"x\\\":\"}}]}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n" + parts, thrown := runSSE(sse, 1234) + if thrown != nil { + t.Fatalf("unexpected error: %v", thrown) + } + // The second chunk has no index; it must merge into index 0 (the last slot). + var toolCall ToolCallPart + found := false + for _, p := range parts { + if tc, ok := p.(ToolCallPart); ok { + toolCall = tc + found = true + break + } + } + if !found { + t.Fatal("no tool-call part found") + } + if toolCall.Input != "{\"x\":1}" { + t.Errorf("merged input = %q, want %q", toolCall.Input, "{\"x\":1}") + } + }) +} diff --git a/internal/seniordev/engine/orclient/parts.go b/internal/seniordev/engine/orclient/parts.go new file mode 100644 index 000000000..6614c43ab --- /dev/null +++ b/internal/seniordev/engine/orclient/parts.go @@ -0,0 +1,565 @@ +//go:build !windows + +package orclient + +// The normalized stream-part union the OpenRouter translator emits. +// +// This union is distinct from the persisted message parts the step loop +// produces: this one uses `delta`, has `response-metadata`, and has no +// `step-start`/`step-finish` framing. +// +// Every variant carries its own MarshalJSON with an explicit key order, so a +// part serialises the same way every time. + +import ( + "bytes" + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// StreamPart is one emitted part. +type StreamPart interface { + PartType() string + json.Marshaler +} + +// Part type tags. +const ( + PartTypeResponseMetadata = "response-metadata" + PartTypeReasoningStart = "reasoning-start" + PartTypeReasoningDelta = "reasoning-delta" + PartTypeReasoningEnd = "reasoning-end" + PartTypeTextStart = "text-start" + PartTypeTextDelta = "text-delta" + PartTypeTextEnd = "text-end" + PartTypeSource = "source" + PartTypeToolInputStart = "tool-input-start" + PartTypeToolInputDelta = "tool-input-delta" + PartTypeToolInputEnd = "tool-input-end" + PartTypeToolCall = "tool-call" + PartTypeFile = "file" + PartTypeError = "error" + PartTypeFinish = "finish" + PartTypeAbort = "abort" +) + +// objectWriter builds a JSON object with explicit key order, skipping keys +// whose value is absent (nil). +type objectWriter struct { + buf bytes.Buffer + first bool + err error +} + +func newObjectWriter() *objectWriter { + w := &objectWriter{first: true} + w.buf.WriteByte('{') + return w +} + +func (w *objectWriter) raw(key string, value json.RawMessage) { + if w.err != nil || value == nil { + return + } + if !w.first { + w.buf.WriteByte(',') + } + w.first = false + k, err := jsonutil.Marshal(key) + if err != nil { + w.err = err + return + } + w.buf.Write(k) + w.buf.WriteByte(':') + w.buf.Write(value) +} + +func (w *objectWriter) str(key, value string) { + enc, err := jsonutil.Marshal(value) + if err != nil { + w.err = err + return + } + w.raw(key, enc) +} + +func (w *objectWriter) marshal(key string, value any) { + enc, err := json.Marshal(value) + if err != nil { + w.err = err + return + } + w.raw(key, enc) +} + +func (w *objectWriter) done() ([]byte, error) { + if w.err != nil { + return nil, w.err + } + w.buf.WriteByte('}') + return w.buf.Bytes(), nil +} + +// ── response-metadata ───────────────────────────────────────────────────── + +// ResponseMetadataPart is emitted TWICE per chunk that carries both an `id` +// and a `model`: once with only `id`, once with only `modelId`. They are +// deliberately separate parts, not one merged part. +type ResponseMetadataPart struct { + ID string + ModelID string + // IsModel selects which of the two emissions this is. + IsModel bool +} + +func (p ResponseMetadataPart) PartType() string { return PartTypeResponseMetadata } + +func (p ResponseMetadataPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeResponseMetadata) + if p.IsModel { + w.str("modelId", p.ModelID) + } else { + w.str("id", p.ID) + } + return w.done() +} + +// ── reasoning ───────────────────────────────────────────────────────────── + +// ReasoningStartPart is `{type:"reasoning-start", id}`. +type ReasoningStartPart struct{ ID string } + +func (p ReasoningStartPart) PartType() string { return PartTypeReasoningStart } + +func (p ReasoningStartPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeReasoningStart) + w.str("id", p.ID) + return w.done() +} + +// ReasoningDeltaPart is `{type:"reasoning-delta", delta, id}` — note `delta` +// precedes `id` here, the reverse of the tool-input parts. +type ReasoningDeltaPart struct { + Delta string + ID string +} + +func (p ReasoningDeltaPart) PartType() string { return PartTypeReasoningDelta } + +func (p ReasoningDeltaPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeReasoningDelta) + w.str("delta", p.Delta) + w.str("id", p.ID) + return w.done() +} + +// ReasoningDetailsView is a LIVE reference to the provider's +// `accumulatedReasoningDetails` array. +// +// This indirection is deliberate: every emitted part shares the SAME +// accumulator, so a `reasoning-end` emitted at chunk 2 and serialised after +// the stream ends shows entries that only arrived at chunk 3. Copying at emit +// time would lose them. +type ReasoningDetailsView struct { + ref *[]ReasoningDetail + own []ReasoningDetail +} + +// DetailsValue builds a detached view, for hand-constructed parts and tests. +func DetailsValue(details ...ReasoningDetail) ReasoningDetailsView { + return ReasoningDetailsView{own: details} +} + +func detailsRef(ref *[]ReasoningDetail) ReasoningDetailsView { + return ReasoningDetailsView{ref: ref} +} + +// Slice resolves the view. +func (v ReasoningDetailsView) Slice() []ReasoningDetail { + if v.ref != nil { + return *v.ref + } + return v.own +} + +// MarshalJSON always writes an array, never null: the empty case is meaningful. +// It signals "the provider produced no reasoning tokens this turn", which is a +// different statement from "no metadata". +func (v ReasoningDetailsView) MarshalJSON() ([]byte, error) { + details := v.Slice() + if details == nil { + details = []ReasoningDetail{} + } + return json.Marshal(details) +} + +// ReasoningEndPart carries the FULL accumulated reasoning_details array. +type ReasoningEndPart struct { + ID string + Details ReasoningDetailsView +} + +func (p ReasoningEndPart) PartType() string { return PartTypeReasoningEnd } + +func (p ReasoningEndPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeReasoningEnd) + w.str("id", p.ID) + w.marshal("providerMetadata", reasoningProviderMetadata(p.Details)) + return w.done() +} + +// reasoningProviderMetadata is `{openrouter:{reasoning_details:[...]}}`. +type reasoningProviderMetadataValue struct { + Openrouter reasoningDetailsEnvelope `json:"openrouter"` +} + +type reasoningDetailsEnvelope struct { + ReasoningDetails ReasoningDetailsView `json:"reasoning_details"` +} + +func reasoningProviderMetadata(details ReasoningDetailsView) reasoningProviderMetadataValue { + return reasoningProviderMetadataValue{Openrouter: reasoningDetailsEnvelope{ReasoningDetails: details}} +} + +// ── text ────────────────────────────────────────────────────────────────── + +// TextStartPart's id is the OpenRouter response id (`gen-…`) when one has been +// seen, else a freshly minted 16-char id. +type TextStartPart struct{ ID string } + +func (p TextStartPart) PartType() string { return PartTypeTextStart } + +func (p TextStartPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeTextStart) + w.str("id", p.ID) + return w.done() +} + +// TextDeltaPart is `{type:"text-delta", delta, id}`. +type TextDeltaPart struct { + Delta string + ID string +} + +func (p TextDeltaPart) PartType() string { return PartTypeTextDelta } + +func (p TextDeltaPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeTextDelta) + w.str("delta", p.Delta) + w.str("id", p.ID) + return w.done() +} + +// TextEndPart is `{type:"text-end", id}`. +type TextEndPart struct{ ID string } + +func (p TextEndPart) PartType() string { return PartTypeTextEnd } + +func (p TextEndPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeTextEnd) + w.str("id", p.ID) + return w.done() +} + +// ── source ──────────────────────────────────────────────────────────────── + +// SourcePart's `id` is THE URL ITSELF, not a generated id. +type SourcePart struct { + URL string + Title string + Content string + StartIndex float64 + EndIndex float64 +} + +func (p SourcePart) PartType() string { return PartTypeSource } + +func (p SourcePart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeSource) + w.str("sourceType", "url") + w.str("id", p.URL) + w.str("url", p.URL) + w.str("title", p.Title) + inner := newObjectWriter() + inner.str("content", p.Content) + inner.marshal("startIndex", float64(p.StartIndex)) + inner.marshal("endIndex", float64(p.EndIndex)) + innerRaw, err := inner.done() + if err != nil { + return nil, err + } + outer := newObjectWriter() + outer.raw("openrouter", innerRaw) + outerRaw, err := outer.done() + if err != nil { + return nil, err + } + w.raw("providerMetadata", outerRaw) + return w.done() +} + +// ── tool input / call ───────────────────────────────────────────────────── + +// ToolInputStartPart is `{type:"tool-input-start", id, toolName}`. +type ToolInputStartPart struct { + ID string + ToolName string +} + +func (p ToolInputStartPart) PartType() string { return PartTypeToolInputStart } + +func (p ToolInputStartPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolInputStart) + w.str("id", p.ID) + w.str("toolName", p.ToolName) + return w.done() +} + +// ToolInputDeltaPart is `{type:"tool-input-delta", id, delta}`: `id` first, +// unlike the text/reasoning deltas. The processor ignores it; it exists so a +// consumer can render arguments as they stream. +type ToolInputDeltaPart struct { + ID string + Delta string +} + +func (p ToolInputDeltaPart) PartType() string { return PartTypeToolInputDelta } + +func (p ToolInputDeltaPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolInputDelta) + w.str("id", p.ID) + w.str("delta", p.Delta) + return w.done() +} + +// ToolInputEndPart is `{type:"tool-input-end", id}`. +type ToolInputEndPart struct{ ID string } + +func (p ToolInputEndPart) PartType() string { return PartTypeToolInputEnd } + +func (p ToolInputEndPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolInputEnd) + w.str("id", p.ID) + return w.done() +} + +// ToolCallPart's `input` is the RAW accumulated argument STRING, not a parsed +// object. `providerMetadata` is attached to the FIRST tool call only; later +// calls omit the key entirely. +type ToolCallPart struct { + ToolCallID string + ToolName string + Input string + + HasProviderMetadata bool + Details ReasoningDetailsView +} + +func (p ToolCallPart) PartType() string { return PartTypeToolCall } + +func (p ToolCallPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolCall) + w.str("toolCallId", p.ToolCallID) + w.str("toolName", p.ToolName) + w.str("input", p.Input) + if p.HasProviderMetadata { + w.marshal("providerMetadata", reasoningProviderMetadata(p.Details)) + } + return w.done() +} + +// ── file ────────────────────────────────────────────────────────────────── + +// FilePart is `{type:"file", mediaType, data}` from `delta.images[]`. +type FilePart struct { + MediaType string + Data string +} + +func (p FilePart) PartType() string { return PartTypeFile } + +func (p FilePart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeFile) + w.str("mediaType", p.MediaType) + w.str("data", p.Data) + return w.done() +} + +// ── error ───────────────────────────────────────────────────────────────── + +// ErrorPart carries whatever the translator put in `error`. Three sources: +// +// chunk parse failure → a validation-error object (see wire.go) +// `error` in the chunk → the RAW `error` object from the chunk +// reader error → the caught error, at flush +// +// In the second case, when the chunk validates as the CHUNK shape (because +// `choices` is present) the error object is passed through unchanged; when it +// validates as the ERROR shape, `code`/`type`/`param` are filled with null and +// the keys reordered to shape order first, extras after. +type ErrorPart struct { + Error json.RawMessage +} + +func (p ErrorPart) PartType() string { return PartTypeError } + +func (p ErrorPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeError) + if p.Error == nil { + w.raw("error", json.RawMessage("null")) + } else { + w.raw("error", p.Error) + } + return w.done() +} + +// AbortPart ends a stream that was cancelled. The reason, when present, is +// the cancellation cause's message. +type AbortPart struct { + Reason string + HasReason bool +} + +func (p AbortPart) PartType() string { return PartTypeAbort } + +func (p AbortPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeAbort) + if p.HasReason { + w.str("reason", p.Reason) + } + return w.done() +} + +// ── finish ──────────────────────────────────────────────────────────────── + +// FinishReason is the `{unified, raw}` pair. `raw` is absent when no +// `finish_reason` was ever seen, and survives the two synthetic promotions. +type FinishReason struct { + Unified string + Raw *string +} + +// MarshalJSON writes `{unified, raw?}`. +func (f FinishReason) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("unified", f.Unified) + if f.Raw != nil { + w.str("raw", *f.Raw) + } + return w.done() +} + +// Unified finish-reason values. +const ( + FinishStop = "stop" + FinishLength = "length" + FinishContentFilter = "content-filter" + FinishToolCalls = "tool-calls" + FinishError = "error" + FinishOther = "other" +) + +// MapToUnified maps a provider finish_reason to the unified set. Everything +// unrecognised, including `"error"`, becomes "other": unified `error` is +// reserved for a parse failure, a top-level error payload or a reader error. +func MapToUnified(finishReason string) string { + switch finishReason { + case "stop": + return FinishStop + case "length": + return FinishLength + case "content_filter": + return FinishContentFilter + case "function_call", "tool_calls": + return FinishToolCalls + } + return FinishOther +} + +// MapOpenRouterFinishReason wraps MapToUnified, keeping the raw value. +func MapOpenRouterFinishReason(finishReason *string) FinishReason { + if finishReason == nil { + return FinishReason{Unified: FinishOther} + } + raw := *finishReason + return FinishReason{Unified: MapToUnified(raw), Raw: &raw} +} + +// The openrouter usage metadata is an ORDERED accumulator, not a struct: keys +// are added as chunks arrive, so a key that first appears on chunk 2 lands at +// the END, after keys chunk 1 already wrote. A stream that reports +// `prompt_tokens_details` only on its second usage chunk therefore serialises +// as `{promptTokens, completionTokens, totalTokens, promptTokensDetails}`. +// Hence *Object, not a Go struct. +// +// senior-dev reads none of it: `cost` never enters the usage block, and +// `totalTokens` is discarded in favour of the `input + output` recomputation. + +// FinishPart is the single terminal part, emitted from `flush`. +type FinishPart struct { + FinishReason FinishReason + Usage calc.LanguageModelV3Usage + Metadata OpenRouterMetadata +} + +// OpenRouterMetadata is `providerMetadata.openrouter` at flush: `{usage}` +// first, then `provider` if the stream ever carried one, then +// `reasoning_details` (always), then `annotations` only when non-empty. +type OpenRouterMetadata struct { + Usage *Object + Provider *string + ReasoningDetails ReasoningDetailsView + Annotations []json.RawMessage +} + +func (m OpenRouterMetadata) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + usage := m.Usage + if usage == nil { + usage = NewObject() + } + w.marshal("usage", usage) + if m.Provider != nil { + w.str("provider", *m.Provider) + } + w.marshal("reasoning_details", m.ReasoningDetails) + if len(m.Annotations) > 0 { + w.marshal("annotations", m.Annotations) + } + return w.done() +} + +func (p FinishPart) PartType() string { return PartTypeFinish } + +func (p FinishPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeFinish) + w.marshal("finishReason", p.FinishReason) + w.marshal("usage", p.Usage) + inner := newObjectWriter() + inner.marshal("openrouter", p.Metadata) + innerRaw, err := inner.done() + if err != nil { + return nil, err + } + w.raw("providerMetadata", innerRaw) + return w.done() +} diff --git a/internal/seniordev/engine/orclient/reasoning.go b/internal/seniordev/engine/orclient/reasoning.go new file mode 100644 index 000000000..45982a26d --- /dev/null +++ b/internal/seniordev/engine/orclient/reasoning.go @@ -0,0 +1,330 @@ +//go:build !windows + +package orclient + +// reasoning_details: the provider's reasoning metadata, round-tripped. +// +// Three variants, all sharing `{id?: string|null, format?: enum|null, +// index?: number}`: +// +// reasoning.summary summary: string +// reasoning.encrypted data: string +// reasoning.text text?: string|null, signature?: string|null +// +// An entry that matches none of the three is dropped SILENTLY and PER-ENTRY, +// never fatally. +// +// Two things make this more than a struct: +// +// - parsing DROPS unknown keys and re-emits the known ones in a fixed +// SHAPE ORDER, not input order: `{signature,index,text,format,type,id,zzz}` +// comes back as `{type,text,signature,id,format,index}`. +// - `text` / `signature` / `id` / `format` may be null, and an explicit +// `null` is distinct from an absent key. They are therefore +// json.RawMessage (nil == absent) rather than *string. + +import ( + "bytes" + "encoding/json" + "strconv" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Reasoning detail type tags. +const ( + ReasoningDetailSummary = "reasoning.summary" + ReasoningDetailEncrypted = "reasoning.encrypted" + ReasoningDetailText = "reasoning.text" +) + +// DefaultReasoningFormat is assumed when a text detail names no format. +const DefaultReasoningFormat = "anthropic-claude-v1" + +// reasoningFormats is the accepted `format` set. A `format` outside it drops +// the whole entry. +var reasoningFormats = map[string]bool{ + "unknown": true, + "openai-responses-v1": true, + "azure-openai-responses-v1": true, + "xai-responses-v1": true, + "anthropic-claude-v1": true, + "google-gemini-v1": true, +} + +// ReasoningDetail is one parsed entry. +type ReasoningDetail struct { + Type string + + // Summary is required for the summary variant. + Summary string + // Data is required for the encrypted variant. + Data string + // Text / Signature may be null or absent on the text variant. + Text json.RawMessage + Signature json.RawMessage + + // Common, all optional. + ID json.RawMessage + Format json.RawMessage + Index *float64 + + // Raw is the normalized provider object. Once a detail crosses the + // provider boundary it is opaque metadata; keeping these bytes avoids a + // decode-to-map/re-encode round trip changing key order or number spelling. + // It is cleared only when the provider's consecutive-text merge mutates + // the detail. + Raw json.RawMessage +} + +// MarshalJSON writes the shape order: the variant's own keys first, then the +// three common ones. Absent keys are omitted; explicit nulls are written. +func (d ReasoningDetail) MarshalJSON() ([]byte, error) { + if d.Raw != nil { + return append([]byte(nil), d.Raw...), nil + } + return d.marshalNormalized() +} + +func (d ReasoningDetail) marshalNormalized() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + first := true + write := func(key string, raw json.RawMessage) error { + if raw == nil { + return nil + } + if !first { + buf.WriteByte(',') + } + first = false + k, err := jsonutil.Marshal(key) + if err != nil { + return err + } + buf.Write(k) + buf.WriteByte(':') + buf.Write(raw) + return nil + } + typeRaw, err := jsonutil.Marshal(d.Type) + if err != nil { + return nil, err + } + if err := write("type", typeRaw); err != nil { + return nil, err + } + switch d.Type { + case ReasoningDetailSummary: + s, err := jsonutil.Marshal(d.Summary) + if err != nil { + return nil, err + } + if err := write("summary", s); err != nil { + return nil, err + } + case ReasoningDetailEncrypted: + s, err := jsonutil.Marshal(d.Data) + if err != nil { + return nil, err + } + if err := write("data", s); err != nil { + return nil, err + } + case ReasoningDetailText: + if err := write("text", d.Text); err != nil { + return nil, err + } + if err := write("signature", d.Signature); err != nil { + return nil, err + } + } + if err := write("id", d.ID); err != nil { + return nil, err + } + if err := write("format", d.Format); err != nil { + return nil, err + } + if d.Index != nil { + if err := write("index", []byte(strconv.FormatFloat(*d.Index, 'f', -1, 64))); err != nil { + return nil, err + } + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// ParseReasoningDetails parses each entry and drops the unrecognised ones. +func ParseReasoningDetails(raw json.RawMessage) []ReasoningDetail { + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil + } + out := make([]ReasoningDetail, 0, len(entries)) + for _, entry := range entries { + d, ok := parseReasoningDetail(entry) + if !ok { + continue + } + out = append(out, d) + } + return out +} + +func parseReasoningDetail(raw json.RawMessage) (ReasoningDetail, bool) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return ReasoningDetail{}, false + } + typeRaw, ok := obj["type"] + if !ok { + return ReasoningDetail{}, false + } + var typ string + if err := json.Unmarshal(typeRaw, &typ); err != nil { + return ReasoningDetail{}, false + } + + d := ReasoningDetail{Type: typ} + switch typ { + case ReasoningDetailSummary: + s, ok := requiredString(obj, "summary") + if !ok { + return ReasoningDetail{}, false + } + d.Summary = s + case ReasoningDetailEncrypted: + s, ok := requiredString(obj, "data") + if !ok { + return ReasoningDetail{}, false + } + d.Data = s + case ReasoningDetailText: + text, ok := nullishRaw(obj, "text") + if !ok { + return ReasoningDetail{}, false + } + sig, ok := nullishRaw(obj, "signature") + if !ok { + return ReasoningDetail{}, false + } + d.Text = text + d.Signature = sig + default: + return ReasoningDetail{}, false + } + + id, ok := nullishRaw(obj, "id") + if !ok { + return ReasoningDetail{}, false + } + d.ID = id + + format, ok := nullishRaw(obj, "format") + if !ok { + return ReasoningDetail{}, false + } + if format != nil && !bytes.Equal(format, []byte("null")) { + var f string + if err := json.Unmarshal(format, &f); err != nil || !reasoningFormats[f] { + return ReasoningDetail{}, false + } + } + d.Format = format + + if idxRaw, present := obj["index"]; present { + var idx float64 + if err := json.Unmarshal(idxRaw, &idx); err != nil { + return ReasoningDetail{}, false + } + d.Index = &idx + } + normalized, err := d.marshalNormalized() + if err != nil { + return ReasoningDetail{}, false + } + d.Raw = normalized + return d, true +} + +func requiredString(obj map[string]json.RawMessage, key string) (string, bool) { + raw, present := obj[key] + if !present { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", false + } + return s, true +} + +// nullishRaw accepts absent (nil, true), explicit null, or a string. +func nullishRaw(obj map[string]json.RawMessage, key string) (json.RawMessage, bool) { + raw, present := obj[key] + if !present { + return nil, true + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return json.RawMessage("null"), true + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, false + } + enc, err := jsonutil.Marshal(s) + if err != nil { + return nil, false + } + return enc, true +} + +// orTruthy is `a || b` over a nullish JSON field: `null`, `""` and an absent +// value all fall through to b. The result may be nil (absent), which the +// enclosing marshal drops; that is how a merge can turn an explicit `null` +// signature into an ABSENT one. +func orTruthy(a, b json.RawMessage) json.RawMessage { + if rawTruthy(a) { + return a + } + return b +} + +func rawTruthy(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + switch { + case len(trimmed) == 0: + return false + case bytes.Equal(trimmed, []byte("null")): + return false + case bytes.Equal(trimmed, []byte(`""`)): + return false + case bytes.Equal(trimmed, []byte("false")): + return false + case bytes.Equal(trimmed, []byte("0")): + return false + } + return true +} + +// rawString unwraps a nullish JSON string to its Go value; `null` and absent +// both give "". +func rawString(raw json.RawMessage) string { + if !rawTruthy(raw) { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + return s +} + +// rawStringLiteral encodes a Go string as a JSON string. +func rawStringLiteral(s string) json.RawMessage { + enc, err := jsonutil.Marshal(s) + if err != nil { + return json.RawMessage(`""`) + } + return enc +} diff --git a/internal/seniordev/engine/orclient/routing.go b/internal/seniordev/engine/orclient/routing.go new file mode 100644 index 000000000..e59723a71 --- /dev/null +++ b/internal/seniordev/engine/orclient/routing.go @@ -0,0 +1,331 @@ +//go:build !windows + +package orclient + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strings" +) + +// ProviderRouting is OpenRouter's request-level `provider` object: the +// preferences that decide which upstream endpoint serves a model. Field names +// and enums follow https://openrouter.ai/docs/features/provider-routing +// exactly so a config author can paste from the OpenRouter docs. +// +// Every field is optional. A nil pointer or empty slice means "not set" and +// is omitted from the wire, so an all-empty value sends no `provider` key at +// all — routing is off unless something is configured. Parsing is strict: an +// unknown key or an out-of-range enum is an error, never a silent no-op, so a +// misspelled rule cannot look configured while the call routes on defaults. +type ProviderRouting struct { + // Order lists provider slugs to try in sequence. + Order []string `json:"order,omitempty"` + // AllowFallbacks lets OpenRouter fall back to other providers when the + // preferred ones are unavailable. OpenRouter's default is true. + AllowFallbacks *bool `json:"allow_fallbacks,omitempty"` + // RequireParameters excludes providers that do not support every + // parameter in the request (tools, temperature, top_k, ...). + RequireParameters *bool `json:"require_parameters,omitempty"` + // DataCollection is "allow" or "deny" for providers that may train on + // inputs. + DataCollection string `json:"data_collection,omitempty"` + // ZDR restricts routing to zero-data-retention endpoints. + ZDR *bool `json:"zdr,omitempty"` + // EnforceDistillableText restricts routing to endpoints whose model + // author permits distillation. + EnforceDistillableText *bool `json:"enforce_distillable_text,omitempty"` + // Only is an allowlist of provider slugs; Ignore is a blocklist. + Only []string `json:"only,omitempty"` + Ignore []string `json:"ignore,omitempty"` + // Quantizations filters endpoints by weight precision. + Quantizations []string `json:"quantizations,omitempty"` + // Sort orders the candidate endpoints by price, throughput or latency. + // Setting it disables OpenRouter's default load balancing. + Sort *RoutingSort `json:"sort,omitempty"` + // MaxPrice is a hard cap in $/million tokens (or $/request); endpoints + // above it are excluded. + MaxPrice *RoutingMaxPrice `json:"max_price,omitempty"` + // PreferredMinThroughput (tokens/s) and PreferredMaxLatency (seconds) + // are soft preferences: endpoints outside them are deprioritised, not + // excluded. + PreferredMinThroughput *RoutingThreshold `json:"preferred_min_throughput,omitempty"` + PreferredMaxLatency *RoutingThreshold `json:"preferred_max_latency,omitempty"` +} + +// RoutingSort is the `sort` field, which OpenRouter accepts either as a bare +// strategy string or as `{"by": ..., "partition": ...}`. It marshals back to +// whichever form the config used so the wire matches the docs example. +type RoutingSort struct { + By string `json:"by"` + Partition string `json:"partition,omitempty"` +} + +// RoutingMaxPrice is the `max_price` object. +type RoutingMaxPrice struct { + Prompt *float64 `json:"prompt,omitempty"` + Completion *float64 `json:"completion,omitempty"` + Image *float64 `json:"image,omitempty"` + Audio *float64 `json:"audio,omitempty"` + Request *float64 `json:"request,omitempty"` +} + +// RoutingThreshold is a performance preference, accepted either as a single +// number or as per-percentile values. +type RoutingThreshold struct { + Value *float64 `json:"-"` + P50 *float64 `json:"p50,omitempty"` + P75 *float64 `json:"p75,omitempty"` + P90 *float64 `json:"p90,omitempty"` + P99 *float64 `json:"p99,omitempty"` +} + +var ( + routingSortStrategies = []string{"price", "throughput", "latency"} + routingSortPartitions = []string{"model", "none"} + routingDataCollection = []string{"allow", "deny"} + routingQuantizations = []string{ + "int4", "int8", "fp4", "mxfp4", "nvfp4", "fp6", "fp8", "mxfp8", + "fp16", "bf16", "fp32", "unknown", + } +) + +// ParseProviderRouting decodes a config value strictly: unknown keys, wrong +// shapes and out-of-range enums are errors. Empty input and `null` mean +// "nothing configured" and return nil. +func ParseProviderRouting(raw []byte) (*ProviderRouting, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + var routing ProviderRouting + if err := decodeStrict(trimmed, &routing); err != nil { + return nil, fmt.Errorf("provider routing: %w", err) + } + if err := routing.Validate(); err != nil { + return nil, fmt.Errorf("provider routing: %w", err) + } + return &routing, nil +} + +func decodeStrict(raw []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if decoder.More() { + return fmt.Errorf("trailing data after value") + } + return nil +} + +// Validate checks enums and value ranges without touching the wire shape. +func (r *ProviderRouting) Validate() error { + if r == nil { + return nil + } + if r.DataCollection != "" && !slices.Contains(routingDataCollection, r.DataCollection) { + return fmt.Errorf("data_collection must be one of %s, got %q", + strings.Join(routingDataCollection, "|"), r.DataCollection) + } + for _, q := range r.Quantizations { + if !slices.Contains(routingQuantizations, q) { + return fmt.Errorf("quantizations: unknown level %q (want one of %s)", + q, strings.Join(routingQuantizations, "|")) + } + } + for name, values := range map[string][]string{"order": r.Order, "only": r.Only, "ignore": r.Ignore} { + for _, slug := range values { + if strings.TrimSpace(slug) == "" { + return fmt.Errorf("%s: provider slugs must be non-empty strings", name) + } + } + } + if r.Sort != nil { + if !slices.Contains(routingSortStrategies, r.Sort.By) { + return fmt.Errorf("sort must be one of %s, got %q", + strings.Join(routingSortStrategies, "|"), r.Sort.By) + } + if r.Sort.Partition != "" && !slices.Contains(routingSortPartitions, r.Sort.Partition) { + return fmt.Errorf("sort.partition must be one of %s, got %q", + strings.Join(routingSortPartitions, "|"), r.Sort.Partition) + } + } + if r.MaxPrice != nil { + for name, value := range map[string]*float64{ + "prompt": r.MaxPrice.Prompt, "completion": r.MaxPrice.Completion, + "image": r.MaxPrice.Image, "audio": r.MaxPrice.Audio, "request": r.MaxPrice.Request, + } { + if value != nil && *value < 0 { + return fmt.Errorf("max_price.%s must not be negative", name) + } + } + } + for name, threshold := range map[string]*RoutingThreshold{ + "preferred_min_throughput": r.PreferredMinThroughput, + "preferred_max_latency": r.PreferredMaxLatency, + } { + if err := threshold.validate(name); err != nil { + return err + } + } + return nil +} + +func (t *RoutingThreshold) validate(name string) error { + if t == nil { + return nil + } + if t.Value == nil && t.P50 == nil && t.P75 == nil && t.P90 == nil && t.P99 == nil { + return fmt.Errorf("%s must be a number or an object with at least one of p50/p75/p90/p99", name) + } + for _, value := range []*float64{t.Value, t.P50, t.P75, t.P90, t.P99} { + if value != nil && *value < 0 { + return fmt.Errorf("%s must not be negative", name) + } + } + return nil +} + +// IsZero reports whether nothing is configured, in which case no `provider` +// key is sent. +func (r *ProviderRouting) IsZero() bool { + return r == nil || (len(r.Order) == 0 && r.AllowFallbacks == nil && r.RequireParameters == nil && + r.DataCollection == "" && r.ZDR == nil && r.EnforceDistillableText == nil && + len(r.Only) == 0 && len(r.Ignore) == 0 && len(r.Quantizations) == 0 && + r.Sort == nil && r.MaxPrice == nil && + r.PreferredMinThroughput == nil && r.PreferredMaxLatency == nil) +} + +// Merge returns a copy of r with every field that override sets replacing +// r's value. Lists replace wholesale rather than concatenating, so a +// narrower level (model, then agent) can drop a provider the broader level +// allowed. Nested objects (sort, max_price, the thresholds) also replace +// wholesale: they are single settings, not bags. +func (r *ProviderRouting) Merge(override *ProviderRouting) *ProviderRouting { + if r == nil && override == nil { + return nil + } + out := ProviderRouting{} + if r != nil { + out = *r + } + if override == nil { + return &out + } + if len(override.Order) > 0 { + out.Order = slices.Clone(override.Order) + } + if override.AllowFallbacks != nil { + out.AllowFallbacks = override.AllowFallbacks + } + if override.RequireParameters != nil { + out.RequireParameters = override.RequireParameters + } + if override.DataCollection != "" { + out.DataCollection = override.DataCollection + } + if override.ZDR != nil { + out.ZDR = override.ZDR + } + if override.EnforceDistillableText != nil { + out.EnforceDistillableText = override.EnforceDistillableText + } + if len(override.Only) > 0 { + out.Only = slices.Clone(override.Only) + } + if len(override.Ignore) > 0 { + out.Ignore = slices.Clone(override.Ignore) + } + if len(override.Quantizations) > 0 { + out.Quantizations = slices.Clone(override.Quantizations) + } + if override.Sort != nil { + out.Sort = override.Sort + } + if override.MaxPrice != nil { + out.MaxPrice = override.MaxPrice + } + if override.PreferredMinThroughput != nil { + out.PreferredMinThroughput = override.PreferredMinThroughput + } + if override.PreferredMaxLatency != nil { + out.PreferredMaxLatency = override.PreferredMaxLatency + } + return &out +} + +// Object renders the routing as the ordered `provider` value for the request +// body, or nil when nothing is configured. Key order is the struct's field +// order, which mirrors the OpenRouter docs. +func (r *ProviderRouting) Object() *Object { + if r.IsZero() { + return nil + } + raw, err := json.Marshal(r) + if err != nil { + return nil + } + object, err := ParseObject(raw) + if err != nil { + return nil + } + return object +} + +// MarshalJSON emits the bare string form when no partition was given. +func (s RoutingSort) MarshalJSON() ([]byte, error) { + if s.Partition == "" { + return json.Marshal(s.By) + } + type plain RoutingSort + return json.Marshal(plain(s)) +} + +// UnmarshalJSON accepts `"throughput"` or `{"by": "throughput", "partition": "none"}`. +func (s *RoutingSort) UnmarshalJSON(raw []byte) error { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) > 0 && trimmed[0] == '"' { + s.Partition = "" + return json.Unmarshal(trimmed, &s.By) + } + type plain RoutingSort + var parsed plain + if err := decodeStrict(trimmed, &parsed); err != nil { + return fmt.Errorf("sort: %w", err) + } + *s = RoutingSort(parsed) + return nil +} + +// MarshalJSON emits the bare number when the config gave one. +func (t RoutingThreshold) MarshalJSON() ([]byte, error) { + if t.Value != nil { + return json.Marshal(*t.Value) + } + type plain RoutingThreshold + return json.Marshal(plain(t)) +} + +// UnmarshalJSON accepts `50` or `{"p50": 100, "p90": 50}`. +func (t *RoutingThreshold) UnmarshalJSON(raw []byte) error { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) > 0 && trimmed[0] != '{' { + var value float64 + if err := json.Unmarshal(trimmed, &value); err != nil { + return fmt.Errorf("threshold must be a number or a percentile object: %w", err) + } + *t = RoutingThreshold{Value: &value} + return nil + } + type plain RoutingThreshold + var parsed plain + if err := decodeStrict(trimmed, &parsed); err != nil { + return fmt.Errorf("threshold: %w", err) + } + *t = RoutingThreshold(parsed) + return nil +} diff --git a/internal/seniordev/engine/orclient/routing_test.go b/internal/seniordev/engine/orclient/routing_test.go new file mode 100644 index 000000000..e373880b0 --- /dev/null +++ b/internal/seniordev/engine/orclient/routing_test.go @@ -0,0 +1,108 @@ +//go:build !windows + +package orclient + +import ( + "strings" + "testing" +) + +func TestParseProviderRoutingAcceptsEveryDocumentedField(t *testing.T) { + routing, err := ParseProviderRouting([]byte(`{ + "order": ["anthropic", "amazon-bedrock"], + "allow_fallbacks": true, + "require_parameters": true, + "data_collection": "deny", + "zdr": false, + "enforce_distillable_text": false, + "only": ["anthropic"], + "ignore": ["gmicloud"], + "quantizations": ["fp8", "bf16"], + "sort": {"by": "throughput", "partition": "none"}, + "max_price": {"prompt": 1, "completion": 2}, + "preferred_min_throughput": {"p50": 100, "p90": 50}, + "preferred_max_latency": 3 + }`)) + if err != nil { + t.Fatal(err) + } + got, _ := routing.Object().MarshalJSON() + want := `{"order":["anthropic","amazon-bedrock"],"allow_fallbacks":true,"require_parameters":true,` + + `"data_collection":"deny","zdr":false,"enforce_distillable_text":false,"only":["anthropic"],` + + `"ignore":["gmicloud"],"quantizations":["fp8","bf16"],"sort":{"by":"throughput","partition":"none"},` + + `"max_price":{"prompt":1,"completion":2},"preferred_min_throughput":{"p50":100,"p90":50},` + + `"preferred_max_latency":3}` + if string(got) != want { + t.Fatalf("wire =\n%s\nwant\n%s", got, want) + } +} + +func TestParseProviderRoutingKeepsBareSortString(t *testing.T) { + routing, err := ParseProviderRouting([]byte(`{"sort": "throughput"}`)) + if err != nil { + t.Fatal(err) + } + got, _ := routing.Object().MarshalJSON() + if string(got) != `{"sort":"throughput"}` { + t.Fatalf("wire = %s", got) + } +} + +func TestParseProviderRoutingRejectsWhatOpenRouterWouldIgnore(t *testing.T) { + // A misspelled or out-of-range rule must fail at config time; a rule that + // parses and does nothing would route on defaults while looking set. + for name, raw := range map[string]string{ + "unknown key": `{"sort": "price", "prefered_max_latency": 3}`, + "bad sort": `{"sort": "fastest"}`, + "bad partition": `{"sort": {"by": "price", "partition": "provider"}}`, + "bad data_collection": `{"data_collection": "never"}`, + "bad quantization": `{"quantizations": ["fp8", "q4_k_m"]}`, + "empty slug": `{"only": [""]}`, + "negative price": `{"max_price": {"prompt": -1}}`, + "empty threshold": `{"preferred_max_latency": {}}`, + "unknown percentile": `{"preferred_max_latency": {"p95": 3}}`, + "threshold wrong type": `{"preferred_min_throughput": "fast"}`, + "not an object": `["sort"]`, + } { + if _, err := ParseProviderRouting([]byte(raw)); err == nil { + t.Errorf("%s: %s parsed without error", name, raw) + } + } +} + +func TestParseProviderRoutingEmptyMeansOff(t *testing.T) { + for _, raw := range []string{``, `null`, `{}`} { + routing, err := ParseProviderRouting([]byte(raw)) + if err != nil { + t.Fatalf("%q: %v", raw, err) + } + if !routing.IsZero() || routing.Object() != nil { + t.Fatalf("%q: routing=%+v object=%v, want nothing", raw, routing, routing.Object()) + } + } +} + +func TestProviderRoutingMergeLaterLevelWins(t *testing.T) { + base, _ := ParseProviderRouting([]byte(`{ + "sort": "throughput", "require_parameters": true, + "ignore": ["a", "b"], "max_price": {"prompt": 1, "completion": 2} + }`)) + override, _ := ParseProviderRouting([]byte(`{ + "sort": {"by": "price"}, "ignore": ["c"], "max_price": {"completion": 5}, "zdr": true + }`)) + got, _ := base.Merge(override).Object().MarshalJSON() + // Lists and nested objects replace wholesale; untouched scalars survive. + want := `{"require_parameters":true,"zdr":true,"ignore":["c"],"sort":"price","max_price":{"completion":5}}` + if string(got) != want { + t.Fatalf("merged = %s\nwant %s", got, want) + } + if unchanged, _ := base.Object().MarshalJSON(); !strings.Contains(string(unchanged), `"ignore":["a","b"]`) { + t.Fatalf("Merge mutated its receiver: %s", unchanged) + } + if base.Merge(nil).IsZero() || (*ProviderRouting)(nil).Merge(override).IsZero() { + t.Fatal("merging with nil lost the configured side") + } + if (*ProviderRouting)(nil).Merge(nil) != nil { + t.Fatal("nil merged with nil must stay nil") + } +} diff --git a/internal/seniordev/engine/orclient/sampling_body_test.go b/internal/seniordev/engine/orclient/sampling_body_test.go new file mode 100644 index 000000000..eba4c9f22 --- /dev/null +++ b/internal/seniordev/engine/orclient/sampling_body_test.go @@ -0,0 +1,56 @@ +//go:build !windows + +package orclient + +import ( + "encoding/json" + "testing" +) + +func pointer(value float64) *float64 { return &value } + +// TestBuildRequestBodyWritesOnlyTheSamplingFieldsThatAreSet: a nil field is +// absent from the body, not null, so the provider's default applies; a set +// field is written under its OpenRouter key. `provider` is never written by +// the sampling fields — it belongs to the routing block in OpenRouterOptions. +func TestBuildRequestBodyWritesOnlyTheSamplingFieldsThatAreSet(t *testing.T) { + bare, err := BuildRequestBody(RequestParams{ModelID: "m"}) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(bare, &body); err != nil { + t.Fatal(err) + } + for _, key := range []string{ + "temperature", "top_p", "top_k", "min_p", "seed", + "frequency_penalty", "presence_penalty", "repetition_penalty", "provider", + } { + if _, present := body[key]; present { + t.Fatalf("unset %s written: %s", key, bare) + } + } + + full, err := BuildRequestBody(RequestParams{ + ModelID: "m", MaxOutputTokens: pointer(4096), + Temperature: pointer(0.2), TopP: pointer(0.9), TopK: pointer(40), + MinP: pointer(0.05), Seed: pointer(7), FrequencyPenalty: pointer(0.1), + PresencePenalty: pointer(0.2), RepetitionPenalty: pointer(1.05), + }) + if err != nil { + t.Fatal(err) + } + body = map[string]any{} + if err := json.Unmarshal(full, &body); err != nil { + t.Fatal(err) + } + if _, present := body["provider"]; present { + t.Fatalf("sampling fields wrote provider: %s", full) + } + if body["max_tokens"] != 4096.0 || body["temperature"] != 0.2 || body["top_p"] != 0.9 || + body["top_k"] != 40.0 || body["min_p"] != 0.05 || body["seed"] != 7.0 || + body["frequency_penalty"] != 0.1 || body["presence_penalty"] != 0.2 || + body["repetition_penalty"] != 1.05 { + t.Fatalf("shaped body = %s", full) + } +} diff --git a/internal/seniordev/engine/orclient/sse.go b/internal/seniordev/engine/orclient/sse.go new file mode 100644 index 000000000..9f809ebc8 --- /dev/null +++ b/internal/seniordev/engine/orclient/sse.go @@ -0,0 +1,161 @@ +//go:build !windows + +package orclient + +// The SSE frame decoder. +// +// Hand-rolled (no third-party dependencies). The rules that matter: +// +// - fields are separated by a newline; an event is dispatched on a BLANK +// line. Both LF and CRLF terminate a line, and a bare CR does too (the SSE +// spec's three line terminators). +// - a line beginning with `:` is a comment. OpenRouter sends +// `: OPENROUTER PROCESSING` keepalives every few seconds; they must not +// produce an event and must not disturb the data buffer. +// - `data:` takes the rest of the line with ONE optional leading space +// stripped. Multiple `data:` lines in one frame are joined with `\n`. +// - an event with an EMPTY data buffer is not dispatched at all. +// - `data: [DONE]` is dropped by the caller, not by the frame decoder, so +// it terminates nothing on its own; the stream ends when the body does. +// - a trailing frame with no terminating blank line IS dispatched at EOF. + +import ( + "bufio" + "io" + "strings" +) + +// SSEEvent is one dispatched event. Only `data` is consumed downstream; `event` +// and `id` are decoded because the grammar requires skipping them correctly. +type SSEEvent struct { + Event string + Data string + ID string +} + +// SSEDecoder splits a byte stream into events. +type SSEDecoder struct { + r *bufio.Reader + + data strings.Builder + hasData bool + event string + lastID string + done bool + started bool +} + +// NewSSEDecoder wraps a reader. The buffer is generous because a single +// reasoning-heavy chunk can exceed the default 4 KiB line limit by a lot. +func NewSSEDecoder(r io.Reader) *SSEDecoder { + return &SSEDecoder{r: bufio.NewReaderSize(r, 64*1024)} +} + +// Next returns the next event, or io.EOF when the stream ends. +func (d *SSEDecoder) Next() (SSEEvent, error) { + for { + if d.done { + return SSEEvent{}, io.EOF + } + line, err := d.readLine() + if !d.started { + d.started = true + line = strings.TrimPrefix(line, "\uFEFF") + } + if err != nil { + d.done = true + if err != io.EOF { + return SSEEvent{}, err + } + if len(line) == 0 && !d.hasData { + return SSEEvent{}, io.EOF + } + if ev, ok := d.feed(line); ok { + return ev, nil + } + if ev, ok := d.dispatch(); ok { + return ev, nil + } + return SSEEvent{}, io.EOF + } + if ev, ok := d.feed(line); ok { + return ev, nil + } + } +} + +// feed consumes one line, returning an event when the line dispatches one. +func (d *SSEDecoder) feed(line string) (SSEEvent, bool) { + if line == "" { + return d.dispatch() + } + if strings.HasPrefix(line, ":") { + // Comment / keepalive. + return SSEEvent{}, false + } + field, value := line, "" + if colon := strings.IndexByte(line, ':'); colon >= 0 { + field = line[:colon] + value = line[colon+1:] + // Exactly ONE leading space is stripped. + if strings.HasPrefix(value, " ") { + value = value[1:] + } + } + switch field { + case "data": + if d.hasData { + d.data.WriteByte('\n') + } + d.data.WriteString(value) + d.hasData = true + case "event": + d.event = value + case "id": + // The spec ignores an id containing NUL; nothing downstream reads it. + if !strings.ContainsRune(value, 0) { + d.lastID = value + } + case "retry": + // Reconnection time; this client never reconnects. + } + return SSEEvent{}, false +} + +func (d *SSEDecoder) dispatch() (SSEEvent, bool) { + if !d.hasData { + d.event = "" + return SSEEvent{}, false + } + ev := SSEEvent{Event: d.event, Data: d.data.String(), ID: d.lastID} + d.data.Reset() + d.hasData = false + d.event = "" + return ev, true +} + +// readLine reads one line, accepting LF, CRLF and a bare CR as terminators. +func (d *SSEDecoder) readLine() (string, error) { + var sb strings.Builder + for { + b, err := d.r.ReadByte() + if err != nil { + return sb.String(), err + } + switch b { + case '\n': + return sb.String(), nil + case '\r': + next, err := d.r.ReadByte() + if err == nil && next != '\n' { + _ = d.r.UnreadByte() + } + return sb.String(), nil + default: + sb.WriteByte(b) + } + } +} + +// DoneSentinel is the payload the stream reader drops before parsing. +const DoneSentinel = "[DONE]" diff --git a/internal/seniordev/engine/orclient/stream.go b/internal/seniordev/engine/orclient/stream.go new file mode 100644 index 000000000..487b5e7d3 --- /dev/null +++ b/internal/seniordev/engine/orclient/stream.go @@ -0,0 +1,601 @@ +//go:build !windows + +package orclient + +// The chunk → stream-part translation: one Transform per decoded SSE chunk +// plus a Flush at end of stream. The order of checks is part of the +// contract: finish_reason is captured before the delta of the same chunk; a +// parse failure or a top-level error payload returns without looking at +// anything else; reasoning-end fires before text-start. + +import ( + "encoding/json" + "strconv" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" +) + +// toolCallSlot is one entry of the provider's `toolCalls` array. +type toolCallSlot struct { + id string + name string + arguments string + inputStarted bool + sent bool +} + +// toolCallArray is the accumulator for tool-call deltas, keyed by the delta's +// index. A non-negative integer index extends `length`; any other key is +// stored but never iterated. +type toolCallArray struct { + slots map[string]*toolCallSlot + length int +} + +func newToolCallArray() *toolCallArray { + return &toolCallArray{slots: map[string]*toolCallSlot{}} +} + +func (a *toolCallArray) get(i int) *toolCallSlot { + return a.getKey(strconv.Itoa(i)) +} + +func (a *toolCallArray) getKey(key string) *toolCallSlot { return a.slots[key] } + +// setKey stores a slot under the delta's index key. Only a non-negative +// integer key extends `length`; any other key (the wire schema accepts any +// number for toolCallDelta.index) is stored but never iterated. +func (a *toolCallArray) setKey(key string, slot *toolCallSlot) { + a.slots[key] = slot + if index, ok := arrayIndex(key); ok && index+1 > a.length { + a.length = index + 1 + } +} + +// arrayIndex reports whether key is a plain non-negative integer. +func arrayIndex(key string) (int, bool) { + if key == "" || (len(key) > 1 && key[0] == '0') { + return 0, false + } + n, err := strconv.ParseUint(key, 10, 31) + if err != nil { + return 0, false + } + return int(n), true +} + +// iterate walks indices 0..length-1. Holes yield nil; callers skip them. +func (a *toolCallArray) iterate(f func(*toolCallSlot)) { + for i := 0; i < a.length; i++ { + f(a.get(i)) + } +} + +// Translator is the per-stream state the chunk translation mutates. +type Translator struct { + toolCalls *toolCallArray + seenToolCallIDs map[string]bool + finishReason FinishReason + + usage calc.LanguageModelV3Usage + openrouterUse *Object + rawUsage json.RawMessage + accumulated []ReasoningDetail + detailsOnCall bool + fileAnnotation []json.RawMessage + + promptTokensSeen *float64 + completionTokensSeen *float64 + + textStarted bool + reasoningStarted bool + textID string + reasoningID string + responseID string + provider *string + + // streamError is a captured mid-stream read error. It surfaces as an + // `error` part at flush. + streamError json.RawMessage + flushed bool +} + +// NewTranslator builds the initial state. finishReason starts as `other`. +func NewTranslator() *Translator { + return &Translator{ + toolCalls: newToolCallArray(), + seenToolCallIDs: map[string]bool{}, + openrouterUse: NewObject(), + finishReason: FinishReason{Unified: FinishOther}, + accumulated: []ReasoningDetail{}, + } +} + +// SetStreamError records a mid-stream reader error. The stream CLOSES on it, +// so the error only shows up in flush. +func (t *Translator) SetStreamError(value json.RawMessage) { t.streamError = value } + +// Transform translates one decoded chunk. A returned error (only +// InvalidResponseDataError from the tool-call accumulator) tears the stream +// down; it is not an `error` part. +func (t *Translator) Transform(chunk Chunk) ([]StreamPart, error) { + var out []StreamPart + emit := func(p StreamPart) { out = append(out, p) } + + if !chunk.Success { + t.finishReason = FinishReason{Unified: FinishError} + emit(ErrorPart{Error: chunk.ParseError}) + return out, nil + } + value := chunk.Value + if value.ErrorField != nil { + t.finishReason = FinishReason{Unified: FinishError} + emit(ErrorPart{Error: value.ErrorField}) + return out, nil + } + if value.Provider != nil && *value.Provider != "" { + p := *value.Provider + t.provider = &p + } + if value.ID != nil && *value.ID != "" { + t.responseID = *value.ID + emit(ResponseMetadataPart{ID: *value.ID}) + } + if value.Model != nil && *value.Model != "" { + emit(ResponseMetadataPart{ModelID: *value.Model, IsModel: true}) + } + if value.Usage != nil { + t.accumulateUsage(value.Usage) + } + + // Only the FIRST choice is ever read. + var choice *Choice + if len(value.Choices) > 0 { + choice = &value.Choices[0] + } + if choice != nil && choice.FinishReason != nil { + t.finishReason = MapOpenRouterFinishReason(choice.FinishReason) + } + if choice == nil || choice.Delta == nil { + return out, nil + } + delta := choice.Delta + + emitReasoningChunk := func(text string) { + if !t.reasoningStarted { + t.reasoningID = generateId() + emit(ReasoningStartPart{ID: t.reasoningID}) + t.reasoningStarted = true + } + id := t.reasoningID + if id == "" { + id = generateId() + } + emit(ReasoningDeltaPart{Delta: text, ID: id}) + } + + if delta.HasReasoningDeta && len(delta.ReasoningDetails) > 0 { + for _, detail := range delta.ReasoningDetails { + if detail.Type == ReasoningDetailText { + if n := len(t.accumulated); n > 0 && t.accumulated[n-1].Type == ReasoningDetailText { + last := &t.accumulated[n-1] + // A null text on either side contributes "", and the + // result is always a string, so a merge turns an ABSENT + // text into "". + last.Text = rawStringLiteral(rawString(last.Text) + rawString(detail.Text)) + last.Signature = orTruthy(last.Signature, detail.Signature) + last.Format = orTruthy(last.Format, detail.Format) + last.Raw = nil + continue + } + // Appended by value, so later merges do not mutate the + // caller's entry. + t.accumulated = append(t.accumulated, detail) + continue + } + t.accumulated = append(t.accumulated, detail) + } + if !t.textStarted { + for _, detail := range delta.ReasoningDetails { + switch detail.Type { + case ReasoningDetailText: + emitReasoningChunk(rawString(detail.Text)) + case ReasoningDetailEncrypted: + // Emits NOTHING — it only accumulates. + case ReasoningDetailSummary: + if detail.Summary != "" { + emitReasoningChunk(detail.Summary) + } + } + } + } + } else if delta.Reasoning != nil && *delta.Reasoning != "" && !t.textStarted { + emitReasoningChunk(*delta.Reasoning) + } + + // An empty content string produces nothing at all. + if delta.Content != nil && *delta.Content != "" { + if t.reasoningStarted && !t.textStarted { + id := t.reasoningID + if id == "" { + id = generateId() + } + emit(ReasoningEndPart{ID: id, Details: t.detailsView()}) + t.reasoningStarted = false + } + if !t.textStarted { + t.textID = t.responseID + if t.textID == "" { + t.textID = generateId() + } + emit(TextStartPart{ID: t.textID}) + t.textStarted = true + } + id := t.textID + if id == "" { + id = generateId() + } + emit(TextDeltaPart{Delta: *delta.Content, ID: id}) + } + + if delta.HasAnnotations { + for _, annotation := range delta.Annotations { + switch annotation.Type { + case "url_citation": + part := SourcePart{URL: annotation.URL} + if annotation.Title != nil { + part.Title = *annotation.Title + } + if annotation.Content != nil { + part.Content = *annotation.Content + } + if annotation.StartIndex != nil { + part.StartIndex = *annotation.StartIndex + } + if annotation.EndIndex != nil { + part.EndIndex = *annotation.EndIndex + } + emit(part) + case "file": + t.fileAnnotation = append(t.fileAnnotation, annotation.Raw) + } + // `file_annotation` (the old format) is parsed and then IGNORED. + } + } + + if delta.HasToolCalls { + parts, err := t.accumulateToolCalls(delta.ToolCalls) + out = append(out, parts...) + if err != nil { + return out, err + } + } + + for _, image := range delta.Images { + emit(FilePart{ + MediaType: getMediaType(image.URL, "image/jpeg"), + Data: base64FromDataURLLoose(image.URL), + }) + } + return out, nil +} + +// accumulateToolCalls folds tool-call deltas into slots and emits the +// tool-input and tool-call parts. +func (t *Translator) accumulateToolCalls(deltas []ToolCallDelta) ([]StreamPart, error) { + var out []StreamPart + emit := func(p StreamPart) { out = append(out, p) } + + for _, d := range deltas { + // A delta without an index continues the most recent tool call; the + // first such delta opens slot 0. + index := strconv.Itoa(max(t.toolCalls.length-1, 0)) + if d.Index != nil { + index = strconv.FormatFloat(*d.Index, 'f', -1, 64) + } + + if t.toolCalls.getKey(index) == nil { + if d.Type == nil || *d.Type != "function" { + return out, newInvalidResponseDataError("Expected 'function' type.", json.RawMessage(d.Raw)) + } + if d.Name == nil { + return out, newInvalidResponseDataError("Expected 'function.name' to be a string.", json.RawMessage(d.Raw)) + } + toolCallID := "" + if d.ID != nil { + toolCallID = *d.ID + } + // Id uniqueness is enforced by the CLIENT, not the server. + if toolCallID == "" || t.seenToolCallIDs[toolCallID] { + toolCallID = generateId() + } + t.seenToolCallIDs[toolCallID] = true + + arguments := "" + if d.Arguments != nil { + arguments = *d.Arguments + } + slot := &toolCallSlot{id: toolCallID, name: *d.Name, arguments: arguments} + t.toolCalls.setKey(index, slot) + + // The whole burst fires immediately when the FIRST delta already + // carries parsable arguments. + if isParsableJSON(slot.arguments) { + slot.inputStarted = true + emit(ToolInputStartPart{ID: slot.id, ToolName: slot.name}) + emit(ToolInputDeltaPart{ID: slot.id, Delta: slot.arguments}) + emit(ToolInputEndPart{ID: slot.id}) + emit(t.toolCallPart(slot)) + slot.sent = true + } + continue + } + + slot := t.toolCalls.getKey(index) + if !slot.inputStarted { + slot.inputStarted = true + emit(ToolInputStartPart{ID: slot.id, ToolName: slot.name}) + if slot.arguments != "" { + emit(ToolInputDeltaPart{ID: slot.id, Delta: slot.arguments}) + } + } + if d.Arguments != nil { + slot.arguments += *d.Arguments + } + // Emitted on EVERY subsequent delta, even for null args. + deltaText := "" + if d.Arguments != nil { + deltaText = *d.Arguments + } + emit(ToolInputDeltaPart{ID: slot.id, Delta: deltaText}) + + if isParsableJSON(slot.arguments) && !slot.sent { + emit(ToolInputEndPart{ID: slot.id}) + emit(t.toolCallPart(slot)) + slot.sent = true + } + } + return out, nil +} + +// toolCallPart builds a `tool-call` and consumes the one-shot +// reasoning-details attachment. +func (t *Translator) toolCallPart(slot *toolCallSlot) ToolCallPart { + part := ToolCallPart{ + ToolCallID: slot.id, + ToolName: slot.name, + Input: slot.arguments, + } + if !t.detailsOnCall { + part.HasProviderMetadata = true + part.Details = t.detailsView() + } + t.detailsOnCall = true + return part +} + +// detailsView hands out a LIVE reference to the accumulator, never a copy: +// every part that carries `reasoning_details` shares the same array, so a +// `reasoning-end` emitted at chunk 2 shows entries that arrived at chunk 3, +// and a merged `reasoning.text` entry shows its FINAL concatenated text +// everywhere it appears. A per-emit copy would lose both on the first stream +// that interleaves reasoning with text. +func (t *Translator) detailsView() ReasoningDetailsView { + return detailsRef(&t.accumulated) +} + +func (t *Translator) accumulateUsage(usage *calc.OpenRouterUsage) { + computed := calc.ComputeTokenUsage(usage) + // ComputeTokenUsage always writes every input and output key, so the + // whole block is replaced on every usage chunk. + t.usage.InputTokens = computed.InputTokens + t.usage.OutputTokens = computed.OutputTokens + t.rawUsage = computed.Raw + + promptTokens := float64(0) + if usage.PromptTokens != nil { + promptTokens = *usage.PromptTokens + } + completionTokens := float64(0) + if usage.CompletionTokens != nil { + completionTokens = *usage.CompletionTokens + } + t.openrouterUse.SetNumber("promptTokens", promptTokens) + if usage.PromptTokensDetails != nil { + cached := float64(0) + if usage.PromptTokensDetails.CachedTokens != nil { + cached = *usage.PromptTokensDetails.CachedTokens + } + inner := NewObject() + inner.SetNumber("cachedTokens", cached) + t.openrouterUse.SetObject("promptTokensDetails", inner) + } + t.openrouterUse.SetNumber("completionTokens", completionTokens) + if usage.CompletionTokensDetails != nil { + reasoning := float64(0) + if usage.CompletionTokensDetails.ReasoningTokens != nil { + reasoning = *usage.CompletionTokensDetails.ReasoningTokens + } + inner := NewObject() + inner.SetNumber("reasoningTokens", reasoning) + t.openrouterUse.SetObject("completionTokensDetails", inner) + } + extra := usageExtras(usage.Raw) + if extra.cost != nil { + t.openrouterUse.SetNumber("cost", *extra.cost) + } + // total_tokens is required by the usage schema, so it is always present. + if extra.totalTokens != nil { + t.openrouterUse.SetNumber("totalTokens", *extra.totalTokens) + } + if extra.upstreamInferenceCost != nil { + inner := NewObject() + inner.SetNumber("upstreamInferenceCost", *extra.upstreamInferenceCost) + t.openrouterUse.SetObject("costDetails", inner) + } + t.promptTokensSeen = &promptTokens + t.completionTokensSeen = &completionTokens +} + +type usageExtraFields struct { + cost *float64 + totalTokens *float64 + upstreamInferenceCost *float64 +} + +// usageExtras reads the three fields calc.OpenRouterUsage does not project: +// `cost`, `total_tokens` and `cost_details.upstream_inference_cost`. They only +// feed the openrouter usage metadata, which senior-dev does not read; they are +// carried so the metadata reflects the wire. +func usageExtras(raw json.RawMessage) usageExtraFields { + var out usageExtraFields + if len(raw) == 0 { + return out + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return out + } + if v, ok := obj["cost"]; ok && !isJSONNull(v) { + var n float64 + if err := json.Unmarshal(v, &n); err == nil { + out.cost = &n + } + } + if v, ok := obj["total_tokens"]; ok && !isJSONNull(v) { + var n float64 + if err := json.Unmarshal(v, &n); err == nil { + out.totalTokens = &n + } + } + if v, ok := obj["cost_details"]; ok && !isJSONNull(v) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(v, &fields); err == nil { + if u, ok := fields["upstream_inference_cost"]; ok && !isJSONNull(u) { + var n float64 + if err := json.Unmarshal(u, &n); err == nil { + out.upstreamInferenceCost = &n + } + } + } + } + return out +} + +// Flush ends the stream: it flushes unsent tool calls, closes open reasoning +// and text, and emits the finish part. +func (t *Translator) Flush() []StreamPart { + if t.flushed { + return nil + } + t.flushed = true + + var out []StreamPart + emit := func(p StreamPart) { out = append(out, p) } + + hasToolCalls := t.toolCalls.length > 0 + if t.streamError != nil { + t.finishReason = FinishReason{Unified: FinishError} + emit(ErrorPart{Error: t.streamError}) + } + + hasEncryptedReasoning := false + for _, d := range t.accumulated { + if d.Type == ReasoningDetailEncrypted && d.Data != "" { + hasEncryptedReasoning = true + break + } + } + // The two synthetic promotions to tool-calls. `raw` is preserved. + if hasToolCalls && hasEncryptedReasoning && t.finishReason.Unified == FinishStop { + t.finishReason = FinishReason{Unified: FinishToolCalls, Raw: t.finishReason.Raw} + } + if hasToolCalls && t.finishReason.Unified == FinishOther { + t.finishReason = FinishReason{Unified: FinishToolCalls, Raw: t.finishReason.Raw} + } + + // Unsent tool calls are flushed ONLY when the final unified reason is + // `tool-calls` — which is exactly what the promotions above buy. + if t.finishReason.Unified == FinishToolCalls { + t.toolCalls.iterate(func(slot *toolCallSlot) { + if slot == nil || slot.sent { + return + } + input := slot.arguments + if !isParsableJSON(input) { + input = "{}" + } + if !slot.inputStarted { + emit(ToolInputStartPart{ID: slot.id, ToolName: slot.name}) + emit(ToolInputDeltaPart{ID: slot.id, Delta: input}) + } + emit(ToolInputEndPart{ID: slot.id}) + part := ToolCallPart{ToolCallID: slot.id, ToolName: slot.name, Input: input} + if !t.detailsOnCall { + part.HasProviderMetadata = true + part.Details = t.detailsView() + } + t.detailsOnCall = true + emit(part) + slot.sent = true + }) + } + + if t.reasoningStarted { + id := t.reasoningID + if id == "" { + id = generateId() + } + emit(ReasoningEndPart{ID: id, Details: t.detailsView()}) + } + if t.textStarted { + id := t.textID + if id == "" { + id = generateId() + } + emit(TextEndPart{ID: id}) + } + + metadata := OpenRouterMetadata{ + Usage: t.openrouterUse, + Provider: t.provider, + ReasoningDetails: t.detailsView(), + } + if len(t.fileAnnotation) > 0 { + metadata.Annotations = t.fileAnnotation + } + + // Late fallbacks for a usage block that never reported totals. + usage := t.usage + if usage.InputTokens.Total == nil && t.promptTokensSeen != nil { + usage.InputTokens.Total = t.promptTokensSeen + } + if usage.OutputTokens.Total == nil && t.completionTokensSeen != nil { + usage.OutputTokens.Total = t.completionTokensSeen + } + usage.Raw = t.rawUsage + + emit(FinishPart{FinishReason: t.finishReason, Usage: usage, Metadata: metadata}) + return out +} + +// FinishReasonSnapshot exposes the running finish reason, so a caller can tell +// before flush whether a still-unsent tool call will ever arrive. +func (t *Translator) FinishReasonSnapshot() FinishReason { return t.finishReason } + +// isParsableJSON reports whether the accumulated arguments parse as JSON. It +// uses the same secure parser as tool validation, which also rejects +// `__proto__` / `constructor.prototype` keys. +func isParsableJSON(input string) bool { + if input == "" { + return false + } + _, err := parseSecureJSONValue(input) + return err == nil +} + +// base64FromDataURLLoose extracts the base64 payload of a data URL, returning +// the input unchanged when it is not one. +func base64FromDataURLLoose(dataURL string) string { + return base64FromDataURL(dataURL) +} diff --git a/internal/seniordev/engine/orclient/toolcall.go b/internal/seniordev/engine/orclient/toolcall.go new file mode 100644 index 000000000..46438944e --- /dev/null +++ b/internal/seniordev/engine/orclient/toolcall.go @@ -0,0 +1,440 @@ +//go:build !windows + +package orclient + +// Tool-call validation, repair, and the `invalid` tool. +// +// A three-stage fallback: +// +// 1. VALIDATE. An unknown tool name raises NoSuchToolError; otherwise an +// EMPTY input string validates as `{}` (it is not an error) and anything +// else is parsed and validated. A validation failure raises +// InvalidToolInputError. +// 2. REPAIR. The repair callback runs for THOSE TWO ERROR TYPES ONLY. +// senior-dev's implementation lowercases a mis-cased tool name when a +// lowercase tool exists, and otherwise rewrites the call to +// `toolName:"invalid"` with `input: {tool, error}` as a JSON string. A +// repaired call is RE-VALIDATED (a second failure is NOT re-repaired); a +// nil result keeps the ORIGINAL error; a returned error is wrapped in +// ToolCallRepairError. +// 3. SAFETY NET. If repair is absent, returns nil, or the repaired call +// still fails, ParseToolCall DOES NOT return an error. It returns a +// synthetic `{type:"tool-call", …, dynamic:true, invalid:true, error}`, +// which the step loop persists as a tool part and immediately fails, +// WITHOUT executing the tool. +// +// Stage 3 is what keeps the loop's exit condition working: the assistant +// message still ends up with a tool part, so the turn counts as a tool-call +// turn and the loop iterates, giving the model a chance to correct itself. +// +// ActiveTools excludes "invalid" so the model can never CHOOSE it, but the +// tool map passed to the parser includes it so repair can TARGET it. +// +// ── the validator seam ─────────────────────────────────────────────────── +// +// ToolSpec.Validate is optional: nil means "accept anything that parses"; a +// tool with a schema installs a real validator. + +import ( + "encoding/json" + "errors" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// InvalidToolName is the tool repair rewrites an unrepairable call to. +const InvalidToolName = "invalid" + +// ToolSpec is one registered tool as the parser sees it. +type ToolSpec struct { + Name string + // Validate checks a parsed input against the tool's schema. Nil accepts + // any value that parsed as JSON. + Validate func(input json.RawMessage) error +} + +// ToolMap is the registered tool set in a fixed order. The order reaches the +// request body and the `invalid` tool's availableTools list, so it is part +// of what keeps prompt-cache keys stable. +type ToolMap struct { + order []string + specs map[string]ToolSpec +} + +// NewToolMap builds a map in the given order. +func NewToolMap(specs ...ToolSpec) *ToolMap { + m := &ToolMap{specs: map[string]ToolSpec{}} + for _, s := range specs { + if _, ok := m.specs[s.Name]; !ok { + m.order = append(m.order, s.Name) + } + m.specs[s.Name] = s + } + return m +} + +// SortedToolMap builds a ToolMap sorted by tool name. +func SortedToolMap(specs ...ToolSpec) *ToolMap { + m := NewToolMap(specs...) + sort.SliceStable(m.order, func(i, j int) bool { + return m.order[i] < m.order[j] + }) + return m +} + +// Names lists the tools in map order. +func (m *ToolMap) Names() []string { + if m == nil { + return nil + } + return append([]string(nil), m.order...) +} + +// ActiveTools is Names without the invalid tool: the set the model is offered. +func (m *ToolMap) ActiveTools() []string { + out := make([]string, 0, len(m.order)) + for _, name := range m.Names() { + if name == InvalidToolName { + continue + } + out = append(out, name) + } + return out +} + +// Get looks up a tool. +func (m *ToolMap) Get(name string) (ToolSpec, bool) { + if m == nil { + return ToolSpec{}, false + } + spec, ok := m.specs[name] + return spec, ok +} + +// ── errors ──────────────────────────────────────────────────────────────── + +// NoSuchToolError reports a call to an unregistered tool. The message text +// matters: the repair callback puts it into the `invalid` tool's input and the +// model reads it. +type NoSuchToolError struct { + ToolName string + AvailableTools []string + Message string +} + +func (e *NoSuchToolError) Error() string { return e.Message } + +func newNoSuchToolError(toolName string, available []string) *NoSuchToolError { + msg := "Model tried to call unavailable tool '" + toolName + "'. " + if len(available) == 0 { + msg += "No tools are available." + } else { + msg += "Available tools: " + strings.Join(available, ", ") + "." + } + return &NoSuchToolError{ToolName: toolName, AvailableTools: available, Message: msg} +} + +// InvalidToolInputError reports an input that failed to parse or validate. +type InvalidToolInputError struct { + ToolName string + ToolInput string + Cause error + Message string +} + +func (e *InvalidToolInputError) Error() string { return e.Message } + +func newInvalidToolInputError(toolName, toolInput string, cause error) *InvalidToolInputError { + msg := "Invalid input for tool " + toolName + ": " + if cause != nil { + msg += cause.Error() + } + return &InvalidToolInputError{ToolName: toolName, ToolInput: toolInput, Cause: cause, Message: msg} +} + +// TypeValidationError is what a Validate hook's rejection is wrapped in. Its +// message reaches the model verbatim through the `invalid` tool's input, in +// the format +// +// Type validation failed: Value: .\nError message: +// +// ToolSpec.Validate implementations should return one of these. +type TypeValidationError struct { + Value json.RawMessage + Cause error + Message string +} + +func (e *TypeValidationError) Error() string { return e.Message } + +// NewTypeValidationError builds one in the format above. +func NewTypeValidationError(value json.RawMessage, cause error) *TypeValidationError { + rendered := "undefined" + if len(value) > 0 { + rendered = string(value) + } + message := "" + if cause != nil { + message = cause.Error() + } + return &TypeValidationError{ + Value: value, + Cause: cause, + Message: "Type validation failed: Value: " + rendered + ".\nError message: " + message, + } +} + +// JSONParseError reports an input that is not JSON. Its message, in the format +// +// JSON parsing failed: Text: .\nError message: +// +// reaches the model verbatim through the `invalid` tool's input. The cause +// text is whatever encoding/json reports. +type JSONParseError struct { + Text string + Cause error + Message string +} + +func (e *JSONParseError) Error() string { return e.Message } + +// NewJSONParseError builds one in the format above. +func NewJSONParseError(text string, cause error) *JSONParseError { + message := "" + if cause != nil { + message = cause.Error() + } + return &JSONParseError{ + Text: text, + Cause: cause, + Message: "JSON parsing failed: Text: " + text + ".\nError message: " + message, + } +} + +// ToolCallRepairError wraps an error returned by the repair callback. +type ToolCallRepairError struct { + Cause error + OriginalError error + Message string +} + +func (e *ToolCallRepairError) Error() string { return e.Message } + +// ── the call shapes ─────────────────────────────────────────────────────── + +// RawToolCall is a tool call as the stream delivered it: `input` is a raw +// JSON STRING, never a parsed value. +type RawToolCall struct { + ToolCallID string + ToolName string + Input string + ProviderExecuted bool + ProviderMetadata json.RawMessage +} + +// ParsedToolCall is ParseToolCall's result. `Invalid` marks the +// synthetic stage-3 result, which is emitted as a `tool-call` part and +// immediately as a `tool-error` part, and is NEVER executed. +type ParsedToolCall struct { + Type string + ToolCallID string + ToolName string + Input json.RawMessage + Dynamic bool + Invalid bool + Error error + ProviderExecuted bool + ProviderMetadata json.RawMessage +} + +// MarshalJSON writes the call with a fixed key order. +func (c ParsedToolCall) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", "tool-call") + w.str("toolCallId", c.ToolCallID) + w.str("toolName", c.ToolName) + w.raw("input", c.Input) + if c.Invalid { + w.raw("dynamic", json.RawMessage("true")) + w.raw("invalid", json.RawMessage("true")) + if c.Error != nil { + w.str("error", c.Error.Error()) + } + } + if c.ProviderExecuted { + w.raw("providerExecuted", json.RawMessage("true")) + } + w.raw("providerMetadata", c.ProviderMetadata) + return w.done() +} + +// RepairFn tries to fix a call that failed validation. Returning (nil, nil) +// keeps the ORIGINAL error. +type RepairFn func(call RawToolCall, tools *ToolMap, failure error) (*RawToolCall, error) + +// ── senior-dev's repair callback ───────────────────────────────────────────── + +// SeniorDevRepairToolCall is senior-dev's RepairFn. +// +// Two steps, in order: +// +// 1. if the LOWERCASED name differs from the emitted one AND a tool with the +// lowercase name exists → return the call with the name lowercased. Note it +// keeps the ORIGINAL input, so a call that failed VALIDATION (not +// name-lookup) and happens to be mis-cased gets re-validated against the +// lowercase tool's schema and can fail a second time — which then lands in +// stage 3 rather than the `invalid` tool. +// 2. otherwise → rewrite to `toolName:"invalid"` with `input: {tool, error}` +// encoded as a raw JSON STRING, which is what RawToolCall.Input holds. +func SeniorDevRepairToolCall(call RawToolCall, tools *ToolMap, failure error) (*RawToolCall, error) { + lower := unicodeLower(call.ToolName) + if lower != call.ToolName { + if _, ok := tools.Get(lower); ok { + repaired := call + repaired.ToolName = lower + return &repaired, nil + } + } + payload := NewObject() + payload.SetString("tool", call.ToolName) + message := "" + if failure != nil { + message = failure.Error() + } + payload.SetString("error", message) + encoded, err := payload.MarshalJSON() + if err != nil { + return nil, err + } + repaired := call + repaired.Input = string(encoded) + repaired.ToolName = InvalidToolName + return &repaired, nil +} + +// ── parseToolCall ───────────────────────────────────────────────────────── + +// ParseToolCall validates and, if needed, repairs one call. It never returns +// an error: every failure path collapses into the synthetic invalid call, +// which is the whole point of stage 3. +func ParseToolCall(call RawToolCall, tools *ToolMap, repair RepairFn) ParsedToolCall { + parsed, err := doParseToolCall(call, tools) + if err == nil { + return parsed + } + + if repair != nil && isRepairable(err) { + repaired, repairErr := repair(call, tools, err) + if repairErr != nil { + err = &ToolCallRepairError{ + Cause: repairErr, + OriginalError: err, + Message: "Error repairing tool call: " + repairErr.Error(), + } + } else if repaired != nil { + // A second failure is NOT re-repaired. + parsed, secondErr := doParseToolCall(*repaired, tools) + if secondErr == nil { + return parsed + } + err = secondErr + } + // A nil repair result keeps the ORIGINAL error, which is already in + // `err`. + } + + return invalidToolCall(call, err) +} + +func isRepairable(err error) bool { + switch err.(type) { + case *NoSuchToolError, *InvalidToolInputError: + return true + } + return false +} + +// invalidToolCall builds the stage-3 result. `input` is the BEST-EFFORT parse of the raw +// string, falling back to the raw string itself when it is not JSON — so +// `input` can be either a parsed value or a bare string. +func invalidToolCall(call RawToolCall, err error) ParsedToolCall { + input := bestEffortParse(call.Input) + return ParsedToolCall{ + Type: "tool-call", + ToolCallID: call.ToolCallID, + ToolName: call.ToolName, + Input: input, + Dynamic: true, + Invalid: true, + Error: err, + ProviderExecuted: call.ProviderExecuted, + ProviderMetadata: call.ProviderMetadata, + } +} + +func bestEffortParse(raw string) json.RawMessage { + if normalized, err := parseSecureJSONValue(raw); err == nil { + if encoded, err := marshalJSONValue(normalized); err == nil { + return encoded + } + } + encoded, err := jsonutil.Marshal(raw) + if err != nil { + return json.RawMessage(`""`) + } + return encoded +} + +// doParseToolCall is stage 1: look the tool up, parse and validate the input. +func doParseToolCall(call RawToolCall, tools *ToolMap) (ParsedToolCall, error) { + spec, ok := tools.Get(call.ToolName) + if !ok { + return ParsedToolCall{}, newNoSuchToolError(call.ToolName, tools.Names()) + } + + // An empty input validates as `{}`; it is NOT an error. + var value json.RawMessage + if strings.TrimSpace(call.Input) == "" { + value = json.RawMessage("{}") + } else { + normalized, err := parseSecureJSONValue(call.Input) + if err != nil { + return ParsedToolCall{}, newInvalidToolInputError(call.ToolName, call.Input, NewJSONParseError(call.Input, err)) + } + encoded, err := marshalJSONValue(normalized) + if err != nil { + return ParsedToolCall{}, newInvalidToolInputError(call.ToolName, call.Input, NewJSONParseError(call.Input, err)) + } + value = encoded + } + if spec.Validate != nil { + if err := spec.Validate(value); err != nil { + return ParsedToolCall{}, newInvalidToolInputError(call.ToolName, call.Input, err) + } + } + return ParsedToolCall{ + Type: "tool-call", + ToolCallID: call.ToolCallID, + ToolName: call.ToolName, + Input: value, + ProviderExecuted: call.ProviderExecuted, + ProviderMetadata: call.ProviderMetadata, + }, nil +} + +// parseSecureJSONValue parses JSON and rejects `__proto__` keys and +// `constructor.prototype` pairs at any depth, so a tool input can never +// smuggle a prototype into a consumer that evaluates it. Both the streaming +// "is parsable" check and tool validation use this exact parser. +func parseSecureJSONValue(input string) (jsonValue, error) { + normalized, err := parseJSONValue([]byte(input)) + if err != nil { + return jsonValue{}, err + } + if hasForbiddenPrototypeJSONValue(normalized) { + return jsonValue{}, errors.New("Object contains forbidden prototype property") + } + return normalized, nil +} diff --git a/internal/seniordev/engine/orclient/transform.go b/internal/seniordev/engine/orclient/transform.go new file mode 100644 index 000000000..a8a8c239c --- /dev/null +++ b/internal/seniordev/engine/orclient/transform.go @@ -0,0 +1,435 @@ +//go:build !windows + +package orclient + +// Message normalisation before a request: surrogate sanitisation, +// unsupported-modality rewriting and the DeepSeek empty-reasoning stub; plus +// the OpenRouter-specific request options. + +import ( + "strings" + "unicode/utf16" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// ModelAPI is the `api` sub-object of a catalog model. +type ModelAPI struct { + Npm string `json:"npm"` + ID string `json:"id"` +} + +// ModelCapabilities is the slice of a catalog model's capabilities request +// assembly reads. `temperature` gates whether a temperature is sent at all; +// `input` gates UnsupportedParts. +type ModelCapabilities struct { + Temperature bool `json:"temperature"` + Reasoning bool `json:"reasoning"` + Attachment bool `json:"attachment"` + ToolCall bool `json:"toolcall"` + Input map[string]bool `json:"input"` + Output map[string]bool `json:"output"` +} + +// ModelLimit is a catalog model's limits. +type ModelLimit struct { + Context float64 `json:"context"` + Input *float64 `json:"input"` + Output float64 `json:"output"` +} + +// Model is the catalog-model projection this package needs. +type Model struct { + ProviderID string `json:"providerID"` + ID string `json:"id"` + API ModelAPI `json:"api"` + Capabilities ModelCapabilities `json:"capabilities"` + Limit ModelLimit `json:"limit"` +} + +// ── SanitizeSurrogates ─────────────────────────────────────────────────── + +// SanitizeSurrogates replaces every UNPAIRED UTF-16 surrogate with U+FFFD. +// +// This is a hand-rolled UTF-16 scan, and it MUST be UTF-16, not runes: the +// whole point is code units, and a surrogate pair must survive untouched while +// its halves individually do not. +// +// A lone surrogate is reachable: `encoding/json` maps a `\uD800` escape to +// U+FFFD on decode, but a Go string is a byte string and can carry the WTF-8 +// encoding of a surrogate code point (ED A0 80 … ED BF BF), which is what a +// non-strict decoder or a byte-level splice produces. Both forms are handled. +// +// The original string is returned BY VALUE when no replacement happens, so a +// CESU-8-encoded (surrogate-pair) input is not silently re-encoded to canonical +// UTF-8. +func SanitizeSurrogates(content string) string { + if !mayContainSurrogate(content) { + return content + } + units := utf16Units(content) + changed := false + for i := 0; i < len(units); i++ { + u := units[i] + switch { + case u >= 0xD800 && u <= 0xDBFF: + // High surrogate: paired only if followed by a low surrogate. + if i+1 < len(units) && units[i+1] >= 0xDC00 && units[i+1] <= 0xDFFF { + i++ + continue + } + units[i] = 0xFFFD + changed = true + case u >= 0xDC00 && u <= 0xDFFF: + // Low surrogate reached without having been consumed as the tail + // of a pair, i.e. not preceded by a high surrogate. + units[i] = 0xFFFD + changed = true + } + } + if !changed { + return content + } + return string(utf16.Decode(units)) +} + +// mayContainSurrogate is the cheap pre-test: a surrogate code unit can only +// appear in a Go string as the WTF-8 sequence ED A0..BF xx, or as a genuine +// astral character (F0..F4 lead byte) whose UTF-16 form is a well-formed pair. +// A well-formed pair is never rewritten, so only the WTF-8 form matters. +func mayContainSurrogate(s string) bool { + for i := 0; i+1 < len(s); i++ { + if s[i] == 0xED && s[i+1] >= 0xA0 && s[i+1] <= 0xBF { + return true + } + } + return false +} + +// utf16Units decodes a Go string to UTF-16 code units, accepting the WTF-8 +// encoding of an unpaired surrogate (which utf8.DecodeRuneInString rejects). +func utf16Units(s string) []uint16 { + out := make([]uint16, 0, len(s)) + for i := 0; i < len(s); { + if s[i] == 0xED && i+2 < len(s) && s[i+1] >= 0xA0 && s[i+1] <= 0xBF && s[i+2] >= 0x80 && s[i+2] <= 0xBF { + cp := rune(s[i]&0x0F)<<12 | rune(s[i+1]&0x3F)<<6 | rune(s[i+2]&0x3F) + out = append(out, uint16(cp)) + i += 3 + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size <= 1 { + out = append(out, 0xFFFD) + i++ + continue + } + if r > 0xFFFF { + hi, lo := utf16.EncodeRune(r) + out = append(out, uint16(hi), uint16(lo)) + } else { + out = append(out, uint16(r)) + } + i += size + } + return out +} + +// ── UnsupportedParts ───────────────────────────────────────────────────── + +// mimeToModality maps a mime type to the capability key that gates it. +func mimeToModality(mime string) string { + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.HasPrefix(mime, "audio/"): + return "audio" + case strings.HasPrefix(mime, "video/"): + return "video" + case mime == "application/pdf": + return "pdf" + } + return "" +} + +// UnsupportedParts runs before NormalizeMessages. It only touches +// ARRAY-content user messages, and only their `file` parts: a part whose +// modality the model does not accept becomes a text part telling the model to +// inform the user. +// +// A nil capabilities.input map counts as "supports nothing", so a malformed +// catalog entry rewrites every file part rather than failing. +func UnsupportedParts(msgs []msgmodel.ModelMessage, model Model) []msgmodel.ModelMessage { + out := make([]msgmodel.ModelMessage, len(msgs)) + for i, msg := range msgs { + out[i] = msg + if msg.Role != "user" { + continue + } + parts, ok := msg.Content.([]any) + if !ok { + continue + } + out[i].Content = mapParts(parts, func(part any) any { + file, ok := part.(msgmodel.FileContent) + if !ok { + return part + } + modality := mimeToModality(file.MediaType) + if modality == "" { + return part + } + if model.Capabilities.Input[modality] { + return part + } + name := modality + if len(file.Filename) > 0 { + if decoded := textOf(rawJSONValue(file.Filename)); decoded != "" { + name = `"` + decoded + `"` + } + } + return msgmodel.TextContent{ + Type: "text", + Text: "ERROR: Cannot read " + name + " (this model does not support " + modality + " input). Inform the user.", + } + }) + } + return out +} + +// Message prepares a message list for an OpenRouter model: UnsupportedParts, +// then NormalizeMessages. It is called immediately before BuildRequestBody. +func Message(msgs []msgmodel.ModelMessage, model Model) []msgmodel.ModelMessage { + return NormalizeMessages(UnsupportedParts(msgs, model), model) +} + +// ── normalizeMessages, reachable branches only ──────────────────────────── + +// NormalizeMessages sanitises surrogates in every text-bearing part and, for +// a DeepSeek model, appends the empty reasoning stub. It returns a new slice +// and leaves the input alone. +func NormalizeMessages(msgs []msgmodel.ModelMessage, model Model) []msgmodel.ModelMessage { + out := make([]msgmodel.ModelMessage, len(msgs)) + for i, msg := range msgs { + out[i] = sanitizeMessage(msg) + } + if strings.Contains(strings.ToLower(model.API.ID), "deepseek") { + out = deepseekReasoningStub(out) + } + return out +} + +func sanitizeMessage(msg msgmodel.ModelMessage) msgmodel.ModelMessage { + switch msg.Role { + case "tool": + parts, ok := msg.Content.([]any) + if !ok { + // Non-array content is left alone. + return msg + } + msg.Content = mapParts(parts, func(part any) any { + if tr, ok := part.(msgmodel.ToolResultContent); ok { + return sanitizeToolResultOutput(tr) + } + return part + }) + return msg + + case "system": + if s, ok := msg.Content.(string); ok { + msg.Content = SanitizeSurrogates(s) + } + return msg + + case "user": + if s, ok := msg.Content.(string); ok { + msg.Content = SanitizeSurrogates(s) + return msg + } + parts, ok := msg.Content.([]any) + if !ok { + return msg + } + msg.Content = mapParts(parts, func(part any) any { + if t, ok := part.(msgmodel.TextContent); ok { + t.Text = SanitizeSurrogates(t.Text) + return t + } + return part + }) + return msg + + case "assistant": + if s, ok := msg.Content.(string); ok { + msg.Content = SanitizeSurrogates(s) + return msg + } + parts, ok := msg.Content.([]any) + if !ok { + return msg + } + msg.Content = mapParts(parts, func(part any) any { + switch p := part.(type) { + case msgmodel.TextContent: + p.Text = SanitizeSurrogates(p.Text) + return p + case msgmodel.ReasoningContent: + p.Text = SanitizeSurrogates(p.Text) + return p + case msgmodel.ToolResultContent: + return sanitizeToolResultOutput(p) + } + return part + }) + return msg + } + // An unknown role is passed through untouched. + return msg +} + +// sanitizeToolResultOutput sanitises the text of a tool result. `json` / +// `error-json` / `execution-denied` outputs are deliberately untouched. +func sanitizeToolResultOutput(tr msgmodel.ToolResultContent) msgmodel.ToolResultContent { + switch tr.Output.Type { + case "text", "error-text": + if s, ok := tr.Output.Value.(string); ok { + tr.Output.Value = SanitizeSurrogates(s) + } + case "content": + items, ok := tr.Output.Value.([]any) + if !ok { + return tr + } + tr.Output.Value = mapParts(items, func(item any) any { + if t, ok := item.(msgmodel.ToolOutputContentText); ok { + t.Text = SanitizeSurrogates(t.Text) + return t + } + return item + }) + } + return tr +} + +func mapParts(parts []any, f func(any) any) []any { + out := make([]any, len(parts)) + for i, p := range parts { + out[i] = f(p) + } + return out +} + +// deepseekReasoningStub: DeepSeek requires every assistant message to carry +// reasoning. +// +// Every assistant message gets `{type:"reasoning", text:""}` APPENDED AT THE +// END of its content (after any tool-calls), unless it already carries a +// reasoning part. String content becomes `[{type:"text", text}]` first, and an +// EMPTY string produces no text part at all. +// +// This fires for every DeepSeek API id. +func deepseekReasoningStub(msgs []msgmodel.ModelMessage) []msgmodel.ModelMessage { + out := make([]msgmodel.ModelMessage, len(msgs)) + for i, msg := range msgs { + if msg.Role != "assistant" { + out[i] = msg + continue + } + if parts, ok := msg.Content.([]any); ok { + hasReasoning := false + for _, p := range parts { + if _, is := p.(msgmodel.ReasoningContent); is { + hasReasoning = true + break + } + } + if hasReasoning { + out[i] = msg + continue + } + next := make([]any, 0, len(parts)+1) + next = append(next, parts...) + next = append(next, msgmodel.ReasoningContent{Type: "reasoning", Text: ""}) + msg.Content = next + out[i] = msg + continue + } + text, _ := msg.Content.(string) + next := make([]any, 0, 2) + if text != "" { + next = append(next, msgmodel.TextContent{Type: "text", Text: text}) + } + next = append(next, msgmodel.ReasoningContent{Type: "reasoning", Text: ""}) + msg.Content = next + out[i] = msg + } + return out +} + +// ── output cap ──────────────────────────────────────────────────────────── +// +// No generation parameter (temperature, top_p, ...) has a built-in default; +// the provider's own defaults apply unless the caller sets one. +// MaxOutputTokens is the one value with a built-in default, below. + +// MaxOutputTokens is `min(model.limit.output, OUTPUT_TOKEN_MAX)`, falling +// back to OUTPUT_TOKEN_MAX when the minimum is 0 or NaN. Delegated to +// internal/engine/calc so the SENIOR_DEV_OUTPUT_TOKEN_MAX read lives in exactly +// one place. +func MaxOutputTokens(model Model) float64 { + return calc.MaxOutputTokens(calc.Model{Limit: calc.ModelLimit{ + Context: model.Limit.Context, + Input: model.Limit.Input, + Output: model.Limit.Output, + }}) +} + +// ── Options / ProviderOptions, OpenRouter cases only ───────────────────── + +// OptionsInput is what Options needs. +type OptionsInput struct { + Model Model `json:"model"` + SessionID string `json:"sessionID"` +} + +// Options is the OpenRouter-specific request option bag, in the order the +// keys reach the wire after the top-level spread: +// +// usage (npm === "@openrouter/ai-sdk-provider") +// prompt_cache_key (providerID === "openrouter") +// +// No reasoning effort is set here; it is sent only when a variant or config +// sets it. +func Options(input OptionsInput) *Object { + result := NewObject() + if input.Model.API.Npm == "@openrouter/ai-sdk-provider" || input.Model.API.Npm == "@llmgateway/ai-sdk-provider" { + usage := NewObject() + usage.SetBool("include", true) + result.SetObject("usage", usage) + } + if input.Model.ProviderID == "openrouter" { + result.SetString("prompt_cache_key", input.SessionID) + } + return result +} + +// ProviderOptions wraps the merged option bag under the provider's SDK key, +// which for OpenRouter is "openrouter": exactly the namespace BuildRequestBody +// unwraps and spreads over the body. Callers that go straight to +// BuildRequestBody can skip the round-trip; this exists so the wrapping is +// testable on its own. +func ProviderOptions(model Model, options *Object) *Object { + out := NewObject() + out.SetObject(sdkKeyFor(model.API.Npm), options) + return out +} + +func sdkKeyFor(npm string) string { + switch npm { + case "@openrouter/ai-sdk-provider": + return "openrouter" + } + // Every model this client serves is an OpenRouter model. + return "openrouter" +} diff --git a/internal/seniordev/engine/orclient/wire.go b/internal/seniordev/engine/orclient/wire.go new file mode 100644 index 000000000..4dd3dd743 --- /dev/null +++ b/internal/seniordev/engine/orclient/wire.go @@ -0,0 +1,943 @@ +//go:build !windows + +package orclient + +// Chunk validation: the streaming chunk shape and the error-response shape, +// tried in that order. +// +// Three properties of the validation drive behaviour and are easy to lose: +// +// 1. `choices` is a REQUIRED array on the chunk shape. A payload carrying +// only `usage` fails BOTH shapes and surfaces as a parse failure, i.e. an +// `error` part and `finishReason = error`, not a silently ignored chunk. +// 2. Unknown fields survive validation. The one place that is observable is +// the `error` field: an object carrying both `choices` and `error` +// validates as a CHUNK, and the emitted error is the RAW object. When it +// validates as the ERROR shape instead, `code`/`type`/`param` are filled +// with null and reordered to shape order. +// 3. `finish_reason` is any string, not an enum, so arbitrary provider +// strings reach `raw` and map to `other`. +// +// The `error` value of a PARSE-FAILURE part is an envelope +// (`{"name":…,"cause":…,"value":…}` / `{"name":…,"cause":…,"text":…}`) whose +// discriminating `name` is what a consumer keys on; the router classifiers +// substring-match on `message`. + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" +) + +// Chunk is one parsed SSE payload. +type Chunk struct { + // Success reports whether the payload validated. + Success bool + // ParseError is the serialised error object emitted when Success is false. + ParseError json.RawMessage + // Value is the validated chunk. + Value *ChunkValue +} + +// ChunkValue is the validated payload. +type ChunkValue struct { + ID *string + Model *string + Provider *string + Usage *calc.OpenRouterUsage + Choices []Choice + + // ErrorField is set when the payload carries an `error` key, for BOTH + // shapes. + ErrorField json.RawMessage +} + +// Choice is `value.choices[i]`. +type Choice struct { + Delta *Delta + FinishReason *string +} + +// Delta is `choices[i].delta`. +type Delta struct { + Content *string + Reasoning *string + ReasoningDetails []ReasoningDetail + HasReasoningDeta bool + Images []ImageResponse + ToolCalls []ToolCallDelta + HasToolCalls bool + Annotations []Annotation + HasAnnotations bool +} + +// ImageResponse is one recognised `delta.images[]` entry. +type ImageResponse struct{ URL string } + +// ToolCallDelta is one `delta.tool_calls[]` entry. Every field may be absent; +// a missing `type` on a FIRST delta is an InvalidResponseDataError. +type ToolCallDelta struct { + Index *float64 + ID *string + Type *string + HasFunction bool + Name *string + Arguments *string + // Raw is the original entry, carried because InvalidResponseDataError + // reports it as `data`. + Raw json.RawMessage +} + +// Annotation is one `delta.annotations[]` entry. +type Annotation struct { + Type string + // url_citation fields. + URL string + Title *string + StartIndex *float64 + EndIndex *float64 + Content *string + // Raw carries the normalized annotation object; the old-format + // `file_annotation` is validated and then IGNORED ENTIRELY. + Raw json.RawMessage +} + +// ParseChunk parses and validates one SSE payload. +func ParseChunk(text string) Chunk { + parsed, err := parseJSONValue([]byte(text)) + if err != nil { + return Chunk{Success: false, ParseError: jsonParseErrorValue(text)} + } + if hasForbiddenPrototypeJSONValue(parsed) { + return Chunk{Success: false, ParseError: jsonParseErrorValue(text)} + } + raw := json.RawMessage(text) + + value, chunkErr := validateChunk(raw) + if chunkErr == nil { + return Chunk{Success: true, Value: value} + } + value, errErr := validateErrorResponse(raw) + if errErr == nil { + return Chunk{Success: true, Value: value} + } + return Chunk{ + Success: false, + ParseError: typeValidationErrorValue(raw, chunkErr, errErr), + } +} + +func hasForbiddenPrototypeJSONValue(value jsonValue) bool { + switch value.Kind { + case kindArray: + for _, child := range value.Array { + if hasForbiddenPrototypeJSONValue(child) { + return true + } + } + case kindObject: + for _, member := range value.Object { + if member.Key == "__proto__" { + return true + } + if member.Key == "constructor" && member.Value.Kind == kindObject { + for _, child := range member.Value.Object { + if child.Key == "prototype" { + return true + } + } + } + if hasForbiddenPrototypeJSONValue(member.Value) { + return true + } + } + } + return false +} + +func jsonParseErrorValue(text string) json.RawMessage { + w := newObjectWriter() + w.str("name", "JSONParseError") + w.raw("cause", json.RawMessage("{}")) + w.str("text", text) + out, err := w.done() + if err != nil { + return json.RawMessage(`{"name":"JSONParseError"}`) + } + return out +} + +func typeValidationErrorValue(raw json.RawMessage, chunkErr, errErr error) json.RawMessage { + cause := newObjectWriter() + cause.str("name", "ValidationError") + cause.str("message", chunkErr.Error()+"\n"+errErr.Error()) + causeRaw, err := cause.done() + if err != nil { + causeRaw = json.RawMessage("{}") + } + w := newObjectWriter() + w.str("name", "TypeValidationError") + w.raw("cause", causeRaw) + w.raw("value", raw) + out, err := w.done() + if err != nil { + return json.RawMessage(`{"name":"TypeValidationError"}`) + } + return out +} + +// ── the chunk shape ────────────────────────────────────────────────────── + +func validateChunk(raw json.RawMessage) (*ChunkValue, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`expected object`) + } + out := &ChunkValue{} + + var err error + if out.ID, err = optionalString(obj, "id"); err != nil { + return nil, err + } + if out.Model, err = optionalString(obj, "model"); err != nil { + return nil, err + } + if out.Provider, err = optionalString(obj, "provider"); err != nil { + return nil, err + } + if out.Usage, err = validateUsage(obj["usage"]); err != nil { + return nil, err + } + + choicesRaw, present := obj["choices"] + if !present || bytes.Equal(bytes.TrimSpace(choicesRaw), []byte("null")) { + return nil, errors.New(`Invalid input: expected array, received undefined at "choices"`) + } + var choices []json.RawMessage + if err := json.Unmarshal(choicesRaw, &choices); err != nil { + return nil, errors.New(`Invalid input: expected array at "choices"`) + } + out.Choices = make([]Choice, 0, len(choices)) + for _, c := range choices { + choice, err := validateChoice(c) + if err != nil { + return nil, err + } + out.Choices = append(out.Choices, choice) + } + if e, ok := obj["error"]; ok { + out.ErrorField = e + } + return out, nil +} + +func validateChoice(raw json.RawMessage) (Choice, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return Choice{}, errors.New(`Invalid input: expected object at "choices[]"`) + } + out := Choice{} + if _, err := nullishNumber(obj, "index"); err != nil { + return Choice{}, err + } + if logprobs, ok := obj["logprobs"]; ok && !isJSONNull(logprobs) { + if err := validateLogprobs(logprobs); err != nil { + return Choice{}, err + } + } + fr, err := nullableOptionalString(obj, "finish_reason") + if err != nil { + return Choice{}, err + } + out.FinishReason = fr + + deltaRaw, present := obj["delta"] + if !present || bytes.Equal(bytes.TrimSpace(deltaRaw), []byte("null")) { + return out, nil + } + delta, err := validateDelta(deltaRaw) + if err != nil { + return Choice{}, err + } + out.Delta = delta + return out, nil +} + +func validateDelta(raw json.RawMessage) (*Delta, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`Invalid input: expected object at "delta"`) + } + out := &Delta{} + + if roleRaw, ok := obj["role"]; ok { + var role string + if err := json.Unmarshal(roleRaw, &role); err != nil || role != "assistant" { + return nil, errors.New(`Invalid input: expected "assistant" at "delta.role"`) + } + } + var err error + if out.Content, err = nullishString(obj, "content"); err != nil { + return nil, err + } + if out.Reasoning, err = nullishString(obj, "reasoning"); err != nil { + return nil, err + } + + if rd, ok := obj["reasoning_details"]; ok && !isJSONNull(rd) { + var entries []json.RawMessage + if err := json.Unmarshal(rd, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.reasoning_details"`) + } + details := make([]ReasoningDetail, 0, len(entries)) + for _, e := range entries { + d, ok := parseReasoningDetail(e) + if !ok { + continue // mapped to null, then filtered + } + details = append(details, d) + } + out.ReasoningDetails = details + out.HasReasoningDeta = true + } + + if imgs, ok := obj["images"]; ok && !isJSONNull(imgs) { + var entries []json.RawMessage + if err := json.Unmarshal(imgs, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.images"`) + } + for _, e := range entries { + img, ok := parseImageResponse(e) + if !ok { + continue // ImageResponseWithUnknownSchema → null → filtered + } + out.Images = append(out.Images, img) + } + } + + if tc, ok := obj["tool_calls"]; ok && !isJSONNull(tc) { + var entries []json.RawMessage + if err := json.Unmarshal(tc, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.tool_calls"`) + } + out.HasToolCalls = true + for _, e := range entries { + d, err := validateToolCallDelta(e) + if err != nil { + return nil, err + } + out.ToolCalls = append(out.ToolCalls, d) + } + } + + if ann, ok := obj["annotations"]; ok && !isJSONNull(ann) { + var entries []json.RawMessage + if err := json.Unmarshal(ann, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.annotations"`) + } + out.HasAnnotations = true + for _, e := range entries { + a, err := validateAnnotation(e) + if err != nil { + return nil, err + } + out.Annotations = append(out.Annotations, a) + } + } + return out, nil +} + +func validateToolCallDelta(raw json.RawMessage) (ToolCallDelta, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return ToolCallDelta{}, errors.New(`Invalid input: expected object at "delta.tool_calls[]"`) + } + out := ToolCallDelta{Raw: raw} + var err error + if out.Index, err = nullishNumber(obj, "index"); err != nil { + return ToolCallDelta{}, err + } + if out.ID, err = nullishString(obj, "id"); err != nil { + return ToolCallDelta{}, err + } + if t, ok := obj["type"]; ok { + var typ string + if err := json.Unmarshal(t, &typ); err != nil || typ != "function" { + return ToolCallDelta{}, errors.New(`Invalid input: expected "function" at "delta.tool_calls[].type"`) + } + out.Type = &typ + } + // `function` is REQUIRED on the delta entry. + fnRaw, ok := obj["function"] + if !ok { + return ToolCallDelta{}, errors.New(`Invalid input: expected object, received undefined at "delta.tool_calls[].function"`) + } + if isJSONNull(fnRaw) { + return ToolCallDelta{}, errors.New(`Invalid input: expected object at "delta.tool_calls[].function"`) + } + var fn map[string]json.RawMessage + if err := json.Unmarshal(fnRaw, &fn); err != nil { + return ToolCallDelta{}, errors.New(`Invalid input: expected object at "delta.tool_calls[].function"`) + } + out.HasFunction = true + if out.Name, err = nullishString(fn, "name"); err != nil { + return ToolCallDelta{}, err + } + if out.Arguments, err = nullishString(fn, "arguments"); err != nil { + return ToolCallDelta{}, err + } + return out, nil +} + +func validateAnnotation(raw json.RawMessage) (Annotation, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "delta.annotations[]"`) + } + typeRaw, ok := obj["type"] + if !ok { + return Annotation{}, errors.New(`Invalid input: expected a discriminated annotation`) + } + var typ string + if err := json.Unmarshal(typeRaw, &typ); err != nil { + return Annotation{}, errors.New(`Invalid input: expected string at "delta.annotations[].type"`) + } + switch typ { + case "url_citation": + inner, ok := obj["url_citation"] + if !ok { + return Annotation{}, errors.New(`Invalid input: expected object at "url_citation"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "url_citation"`) + } + url, ok := requiredString(fields, "url") + if !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "url_citation.url"`) + } + a := Annotation{Type: typ, URL: url, Raw: raw} + var err error + if a.Title, err = optionalString(fields, "title"); err != nil { + return Annotation{}, err + } + if a.StartIndex, err = optionalNumber(fields, "start_index"); err != nil { + return Annotation{}, err + } + if a.EndIndex, err = optionalNumber(fields, "end_index"); err != nil { + return Annotation{}, err + } + if a.Content, err = optionalString(fields, "content"); err != nil { + return Annotation{}, err + } + normalizedInner, err := normalizePassthroughObject(inner, + []string{"url", "title", "start_index", "end_index", "content"}, nil) + if err != nil { + return Annotation{}, err + } + normalizedOuter, err := normalizePassthroughObject(raw, + []string{"type", "url_citation"}, + map[string]json.RawMessage{"url_citation": normalizedInner}) + if err != nil { + return Annotation{}, err + } + a.Raw = normalizedOuter + return a, nil + case "file_annotation": + inner, ok := obj["file_annotation"] + if !ok || isJSONNull(inner) { + return Annotation{}, errors.New(`Invalid input: expected object at "file_annotation"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "file_annotation"`) + } + if _, ok := requiredString(fields, "file_id"); !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "file_annotation.file_id"`) + } + if _, err := optionalString(fields, "quote"); err != nil { + return Annotation{}, err + } + normalizedInner, err := normalizePassthroughObject(inner, + []string{"file_id", "quote"}, nil) + if err != nil { + return Annotation{}, err + } + normalizedOuter, err := normalizePassthroughObject(raw, + []string{"type", "file_annotation"}, + map[string]json.RawMessage{"file_annotation": normalizedInner}) + if err != nil { + return Annotation{}, err + } + return Annotation{Type: typ, Raw: normalizedOuter}, nil + case "file": + inner, ok := obj["file"] + if !ok || isJSONNull(inner) { + return Annotation{}, errors.New(`Invalid input: expected object at "file"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "file"`) + } + if _, ok := requiredString(fields, "hash"); !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "file.hash"`) + } + if _, ok := requiredString(fields, "name"); !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "file.name"`) + } + overrides := map[string]json.RawMessage{} + if content, present := fields["content"]; present { + normalized, err := normalizeFileAnnotationContent(content) + if err != nil { + return Annotation{}, err + } + overrides["content"] = normalized + } + normalizedInner, err := normalizePassthroughObject(inner, + []string{"hash", "name", "content"}, overrides) + if err != nil { + return Annotation{}, err + } + normalizedOuter, err := normalizePassthroughObject(raw, + []string{"type", "file"}, + map[string]json.RawMessage{"file": normalizedInner}) + if err != nil { + return Annotation{}, err + } + return Annotation{Type: typ, Raw: normalizedOuter}, nil + } + // The annotation union has NO unknown fallback, so an unrecognised entry + // fails the whole chunk. + return Annotation{}, errors.New(`Invalid input: unrecognised annotation type ` + typ) +} + +func parseImageResponse(raw json.RawMessage) (ImageResponse, bool) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return ImageResponse{}, false + } + typ, ok := requiredString(obj, "type") + if !ok || typ != "image_url" { + return ImageResponse{}, false + } + inner, ok := obj["image_url"] + if !ok { + return ImageResponse{}, false + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return ImageResponse{}, false + } + url, ok := requiredString(fields, "url") + if !ok { + return ImageResponse{}, false + } + return ImageResponse{URL: url}, true +} + +// validateUsage checks the `usage` object: prompt_tokens, +// completion_tokens and total_tokens are all REQUIRED numbers, so a partial +// usage object fails the whole chunk. +func validateUsage(raw json.RawMessage) (*calc.OpenRouterUsage, error) { + if raw == nil || isJSONNull(raw) { + return nil, nil + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage"`) + } + for _, required := range []string{"prompt_tokens", "completion_tokens", "total_tokens"} { + v, ok := obj[required] + if !ok { + return nil, errors.New(`Invalid input: expected number, received undefined at "usage.` + required + `"`) + } + if !isJSONNumber(v) { + return nil, errors.New(`Invalid input: expected number at "usage.` + required + `"`) + } + } + overrides := map[string]json.RawMessage{} + if d, ok := obj["prompt_tokens_details"]; ok && !isJSONNull(d) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(d, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage.prompt_tokens_details"`) + } + cached, ok := fields["cached_tokens"] + if !ok { + return nil, errors.New(`Invalid input: expected number, received undefined at "usage.prompt_tokens_details.cached_tokens"`) + } + if !isJSONNumber(cached) { + return nil, errors.New(`Invalid input: expected number at "usage.prompt_tokens_details.cached_tokens"`) + } + if cacheWrite, present := fields["cache_write_tokens"]; present && + !isJSONNull(cacheWrite) && !isJSONNumber(cacheWrite) { + return nil, errors.New(`Invalid input: expected number at "usage.prompt_tokens_details.cache_write_tokens"`) + } + normalized, err := normalizePassthroughObject(d, + []string{"cached_tokens", "cache_write_tokens"}, nil) + if err != nil { + return nil, err + } + overrides["prompt_tokens_details"] = normalized + } + if d, ok := obj["completion_tokens_details"]; ok && !isJSONNull(d) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(d, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage.completion_tokens_details"`) + } + reasoning, ok := fields["reasoning_tokens"] + if !ok { + return nil, errors.New(`Invalid input: expected number, received undefined at "usage.completion_tokens_details.reasoning_tokens"`) + } + if !isJSONNumber(reasoning) { + return nil, errors.New(`Invalid input: expected number at "usage.completion_tokens_details.reasoning_tokens"`) + } + normalized, err := normalizePassthroughObject(d, + []string{"reasoning_tokens"}, nil) + if err != nil { + return nil, err + } + overrides["completion_tokens_details"] = normalized + } + if cost, present := obj["cost"]; present && !isJSONNumber(cost) { + return nil, errors.New(`Invalid input: expected number at "usage.cost"`) + } + if details, present := obj["cost_details"]; present && !isJSONNull(details) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(details, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage.cost_details"`) + } + if upstream, ok := fields["upstream_inference_cost"]; ok && + !isJSONNull(upstream) && !isJSONNumber(upstream) { + return nil, errors.New(`Invalid input: expected number at "usage.cost_details.upstream_inference_cost"`) + } + normalized, err := normalizePassthroughObject(details, + []string{"upstream_inference_cost"}, nil) + if err != nil { + return nil, err + } + overrides["cost_details"] = normalized + } + normalized, err := normalizePassthroughObject(raw, []string{ + "prompt_tokens", + "prompt_tokens_details", + "completion_tokens", + "completion_tokens_details", + "total_tokens", + "cost", + "cost_details", + }, overrides) + if err != nil { + return nil, err + } + var usage calc.OpenRouterUsage + if err := json.Unmarshal(normalized, &usage); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage"`) + } + return &usage, nil +} + +// ── the error shape ────────────────────────────────────────────────────── + +// validateErrorResponse checks the error-response shape: `error.message` is +// required, and `code`/`type`/`param` default to explicit nulls in the +// normalized object, in SHAPE order, with extra keys after. +func validateErrorResponse(raw json.RawMessage) (*ChunkValue, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`expected object`) + } + errRaw, ok := obj["error"] + if !ok { + return nil, errors.New(`Invalid input: expected object, received undefined at "error"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(errRaw, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "error"`) + } + if _, ok := requiredString(fields, "message"); !ok { + return nil, errors.New(`Invalid input: expected string at "error.message"`) + } + if code, present := fields["code"]; present && !isJSONNull(code) && + !isJSONString(code) && !isJSONNumber(code) { + return nil, errors.New(`Invalid input: expected string or number at "error.code"`) + } + if typ, present := fields["type"]; present && !isJSONNull(typ) && !isJSONString(typ) { + return nil, errors.New(`Invalid input: expected string at "error.type"`) + } + + ordered, err := ParseObject(errRaw) + if err != nil { + return nil, err + } + normalized := NewObject() + for _, key := range []string{"code", "message", "type", "param"} { + if v, ok := ordered.Get(key); ok { + if err := normalized.Set(key, v); err != nil { + return nil, err + } + continue + } + if key == "message" { + continue + } + normalized.set(key, jsonValue{Kind: kindNull}) + } + for _, key := range ordered.Keys() { + if normalized.Has(key) { + continue + } + v, _ := ordered.Get(key) + if err := normalized.Set(key, v); err != nil { + return nil, err + } + } + encoded, err := normalized.MarshalJSON() + if err != nil { + return nil, err + } + return &ChunkValue{ErrorField: encoded}, nil +} + +// ── small field helpers ────────────────────────────────────────────────── + +func isJSONNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + +// optionalString reads an optional string: absent is fine, null is NOT. +func optionalString(obj map[string]json.RawMessage, key string) (*string, error) { + raw, ok := obj[key] + if !ok { + return nil, nil + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, errors.New(`Invalid input: expected string at "` + key + `"`) + } + return &s, nil +} + +// nullishString reads a string that may be absent or null; both give nil. +func nullishString(obj map[string]json.RawMessage, key string) (*string, error) { + raw, ok := obj[key] + if !ok || isJSONNull(raw) { + return nil, nil + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, errors.New(`Invalid input: expected string at "` + key + `"`) + } + return &s, nil +} + +// nullableOptionalString is nullishString under the name the wire field +// declares. +func nullableOptionalString(obj map[string]json.RawMessage, key string) (*string, error) { + return nullishString(obj, key) +} + +func nullishNumber(obj map[string]json.RawMessage, key string) (*float64, error) { + raw, ok := obj[key] + if !ok || isJSONNull(raw) { + return nil, nil + } + var n float64 + if err := json.Unmarshal(raw, &n); err != nil { + return nil, errors.New(`Invalid input: expected number at "` + key + `"`) + } + return &n, nil +} + +func optionalNumber(obj map[string]json.RawMessage, key string) (*float64, error) { + raw, ok := obj[key] + if !ok { + return nil, nil + } + var n float64 + if err := json.Unmarshal(raw, &n); err != nil { + return nil, errors.New(`Invalid input: expected number at "` + key + `"`) + } + return &n, nil +} + +// validateLogprobs validates choice.logprobs, which nothing reads afterwards: +// a malformed block still turns the whole SSE chunk into an error part. +func validateLogprobs(raw json.RawMessage) error { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return errors.New(`Invalid input: expected object at "choices[].logprobs"`) + } + content, ok := obj["content"] + if !ok { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content"`) + } + // content may be an explicit null. + if isJSONNull(content) { + return nil + } + var entries []json.RawMessage + if err := json.Unmarshal(content, &entries); err != nil { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content"`) + } + for _, entry := range entries { + var token map[string]json.RawMessage + if err := json.Unmarshal(entry, &token); err != nil { + return errors.New(`Invalid input: expected object at "choices[].logprobs.content[]"`) + } + if _, ok := requiredString(token, "token"); !ok { + return errors.New(`Invalid input: expected string at "choices[].logprobs.content[].token"`) + } + if value, ok := token["logprob"]; !ok || !isJSONNumber(value) { + return errors.New(`Invalid input: expected number at "choices[].logprobs.content[].logprob"`) + } + top, ok := token["top_logprobs"] + if !ok || isJSONNull(top) { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content[].top_logprobs"`) + } + var alternatives []json.RawMessage + if err := json.Unmarshal(top, &alternatives); err != nil { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content[].top_logprobs"`) + } + for _, alternative := range alternatives { + var fields map[string]json.RawMessage + if err := json.Unmarshal(alternative, &fields); err != nil { + return errors.New(`Invalid input: expected object at "choices[].logprobs.content[].top_logprobs[]"`) + } + if _, ok := requiredString(fields, "token"); !ok { + return errors.New(`Invalid input: expected string at "choices[].logprobs.content[].top_logprobs[].token"`) + } + if value, ok := fields["logprob"]; !ok || !isJSONNumber(value) { + return errors.New(`Invalid input: expected number at "choices[].logprobs.content[].top_logprobs[].logprob"`) + } + } + } + return nil +} + +func normalizeFileAnnotationContent(raw json.RawMessage) (json.RawMessage, error) { + if isJSONNull(raw) { + return nil, errors.New(`Invalid input: expected array at "file.content"`) + } + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "file.content"`) + } + values := make([]jsonValue, 0, len(entries)) + for _, entry := range entries { + var fields map[string]json.RawMessage + if err := json.Unmarshal(entry, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "file.content[]"`) + } + if _, ok := requiredString(fields, "type"); !ok { + return nil, errors.New(`Invalid input: expected string at "file.content[].type"`) + } + if _, err := optionalString(fields, "text"); err != nil { + return nil, err + } + normalized, err := normalizePassthroughObject(entry, []string{"type", "text"}, nil) + if err != nil { + return nil, err + } + value, err := parseJSONValue(normalized) + if err != nil { + return nil, err + } + values = append(values, value) + } + return marshalJSONValue(jsonValue{Kind: kindArray, Array: values}) +} + +// normalizePassthroughObject re-emits an object with the declared shape keys +// first, in shape order, followed by the remaining keys in source order. +// Overrides contain already-normalized nested objects. +func normalizePassthroughObject( + raw json.RawMessage, + shape []string, + overrides map[string]json.RawMessage, +) (json.RawMessage, error) { + source, err := ParseObject(raw) + if err != nil { + return nil, err + } + out := NewObject() + for _, key := range shape { + value, present := source.Get(key) + if !present { + continue + } + if override, ok := overrides[key]; ok { + value = override + } + if err := out.Set(key, value); err != nil { + return nil, err + } + } + for _, key := range source.Keys() { + if out.Has(key) { + continue + } + value, _ := source.Get(key) + if err := out.Set(key, value); err != nil { + return nil, err + } + } + return out.MarshalJSON() +} + +func isJSONNumber(raw json.RawMessage) bool { + var number json.Number + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil { + return false + } + number, ok := token.(json.Number) + if !ok { + return false + } + var n float64 + return json.Unmarshal([]byte(number.String()), &n) == nil +} + +func isJSONString(raw json.RawMessage) bool { + var value string + return json.Unmarshal(raw, &value) == nil +} + +// getMediaType reads the media type of a data URL, else the default. +func getMediaType(dataURL, defaultMediaType string) string { + if !strings.HasPrefix(dataURL, "data:") { + return defaultMediaType + } + rest := dataURL[len("data:"):] + end := strings.IndexByte(rest, ';') + if end < 0 { + end = len(rest) + } + if end == 0 { + return defaultMediaType + } + return rest[:end] +} + +// InvalidResponseDataError is returned by the tool-call accumulator for a +// malformed first delta. It tears the whole stream down; it is not an `error` +// part. +type InvalidResponseDataError struct { + Message string + Data json.RawMessage +} + +func (e *InvalidResponseDataError) Error() string { return e.Message } + +func newInvalidResponseDataError(message string, data any) *InvalidResponseDataError { + encoded, err := json.Marshal(data) + if err != nil { + encoded = nil + } + return &InvalidResponseDataError{Message: message, Data: encoded} +} diff --git a/internal/seniordev/engine/retrysched/contextoverflow.go b/internal/seniordev/engine/retrysched/contextoverflow.go new file mode 100644 index 000000000..6babf27bc --- /dev/null +++ b/internal/seniordev/engine/retrysched/contextoverflow.go @@ -0,0 +1,70 @@ +//go:build !windows + +package retrysched + +import ( + "encoding/json" + "regexp" + "strings" +) + +var contextOverflowPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)prompt is too long`), + regexp.MustCompile(`(?i)input is too long for requested model`), + regexp.MustCompile(`(?i)exceeds the context window`), + regexp.MustCompile(`(?i)input token count.*exceeds the maximum`), + regexp.MustCompile(`(?i)maximum prompt length is \d+`), + regexp.MustCompile(`(?i)reduce the length of the messages`), + regexp.MustCompile(`(?i)maximum context length is \d+ tokens`), + regexp.MustCompile(`(?i)exceeds the limit of \d+`), + regexp.MustCompile(`(?i)exceeds the available context size`), + regexp.MustCompile(`(?i)greater than the context length`), + regexp.MustCompile(`(?i)context window exceeds limit`), + regexp.MustCompile(`(?i)exceeded model token limit`), + regexp.MustCompile(`(?i)context[_ ]length[_ ]exceeded`), + regexp.MustCompile(`(?i)request entity too large`), + regexp.MustCompile(`(?i)context length is only \d+ tokens`), + regexp.MustCompile(`(?i)input length.*exceeds.*context length`), + regexp.MustCompile(`(?i)prompt too long; exceeded (max )?context length`), + regexp.MustCompile(`(?i)too large for model with \d+ maximum context length`), + regexp.MustCompile(`(?i)model_context_window_exceeded`), +} + +var contextOverflowNoBody = regexp.MustCompile(`(?i)^4(00|13)\s*(status code)?\s*\(no body\)`) + +// IsContextOverflow reports whether a classified error says the prompt did +// not fit the model's context: a 413, a known overflow message, or a +// context_length_exceeded code in the response body. +func IsContextOverflow(err Err) bool { + if err.Name == "ContextOverflowError" { + return true + } + if !err.IsAPIError() { + return false + } + if err.Data.StatusCode != nil && *err.Data.StatusCode == 413 { + return true + } + message := "" + if err.Data.Message != nil { + message = strings.TrimSpace(*err.Data.Message) + } + for _, pattern := range contextOverflowPatterns { + if pattern.MatchString(message) { + return true + } + } + if contextOverflowNoBody.MatchString(message) { + return true + } + if err.Data.ResponseBody == nil { + return false + } + var body struct { + Error *struct { + Code string `json:"code"` + } `json:"error"` + } + return json.Unmarshal([]byte(*err.Data.ResponseBody), &body) == nil && + body.Error != nil && body.Error.Code == "context_length_exceeded" +} diff --git a/internal/seniordev/engine/retrysched/contextoverflow_test.go b/internal/seniordev/engine/retrysched/contextoverflow_test.go new file mode 100644 index 000000000..d40b0d8e5 --- /dev/null +++ b/internal/seniordev/engine/retrysched/contextoverflow_test.go @@ -0,0 +1,37 @@ +//go:build !windows + +package retrysched + +import "testing" + +func TestContextOverflowProviderPredicate(t *testing.T) { + // A hard provider failure is an overflow by message, by status, or by + // the response-body error code. + status400 := float64(400) + status413 := float64(413) + for _, test := range []struct { + name string + message string + status *float64 + body *string + want bool + }{ + {name: "message signature", message: "maximum context length is 128000 tokens", status: &status400, want: true}, + {name: "entity too large status", message: "payload rejected", status: &status413, want: true}, + {name: "response code", message: "bad request", status: &status400, body: stringAddress(`{"error":{"code":"context_length_exceeded"}}`), want: true}, + {name: "ordinary bad request", message: "invalid schema", status: &status400, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + retryable := false + err := Err{Name: "APIError", Data: ErrData{ + Message: &test.message, StatusCode: test.status, + IsRetryable: &retryable, ResponseBody: test.body, + }} + if got := IsContextOverflow(err); got != test.want { + t.Fatalf("IsContextOverflow(%+v) = %v, want %v", err, got, test.want) + } + }) + } +} + +func stringAddress(value string) *string { return &value } diff --git a/internal/seniordev/engine/retrysched/errors.go b/internal/seniordev/engine/retrysched/errors.go new file mode 100644 index 000000000..a594bc6f6 --- /dev/null +++ b/internal/seniordev/engine/retrysched/errors.go @@ -0,0 +1,127 @@ +//go:build !windows + +package retrysched + +import ( + "encoding/json" + "errors" + "regexp" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// timeoutMessageRE matches the timeout-shaped messages a transport or a +// provider produces. +var timeoutMessageRE = regexp.MustCompile(`(?i)timeout|timed out|deadline exceeded`) + +// StatusError is the error the OpenRouter path returns for a failed HTTP +// response: a message plus the status, headers and body the response carried. +// It exposes the status, name, body and cause through the small interfaces +// the adaptive router's classifiers probe. +type StatusError struct { + Message string + Name string + Status *float64 + Cause error + ResponseHeaders map[string]string + ResponseBody *string +} + +func (e *StatusError) Error() string { return e.Message } + +// ErrorName is the symbolic error name; it defaults to "Error". +func (e *StatusError) ErrorName() string { + if e.Name == "" { + return "Error" + } + return e.Name +} + +// ErrorStatusCode reports the HTTP status, when the error carries one. +func (e *StatusError) ErrorStatusCode() (float64, bool) { + if e.Status == nil { + return 0, false + } + return *e.Status, true +} + +// ErrorDetail is the response body, so classifiers can match provider +// messages that only appear there. +func (e *StatusError) ErrorDetail() string { + if e.ResponseBody == nil { + return "" + } + return *e.ResponseBody +} + +func (e *StatusError) Unwrap() error { return e.Cause } + +// NewProviderError builds the StatusError for a failed HTTP response. +func NewProviderError(message string, status float64, headers map[string]string, body *string) *StatusError { + return &StatusError{ + Message: message, Status: &status, + ResponseHeaders: headers, ResponseBody: body, + } +} + +// RetryError projects the failure into the classified Err shape: an APIError, +// or a ContextOverflowError when the message or body says the prompt did not +// fit. +func (e *StatusError) RetryError() Err { + if e.Status == nil { + // Without an HTTP status this is not a provider API error; keep the + // symbolic name (AbortError, for one) the step loop classifies by. + return Err{Name: e.ErrorName(), Data: ErrData{Message: &e.Message, ResponseBody: e.ResponseBody}} + } + status := *e.Status + retryable := status == 408 || status == 409 || status == 429 || status >= 500 + result := Err{Name: "APIError", Data: ErrData{ + Message: &e.Message, StatusCode: e.Status, IsRetryable: &retryable, + ResponseHeaders: e.ResponseHeaders, ResponseBody: e.ResponseBody, + }} + if IsContextOverflow(result) { + return Err{Name: "ContextOverflowError", Data: ErrData{ + Message: &e.Message, ResponseBody: e.ResponseBody, + }} + } + return result +} + +// FromError classifies a Go error into the Err shape. +func FromError(err error) Err { + if err == nil { + return Err{} + } + var classified interface{ RetryError() Err } + if errors.As(err, &classified) { + return classified.RetryError() + } + name := "UnknownError" + var named interface{ ErrorName() string } + if errors.As(err, &named) && named.ErrorName() != "" { + name = named.ErrorName() + } + message := err.Error() + return Err{Name: name, Data: ErrData{Message: &message}} +} + +// FromStreamError classifies an in-band error payload from a model stream. +func FromStreamError(raw json.RawMessage) Err { + parsed := msgmodel.FromError(raw) + var data ErrData + _ = json.Unmarshal(parsed.Data, &data) + return Err{Name: parsed.Name, Data: data} +} + +// HeaderPairs flattens response headers into a lowercase-keyed map. +func HeaderPairs(headers map[string][]string) map[string]string { + if headers == nil { + return nil + } + out := make(map[string]string, len(headers)) + for key, values := range headers { + out[strings.ToLower(key)] = strings.Join(values, ", ") + } + return out +} diff --git a/internal/seniordev/engine/retrysched/errors_test.go b/internal/seniordev/engine/retrysched/errors_test.go new file mode 100644 index 000000000..ce7431c62 --- /dev/null +++ b/internal/seniordev/engine/retrysched/errors_test.go @@ -0,0 +1,60 @@ +//go:build !windows + +package retrysched + +import ( + "errors" + "fmt" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestStatusErrorClassifiesThroughTheRouter(t *testing.T) { + body := `{"error":{"message":"Provider returned error","error_type":"unmapped"}}` + err := NewProviderError("429 Too Many Requests", 429, HeaderPairs(map[string][]string{"Retry-After": {"7"}}), &body) + if !adaptive.IsLikelyRateLimit(err) { + t.Error("a 429 must classify as a rate limit") + } + if !adaptive.IsLikelyTransientProviderError(err) { + t.Error("the response body must be searched by the classifiers") + } + wrapped := fmt.Errorf("stream failed: %w", err) + if !adaptive.IsLikelyRateLimit(wrapped) { + t.Error("wrapping must not hide the status") + } + if err.ResponseHeaders["retry-after"] != "7" { + t.Errorf("headers = %v", err.ResponseHeaders) + } +} + +func TestFromErrorProjectsStatusAndOverflow(t *testing.T) { + classified := FromError(NewProviderError("Bad Gateway", 502, nil, nil)) + if !classified.IsAPIError() || classified.Data.StatusCode == nil || *classified.Data.StatusCode != 502 { + t.Fatalf("classified = %+v", classified) + } + if classified.Data.IsRetryable == nil || !*classified.Data.IsRetryable { + t.Fatalf("a 502 is retryable: %+v", classified) + } + overflow := FromError(NewProviderError("prompt is too long: 300000 tokens", 400, nil, nil)) + if overflow.Name != "ContextOverflowError" { + t.Fatalf("overflow = %+v", overflow) + } + plain := FromError(errors.New("dial tcp: connection refused")) + if plain.Name != "UnknownError" || plain.Data.Message == nil || *plain.Data.Message != "dial tcp: connection refused" { + t.Fatalf("plain = %+v", plain) + } + if zero := FromError(nil); zero.Name != "" || zero.Data.Message != nil { + t.Fatalf("nil classifies to the zero Err, got %+v", zero) + } +} + +func TestNamedErrorsKeepTheirName(t *testing.T) { + abort := &StatusError{Message: "aborted", Name: "AbortError"} + if !IsTimeoutError(FromError(abort)) { + t.Error("an AbortError is a timeout for the step loop") + } + if !adaptive.IsLikelyTimeout(abort) { + t.Error("an AbortError is a timeout for the router") + } +} diff --git a/internal/seniordev/engine/retrysched/retrysched.go b/internal/seniordev/engine/retrysched/retrysched.go new file mode 100644 index 000000000..74ea4aa72 --- /dev/null +++ b/internal/seniordev/engine/retrysched/retrysched.go @@ -0,0 +1,41 @@ +//go:build !windows + +// Package retrysched classifies the errors a model call can end in: the +// parsed Err shape, the timeout and context-overflow predicates the step loop +// consults, and the StatusError a failed provider response becomes. senior-dev +// issues one request per model call; nothing here schedules or re-issues a +// request. +package retrysched + +// Err is a classified error: a name plus the payload fields the predicates +// probe. +type Err struct { + Name string `json:"name"` + Data ErrData `json:"data"` +} + +// ErrData models the error payload for the keys the predicates probe. Every +// field is a pointer because a provider error can omit any of them, including +// the message. +type ErrData struct { + Message *string `json:"message,omitempty"` + StatusCode *float64 `json:"statusCode,omitempty"` + IsRetryable *bool `json:"isRetryable,omitempty"` + ResponseHeaders map[string]string `json:"responseHeaders,omitempty"` + ResponseBody *string `json:"responseBody,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// IsAPIError reports whether the classified error is a provider API error. +func (e Err) IsAPIError() bool { return e.Name == "APIError" } + +// IsTimeoutError reports whether the classified error is timeout-shaped: an +// AbortError by name, or a timeout-shaped message. A missing message is not a +// match. +func IsTimeoutError(err Err) bool { + if err.Name == "AbortError" { + return true + } + msg := err.Data.Message + return msg != nil && timeoutMessageRE.MatchString(*msg) +} diff --git a/internal/seniordev/engine/retrysched/retrysched_test.go b/internal/seniordev/engine/retrysched/retrysched_test.go new file mode 100644 index 000000000..48c100dfc --- /dev/null +++ b/internal/seniordev/engine/retrysched/retrysched_test.go @@ -0,0 +1,60 @@ +//go:build !windows + +package retrysched + +import "testing" + +// apiError builds the wire-shape APIError (name + data) a provider failure +// classifies to. +func apiError(message string) Err { + isRetryable := false + return Err{Name: "APIError", Data: ErrData{Message: &message, IsRetryable: &isRetryable}} +} + +func TestIsTimeoutError(t *testing.T) { + t.Run("matches AbortError by name", func(t *testing.T) { + if got := IsTimeoutError(Err{Name: "AbortError"}); got != true { + t.Errorf("IsTimeoutError = %v, want true", got) + } + }) + + t.Run("matches timeout-shaped messages", func(t *testing.T) { + for _, message := range []string{ + "openrouter first-content timeout after 45000ms", + "openrouter content-idle timeout: no data: chunks", + "request timed out", + "context deadline exceeded", + } { + if got := IsTimeoutError(apiError(message)); got != true { + t.Errorf("IsTimeoutError(%q) = %v, want true", message, got) + } + } + }) + + t.Run("does not match rate-limit or generic errors", func(t *testing.T) { + if got := IsTimeoutError(apiError("rate limit exceeded")); got != false { + t.Errorf("IsTimeoutError(rate limit) = %v, want false", got) + } + if got := IsTimeoutError(apiError("Internal Server Error")); got != false { + t.Errorf("IsTimeoutError(500) = %v, want false", got) + } + }) + + t.Run("an absent message is not a timeout", func(t *testing.T) { + retryable := true + if IsTimeoutError(Err{Name: "APIError", Data: ErrData{IsRetryable: &retryable}}) { + t.Error("IsTimeoutError with absent message = true, want false") + } + }) +} + +func TestFromStreamErrorReadsTheInBandPayload(t *testing.T) { + classified := FromStreamError([]byte( + `{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}}`)) + if classified.Data.Message == nil || *classified.Data.Message == "" { + t.Fatalf("in-band error lost its message: %#v", classified) + } + if IsContextOverflow(classified) { + t.Fatalf("a 502 classified as a context overflow: %#v", classified) + } +} diff --git a/internal/seniordev/engine/steploop/compacted_after_test.go b/internal/seniordev/engine/steploop/compacted_after_test.go new file mode 100644 index 000000000..7b9cb9c55 --- /dev/null +++ b/internal/seniordev/engine/steploop/compacted_after_test.go @@ -0,0 +1,49 @@ +//go:build !windows + +package steploop + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +func finishedAssistant(id string, summary bool, failed bool) msgmodel.WithParts { + finish := "stop" + info := msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses"}, + Finish: &finish, + } + if summary { + flag := true + info.Summary = &flag + } + if failed { + converted := msgmodel.NewUnknownError("boom") + info.Error = &converted + } + return msgmodel.WithParts{Info: info} +} + +// The verbatim tail keeps the assistant whose token count triggered the +// compaction, and the projection places it after the summary. Its count must +// not trigger a second boundary. +func TestCompactedAfterSeesANewerCompletedSummary(t *testing.T) { + tail := finishedAssistant("msg_0001", false, false) + summary := finishedAssistant("msg_0002", true, false) + failedSummary := finishedAssistant("msg_0003", true, true) + projection := []msgmodel.WithParts{summary, tail} + if !CompactedAfter(projection, tail.Info.(msgmodel.Assistant)) { + t.Fatal("a newer completed summary was not detected") + } + if CompactedAfter([]msgmodel.WithParts{tail}, tail.Info.(msgmodel.Assistant)) { + t.Fatal("no summary at all was reported as compacted-after") + } + if CompactedAfter([]msgmodel.WithParts{failedSummary, tail}, tail.Info.(msgmodel.Assistant)) { + t.Fatal("an errored summary attempt counted as a boundary") + } + older := finishedAssistant("msg_0000", true, false) + if CompactedAfter([]msgmodel.WithParts{older, tail}, tail.Info.(msgmodel.Assistant)) { + t.Fatal("an older summary counted as newer") + } +} diff --git a/internal/seniordev/engine/steploop/doc.go b/internal/seniordev/engine/steploop/doc.go new file mode 100644 index 000000000..e933fa4da --- /dev/null +++ b/internal/seniordev/engine/steploop/doc.go @@ -0,0 +1,32 @@ +//go:build !windows + +// Package steploop drives one turn of the agent. Run loads the persisted +// transcript, issues one model request per step, settles the tool calls the +// model made, and repeats until the model stops calling tools, the step cap is +// reached, or the processor asks to stop. When the transcript records a +// pending compaction, the step is handed to the TaskController instead. +// +// Seams: +// +// - Store is the message persistence service. Messages must return fresh, +// chronological values; the loop rebuilds its view from them every step. +// - LLMClient/PartStream are the narrow model-client seam; scripted tests +// return an in-memory stream. +// - ToolExecutor executes one already-resolved tool call. Tool discovery, +// permission checks and the concrete tools stay outside this package and +// arrive in RunOptions.Tools. +// - ModelResolver names the model for the latest user message. +// - TaskController owns compaction: overflow detection, boundary creation, +// processing and pruning. +// - The clock and the ascending ID factories are package seams with +// SetNowForTesting and SetIDFactoryForTesting restore closures. +// +// Behaviour worth knowing: +// +// - The natural exit is: a non-empty finish other than "tool-calls", no +// non-provider-executed tool part on the matching persisted assistant, and +// the last user message older than the last assistant. +// - Tool cleanup snapshots all outstanding calls, waits for each with an +// independent short timeout, then force-writes every survivor as +// "Tool execution aborted". +package steploop diff --git a/internal/seniordev/engine/steploop/helpers.go b/internal/seniordev/engine/steploop/helpers.go new file mode 100644 index 000000000..2831c4bac --- /dev/null +++ b/internal/seniordev/engine/steploop/helpers.go @@ -0,0 +1,164 @@ +//go:build !windows + +package steploop + +import ( + "strings" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// BackScanResult is what a newest-first scan of the transcript finds. +type BackScanResult struct { + LastUser *msgmodel.User + LastAssistant *msgmodel.Assistant + LastFinished *msgmodel.Assistant + Tasks []msgmodel.Part +} + +// BackScan scans chronological filtered messages from newest to oldest. +func BackScan(msgs []msgmodel.WithParts) BackScanResult { + var out BackScanResult + for i := len(msgs) - 1; i >= 0; i-- { + msg := msgs[i] + switch info := msg.Info.(type) { + case msgmodel.User: + if out.LastUser == nil { + copy := info + out.LastUser = © + } + case msgmodel.Assistant: + if out.LastAssistant == nil { + copy := info + out.LastAssistant = © + } + if out.LastFinished == nil && info.Finish != nil && *info.Finish != "" { + copy := info + out.LastFinished = © + } + } + if out.LastUser != nil && out.LastFinished != nil { + break + } + if out.LastFinished == nil { + for _, part := range msg.Parts { + switch part.(type) { + case msgmodel.CompactionPart: + out.Tasks = append(out.Tasks, part) + } + } + } + } + return out +} + +// CompactedAfter reports whether a completed compaction summary is newer (by +// ascending message ID) than the given assistant. The token count recorded on +// that assistant is then stale: the boundary has already replaced the context +// it measured. Re-checking it would create a boundary loop, because the +// verbatim tail keeps that very message -- and its count -- after every +// compaction, and the projection places the tail after the summary, where a +// newest-first scan finds it first. +func CompactedAfter(msgs []msgmodel.WithParts, assistant msgmodel.Assistant) bool { + for _, msg := range msgs { + candidate, ok := msg.Info.(msgmodel.Assistant) + if !ok || !boolValue(candidate.Summary) || candidate.Error != nil || + candidate.Finish == nil || *candidate.Finish == "" { + continue + } + if idLess(assistant.ID, candidate.ID) { + return true + } + } + return false +} + +// ShouldExit reports the natural exit: the last assistant finished for a +// reason other than tool calls, none of its persisted tool parts is pending +// on this side (provider-executed tool parts do not count), and the last user +// message is older than it. +func ShouldExit(lastUser *msgmodel.User, lastAssistant *msgmodel.Assistant, msgs []msgmodel.WithParts) bool { + if lastUser == nil || lastAssistant == nil || lastAssistant.Finish == nil || *lastAssistant.Finish == "" { + return false + } + if *lastAssistant.Finish == orFinishToolCalls { + return false + } + + var persisted *msgmodel.WithParts + for i := len(msgs) - 1; i >= 0; i-- { + assistant, ok := msgs[i].Info.(msgmodel.Assistant) + if ok && assistant.ID == lastAssistant.ID { + persisted = &msgs[i] + break + } + } + if persisted != nil { + for _, raw := range persisted.Parts { + part, ok := raw.(msgmodel.ToolPart) + if ok && !part.ProviderExecuted() { + return false + } + } + } + return idLess(lastUser.ID, lastAssistant.ID) +} + +const orFinishToolCalls = "tool-calls" + +// idLess orders two message IDs lexicographically by UTF-16 code unit. IDs +// are ASCII in practice, so this is plain lexicographic order; the UTF-16 +// form keeps the comparison well-defined for any string. +func idLess(left, right string) bool { + a := utf16.Encode([]rune(left)) + b := utf16.Encode([]rune(right)) + n := len(a) + if len(b) < n { + n = len(b) + } + for i := 0; i < n; i++ { + if a[i] != b[i] { + return a[i] < b[i] + } + } + return len(a) < len(b) +} + +// WrapLateUserText wraps the text of any user message that arrived after the +// last finished assistant in a system reminder, so the model treats it as an +// interjection. The input must be a fresh store load because this mutates +// text-part values in place. +func WrapLateUserText(msgs []msgmodel.WithParts, lastFinished msgmodel.Assistant) { + for mi := range msgs { + user, ok := msgs[mi].Info.(msgmodel.User) + if !ok || !idLess(lastFinished.ID, user.ID) { + continue + } + for partIdx, raw := range msgs[mi].Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolValue(part.Ignored) || boolValue(part.Synthetic) || strings.TrimSpace(part.Text) == "" { + continue + } + part.Text = strings.Join([]string{ + "", + "The user sent the following message:", + part.Text, + "", + "Please address this message and continue with your tasks.", + "", + }, "\n") + msgs[mi].Parts[partIdx] = part + } + } +} + +func boolValue(v *bool) bool { return v != nil && *v } + +func newestFirst(msgs []msgmodel.WithParts) []msgmodel.WithParts { + out := append([]msgmodel.WithParts(nil), msgs...) + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} diff --git a/internal/seniordev/engine/steploop/helpers_test.go b/internal/seniordev/engine/steploop/helpers_test.go new file mode 100644 index 000000000..c94ee2064 --- /dev/null +++ b/internal/seniordev/engine/steploop/helpers_test.go @@ -0,0 +1,20 @@ +//go:build !windows + +package steploop + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Shared helpers for the tests in this package. + +func jsonString(t *testing.T, value any) string { + t.Helper() + raw, err := jsonutil.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/internal/seniordev/engine/steploop/invalid_tool_test.go b/internal/seniordev/engine/steploop/invalid_tool_test.go new file mode 100644 index 000000000..e698ed887 --- /dev/null +++ b/internal/seniordev/engine/steploop/invalid_tool_test.go @@ -0,0 +1,118 @@ +//go:build !windows + +package steploop + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" +) + +type releasedTool struct { + started chan<- struct{} + release <-chan struct{} +} + +func (tool releasedTool) Execute(_ context.Context, _ ToolCall) (ToolResult, error) { + close(tool.started) + <-tool.release + return ToolResult{Title: "done", Output: "answered", Metadata: msgmodel.RawObject("{}")}, nil +} + +func TestWaitForResultDefinitionKeepsProcessorStreamOpen(t *testing.T) { + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "coder", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + started := make(chan struct{}) + release := make(chan struct{}) + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, + Tools: []ToolDefinition{{ + Provider: orclient.Tool{Type: "function", Name: "question"}, + WaitForResult: true, + }}, + Executor: releasedTool{started: started, release: release}, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "call_1", ToolName: "question"}, + orclient.ToolCallPart{ToolCallID: "call_1", ToolName: "question", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + done := make(chan error, 1) + go func() { + _, err := processor.Process(context.Background(), stream) + done <- err + }() + <-started + select { + case err := <-done: + t.Fatalf("processor finished while result-blocking tool was pending: %v", err) + case <-time.After(20 * time.Millisecond): + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + parts := store.rawSnapshot()[0].Parts + toolPart := parts[1].(msgmodel.ToolPart) + if toolPart.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("tool state = %s", jsonString(t, toolPart.State)) + } +} + +func TestUnknownToolCompletesAsInvalidAndContinues(t *testing.T) { + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "coder", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + processor := NewProcessor(ProcessorOptions{ + Store: store, + Assistant: assistant, + Tools: []ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "read", + }}}, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "call_1", ToolName: "missing"}, + orclient.ToolCallPart{ToolCallID: "call_1", ToolName: "missing", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + + result, err := processor.Process(context.Background(), stream) + if err != nil { + t.Fatal(err) + } + if result != ResultContinue { + t.Fatalf("Process result = %q, want %q", result, ResultContinue) + } + + parts := store.rawSnapshot()[0].Parts + assertPartTypes(t, parts, "step-start,tool,step-finish") + toolPart := parts[1].(msgmodel.ToolPart) + if toolPart.Tool != orclient.InvalidToolName || toolPart.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("unknown tool state = %#v", toolPart) + } + state := jsonString(t, toolPart.State) + wantOutput := "The arguments provided to the tool are invalid: " + + "Model tried to call unavailable tool 'missing'. Available tools: invalid, read." + for _, fragment := range []string{ + `"title":"Invalid Tool"`, + `"metadata":{}`, + `"output":` + jsonString(t, wantOutput), + } { + if !strings.Contains(state, fragment) { + t.Fatalf("invalid state %s missing %s", state, fragment) + } + } +} diff --git a/internal/seniordev/engine/steploop/loop.go b/internal/seniordev/engine/steploop/loop.go new file mode 100644 index 000000000..7e8c5a3da --- /dev/null +++ b/internal/seniordev/engine/steploop/loop.go @@ -0,0 +1,232 @@ +//go:build !windows + +package steploop + +import ( + "context" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// Loop wires the outer state machine's service seams. +type Loop struct { + Store Store + Client LLMClient + Models ModelResolver + Executor ToolExecutor + Tasks TaskController + + // ProcessorWaitTimeout bounds the tool drain after an aborted stream. + // Zero means 250ms. + ProcessorWaitTimeout time.Duration +} + +// Run drives the step loop until its natural exit or a processor stop. +func (l *Loop) Run(ctx context.Context, opts RunOptions) (msgmodel.Assistant, error) { + if l == nil || l.Store == nil { + return msgmodel.Assistant{}, errors.New("steploop: Store is required") + } + if l.Client == nil { + return msgmodel.Assistant{}, errors.New("steploop: Client is required") + } + if l.Models == nil { + return msgmodel.Assistant{}, errors.New("steploop: Models is required") + } + models := l.Models + + step := 0 +loop: + for { + chronological, err := l.Store.Messages(ctx, opts.SessionID) + if err != nil { + return msgmodel.Assistant{}, err + } + msgs := msgmodel.FilterCompacted(newestFirst(chronological)) + scan := BackScan(msgs) + if scan.LastUser == nil { + return msgmodel.Assistant{}, errors.New("No user message found in stream. This should never happen.") + } + + if ShouldExit(scan.LastUser, scan.LastAssistant, msgs) { + break + } + + step++ + model, err := models.Resolve(ctx, *scan.LastUser) + if err != nil { + return msgmodel.Assistant{}, err + } + taskInput := TaskInput{SessionID: opts.SessionID, Messages: msgs, User: *scan.LastUser, Model: model} + if len(scan.Tasks) > 0 { + task := scan.Tasks[len(scan.Tasks)-1] // tasks.pop() + switch value := task.(type) { + case msgmodel.CompactionPart: + if l.Tasks == nil { + return msgmodel.Assistant{}, errors.New("steploop: TaskController is required for compaction") + } + result, err := l.Tasks.ProcessCompaction(ctx, taskInput, value) + if err != nil { + return msgmodel.Assistant{}, err + } + if result == ResultStop { + break loop + } + continue + } + } + if scan.LastFinished != nil && !boolValue(scan.LastFinished.Summary) && + !CompactedAfter(msgs, *scan.LastFinished) && l.Tasks != nil { + overflow, err := l.Tasks.IsOverflow(ctx, *scan.LastFinished, model) + if err != nil { + return msgmodel.Assistant{}, err + } + if overflow { + if err := l.Tasks.CreateCompaction(ctx, opts.SessionID, *scan.LastUser, false); err != nil { + return msgmodel.Assistant{}, err + } + continue + } + } + if opts.InjectReminders != nil { + msgs, err = opts.InjectReminders(ctx, msgs, *scan.LastUser) + if err != nil { + return msgmodel.Assistant{}, err + } + } + if step > 1 && scan.LastFinished != nil { + WrapLateUserText(msgs, *scan.LastFinished) + } + + assistant := newAssistant(opts, *scan.LastUser, model) + if err := l.Store.UpdateMessage(ctx, assistant); err != nil { + return msgmodel.Assistant{}, err + } + + prompt, err := msgmodel.ToModelMessages(msgs, model.Message, nil) + if err != nil { + return msgmodel.Assistant{}, err + } + if float64(step) >= opts.maxSteps() { + prompt = append(prompt, msgmodel.ModelMessage{Role: "assistant", Content: MaxStepsPrompt}) + } + + params := model.Request + params.ModelID = model.Message.ID + params.Prompt = prompt + if params.MaxOutputTokens == nil { + maxOutput := calc.MaxOutputTokens(model.Calc) + params.MaxOutputTokens = &maxOutput + } + params.Tools = nil + for _, tool := range opts.Tools { + params.Tools = append(params.Tools, tool.Provider) + } + + processor := NewProcessor(ProcessorOptions{ + Store: l.Store, + Assistant: assistant, + Model: model, + Tools: opts.Tools, + Executor: l.Executor, + WaitTimeout: l.ProcessorWaitTimeout, + }) + outcome, processErr := func() (Result, error) { + if opts.AfterAssistant != nil { + defer opts.AfterAssistant(ctx, assistant.ID) + } + stream, streamErr := l.Client.Stream(ctx, params) + if streamErr != nil { + stream = &SliceStream{Failure: streamErr} + } + return processor.Process(ctx, stream) + }() + if opts.AfterTurn != nil { + parts, partsErr := assistantParts(ctx, l.Store, opts.SessionID, assistant.ID) + if partsErr != nil { + return msgmodel.Assistant{}, partsErr + } + if hookErr := opts.AfterTurn(ctx, processor.Message(), parts); hookErr != nil { + return msgmodel.Assistant{}, hookErr + } + } + if processErr != nil { + return msgmodel.Assistant{}, processErr + } + if outcome == ResultStop { + break + } + if outcome == ResultContinue && l.Tasks != nil { + current := processor.Message() + if current.Finish != nil && !boolValue(current.Summary) { + overflow, overflowErr := l.Tasks.IsOverflow(ctx, current, model) + if overflowErr != nil { + return msgmodel.Assistant{}, overflowErr + } + if overflow { + outcome = ResultCompact + } + } + } + if outcome == ResultCompact && l.Tasks != nil { + current := processor.Message() + if err := l.Tasks.CreateCompaction(ctx, opts.SessionID, *scan.LastUser, current.Finish == nil); err != nil { + return msgmodel.Assistant{}, err + } + } + } + + if l.Tasks != nil { + // Pruning runs in the background; its result is not awaited. + go func(tasks TaskController) { + _ = tasks.Prune(ctx, opts.SessionID) + }(l.Tasks) + } + chronological, err := l.Store.Messages(ctx, opts.SessionID) + if err != nil { + return msgmodel.Assistant{}, err + } + for i := len(chronological) - 1; i >= 0; i-- { + if assistant, ok := chronological[i].Info.(msgmodel.Assistant); ok { + return assistant, nil + } + } + return msgmodel.Assistant{}, errors.New("Impossible") +} + +func assistantParts( + ctx context.Context, store Store, sessionID, messageID string, +) (msgmodel.Parts, error) { + messages, err := store.Messages(ctx, sessionID) + if err != nil { + return nil, err + } + for index := len(messages) - 1; index >= 0; index-- { + assistant, ok := messages[index].Info.(msgmodel.Assistant) + if ok && assistant.ID == messageID { + return messages[index].Parts, nil + } + } + return nil, nil +} + +func newAssistant(opts RunOptions, user msgmodel.User, model Model) msgmodel.Assistant { + return msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: nextID("msg"), SessionID: opts.SessionID}, + Time: msgmodel.AssistantTime{Created: currentNow()}, + ParentID: user.ID, + ModelID: model.Message.ID, + ProviderID: model.Message.ProviderID, + Mode: user.Agent, + Agent: user.Agent, + Path: msgmodel.AssistantPath{Cwd: opts.Workspace, Root: opts.Worktree}, + Cost: float64(0), + Tokens: msgmodel.Tokens{ + Input: 0, Output: 0, Reasoning: 0, + Cache: msgmodel.TokenCache{Read: 0, Write: 0}, + }, + Variant: user.Model.Variant, + } +} diff --git a/internal/seniordev/engine/steploop/processor.go b/internal/seniordev/engine/steploop/processor.go new file mode 100644 index 000000000..94ab97306 --- /dev/null +++ b/internal/seniordev/engine/steploop/processor.go @@ -0,0 +1,779 @@ +//go:build !windows + +package steploop + +import ( + "context" + "encoding/json" + "errors" + "io" + "math" + "sync" + "time" + + "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/retrysched" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// ProcessorOptions configure the processor for one assistant turn. +type ProcessorOptions struct { + Store Store + Assistant msgmodel.Assistant + Model Model + Tools []ToolDefinition + Executor ToolExecutor + WaitTimeout time.Duration +} + +type toolCallState struct { + part msgmodel.ToolPart + done chan struct{} + once sync.Once + // dispatched marks that an executor was actually launched for this call. + // A registration whose tool-call part never arrived (length-truncated or + // errored stream) has no executor and can never settle itself, so cleanup + // must keep the bounded drain for those. + dispatched bool +} + +// Processor persists one assistant turn and owns its in-flight tool registry. +type Processor struct { + store Store + message msgmodel.Assistant + model Model + tools []ToolDefinition + executor ToolExecutor + wait time.Duration + sessionID string + + mu sync.Mutex + toolCalls map[string]*toolCallState + toolOrder []string + current *msgmodel.TextPart + reasoning map[string]msgmodel.ReasoningPart + reasoningOrder []string + streamAborted bool +} + +func NewProcessor(opts ProcessorOptions) *Processor { + wait := opts.WaitTimeout + if wait <= 0 { + wait = 250 * time.Millisecond + } + return &Processor{ + store: opts.Store, + message: opts.Assistant, + model: opts.Model, + tools: append([]ToolDefinition(nil), opts.Tools...), + executor: opts.Executor, + wait: wait, + sessionID: opts.Assistant.SessionID, + toolCalls: map[string]*toolCallState{}, + reasoning: map[string]msgmodel.ReasoningPart{}, + } +} + +// Message returns a copy of the current assistant message. +func (p *Processor) Message() msgmodel.Assistant { + p.mu.Lock() + defer p.mu.Unlock() + return p.message +} + +// UpdateToolCall rewrites and persists one in-flight tool part. The callback +// runs while the processor lock is held. +func (p *Processor) UpdateToolCall(ctx context.Context, toolCallID string, update func(msgmodel.ToolPart) msgmodel.ToolPart) (*msgmodel.ToolPart, error) { + p.mu.Lock() + defer p.mu.Unlock() + call := p.toolCalls[toolCallID] + if call == nil { + return nil, nil + } + part := update(call.part) + if err := p.store.UpdatePart(ctx, part); err != nil { + return nil, err + } + call.part = part + copy := part + return ©, nil +} + +// CompleteToolCall persists a tool result and settles the call. A +// non-running or missing call is a no-op and is not settled. +func (p *Processor) CompleteToolCall(ctx context.Context, toolCallID string, output ToolResult) error { + p.mu.Lock() + defer p.mu.Unlock() + call := p.toolCalls[toolCallID] + if call == nil || call.part.State == nil || call.part.State.ToolStatus() != msgmodel.ToolStatusRunning { + return nil + } + // Settle on every exit past the guard: execute() discards these errors, + // and an unsettled dispatched call would hang cleanup's unbounded wait. + defer p.settleLocked(toolCallID) + end := currentNow() + start, _ := call.part.State.StartTime() + metadata := output.Metadata + if !msgmodel.IsRecord(metadata) { + metadata = msgmodel.RawObject("{}") + } + state, err := completedState(call.part.State.ToolInput(), output, metadata, start, end) + if err != nil { + return err + } + call.part.State = state + return p.store.UpdatePart(ctx, call.part) +} + +// FailToolCall persists a tool failure and settles the call. A non-running or +// missing call is a no-op. +func (p *Processor) FailToolCall(ctx context.Context, toolCallID string, failure error) (bool, error) { + p.mu.Lock() + defer p.mu.Unlock() + call := p.toolCalls[toolCallID] + if call == nil || call.part.State == nil || call.part.State.ToolStatus() != msgmodel.ToolStatusRunning { + return false, nil + } + // Settle on every exit past the guard — see CompleteToolCall. + defer p.settleLocked(toolCallID) + message := "null" + if failure != nil { + message = failure.Error() + } + end := currentNow() + start, _ := call.part.State.StartTime() + state, err := errorState(call.part.State.ToolInput(), message, start, end) + if err != nil { + return false, err + } + call.part.State = state + if err := p.store.UpdatePart(ctx, call.part); err != nil { + return false, err + } + return true, nil +} + +func (p *Processor) settleLocked(toolCallID string) { + call := p.toolCalls[toolCallID] + delete(p.toolCalls, toolCallID) // unregister before waking the waiters. + if call != nil { + call.once.Do(func() { close(call.done) }) + } +} + +// Process drains one provider stream, synthesizing start-step/finish-step and +// tool-result/error events around orclient's lower-level parts. +func (p *Processor) Process(ctx context.Context, stream PartStream) (result Result, err error) { + result = ResultContinue + p.mu.Lock() + p.streamAborted = true + p.mu.Unlock() + if stream == nil { + return ResultStop, errors.New("steploop: nil stream") + } + defer stream.Close() + defer func() { + cleanupErr := p.cleanup(ctx) + if err == nil && cleanupErr != nil { + err = cleanupErr + } + }() + + if err = p.persistPart(ctx, msgmodel.StepStartPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + }); err != nil { + return ResultStop, err + } + + for { + var part orclient.StreamPart + part, err = stream.Next() + if err == io.EOF { + p.mu.Lock() + p.streamAborted = false + p.mu.Unlock() + return result, nil + } + if err != nil { + classified := retrysched.FromError(err) + if retrysched.IsContextOverflow(classified) { + return ResultCompact, nil + } + p.setAssistantError(classified, err.Error()) + return ResultStop, nil + } + switch value := part.(type) { + case orclient.ReasoningStartPart: + err = p.reasoningStart(ctx, value) + case orclient.ReasoningDeltaPart: + err = p.reasoningDelta(ctx, value) + case orclient.ReasoningEndPart: + err = p.reasoningEnd(ctx, value) + case orclient.TextStartPart: + err = p.textStart(ctx) + case orclient.TextDeltaPart: + err = p.textDelta(ctx, value) + case orclient.TextEndPart: + err = p.textEnd(ctx) + case orclient.ToolInputStartPart: + err = p.toolInputStart(ctx, value) + case orclient.ToolCallPart: + err = p.toolCall(ctx, value) + case orclient.ErrorPart: + classified := retrysched.FromStreamError(value.Error) + if retrysched.IsContextOverflow(classified) { + return ResultCompact, nil + } + p.setAssistantError(classified, errorPartMessage(value.Error)) + return ResultStop, nil + case orclient.AbortPart: + message := "Aborted" + if value.HasReason && value.Reason != "" { + message = value.Reason + } + p.mu.Lock() + abort := msgmodel.NewMessageAbortedError(message) + p.message.Error = &abort + p.mu.Unlock() + return ResultStop, nil + case orclient.FinishPart: + err = p.finish(ctx, value) + } + if err != nil { + return ResultStop, err + } + } +} + +func (p *Processor) persistPart(ctx context.Context, part msgmodel.Part) error { + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) reasoningStart(ctx context.Context, value orclient.ReasoningStartPart) error { + p.mu.Lock() + defer p.mu.Unlock() + if _, exists := p.reasoning[value.ID]; exists { + return nil + } + part := msgmodel.ReasoningPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + Text: "", + Time: msgmodel.TimeStartEnd{Start: currentNow()}, + } + p.reasoning[value.ID] = part + p.reasoningOrder = append(p.reasoningOrder, value.ID) + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) reasoningDelta(ctx context.Context, value orclient.ReasoningDeltaPart) error { + p.mu.Lock() + defer p.mu.Unlock() + part, exists := p.reasoning[value.ID] + if !exists { + return nil + } + part.Text += value.Delta + p.reasoning[value.ID] = part + p.publishPartDelta(ctx, part.PartBase, value.Delta) + return nil +} + +func (p *Processor) reasoningEnd(ctx context.Context, value orclient.ReasoningEndPart) error { + p.mu.Lock() + defer p.mu.Unlock() + part, exists := p.reasoning[value.ID] + if !exists { + return nil + } + end := currentNow() + part.Time.End = &end + part.Metadata = reasoningMetadata(value.Details) + delete(p.reasoning, value.ID) + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) textStart(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + part := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + Text: "", + Time: &msgmodel.TimeStartEnd{Start: currentNow()}, + } + p.current = &part + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) textDelta(ctx context.Context, value orclient.TextDeltaPart) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.current == nil { + return nil + } + p.current.Text += value.Delta + p.publishPartDelta(ctx, p.current.PartBase, value.Delta) + return nil +} + +func (p *Processor) publishPartDelta(ctx context.Context, base msgmodel.PartBase, delta string) { + store, ok := p.store.(interface { + UpdatePartDelta(context.Context, msgmodel.PartDeltaEvent) + }) + if !ok { + return + } + store.UpdatePartDelta(ctx, msgmodel.PartDeltaEvent{ + SessionID: base.SessionID, + MessageID: base.MessageID, + PartID: base.ID, + Field: "text", + Delta: delta, + }) +} + +func (p *Processor) textEnd(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.current == nil { + return nil + } + end := currentNow() + if p.current.Time == nil { + p.current.Time = &msgmodel.TimeStartEnd{Start: end} + } + p.current.Time.End = &end + err := p.store.UpdatePart(ctx, *p.current) + p.current = nil + return err +} + +func (p *Processor) toolInputStart(ctx context.Context, value orclient.ToolInputStartPart) error { + p.mu.Lock() + defer p.mu.Unlock() + partID := nextID("prt") + if previous := p.toolCalls[value.ID]; previous != nil { + partID = previous.part.ID + } + part := msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: partID, SessionID: p.sessionID, MessageID: p.message.ID}, + CallID: value.ID, + Tool: value.ToolName, + State: msgmodel.PendingToolState(), + } + if err := p.store.UpdatePart(ctx, part); err != nil { + return err + } + if _, exists := p.toolCalls[value.ID]; !exists { + p.toolOrder = append(p.toolOrder, value.ID) + } + p.toolCalls[value.ID] = &toolCallState{part: part, done: make(chan struct{})} + return nil +} + +func (p *Processor) toolCall(ctx context.Context, value orclient.ToolCallPart) error { + specs := make([]orclient.ToolSpec, 0, len(p.tools)+1) + for _, tool := range p.tools { + specs = append(specs, orclient.ToolSpec{Name: tool.Provider.Name, Validate: tool.Validate}) + } + specs = append(specs, orclient.ToolSpec{Name: orclient.InvalidToolName}) + // The full map, invalid included, is sorted before the call is parsed; + // the provider never sees invalid, but it stays the fallback target for a + // call that names an unknown tool. + parsed := orclient.ParseToolCall(orclient.RawToolCall{ + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Input: value.Input, + }, orclient.SortedToolMap(specs...), orclient.SeniorDevRepairToolCall) + + _, err := p.UpdateToolCall(ctx, value.ToolCallID, func(part msgmodel.ToolPart) msgmodel.ToolPart { + start := currentNow() + state, stateErr := runningState(part.State, msgmodel.RawObject(parsed.Input), start) + if stateErr == nil { + part.State = state + } + part.Tool = parsed.ToolName + if value.HasProviderMetadata { + part.Metadata = reasoningMetadata(value.Details) + } + return part + }) + if err != nil { + return err + } + if parsed.Invalid { + _, err = p.FailToolCall(ctx, value.ToolCallID, parsed.Error) + return err + } + if parsed.ToolName == orclient.InvalidToolName { + return p.CompleteToolCall(ctx, value.ToolCallID, ToolResult{ + Title: "Invalid Tool", + Output: "The arguments provided to the tool are invalid: " + + invalidToolMessage(parsed.Input), + Metadata: msgmodel.RawObject("{}"), + }) + } + if p.executor == nil { + // Without an executor the call stays incomplete; cleanup settles it + // as aborted. + return nil + } + modelID := p.model.Message.API.ID + if modelID == "" { + modelID = p.model.Message.ID + } + call := ToolCall{ + ID: parsed.ToolCallID, + Name: parsed.ToolName, + Input: append(json.RawMessage(nil), parsed.Input...), + SessionID: p.sessionID, + MessageID: p.message.ID, + Agent: p.message.Agent, + ModelID: modelID, + } + execute := func() { + messages, messagesErr := p.store.Messages(ctx, p.sessionID) + if messagesErr != nil { + _, _ = p.FailToolCall(ctx, call.ID, messagesErr) + return + } + executeCtx := WithToolMessages(ctx, msgmodel.FilterCompacted(newestFirst(messages))) + output, executeErr := p.executor.Execute(executeCtx, call) + if executeErr != nil { + _, _ = p.FailToolCall(ctx, call.ID, executeErr) + return + } + _ = p.CompleteToolCall(ctx, call.ID, output) + } + p.mu.Lock() + if state := p.toolCalls[call.ID]; state != nil { + state.dispatched = true + } + p.mu.Unlock() + for _, definition := range p.tools { + if definition.Provider.Name == call.Name && definition.WaitForResult { + execute() + return nil + } + } + go execute() + return nil +} + +func (p *Processor) finish(ctx context.Context, value orclient.FinishPart) error { + usage := calc.GetUsage(calc.GetUsageInput{ + Model: p.model.Calc, + Usage: calc.AsLanguageModelUsage(value.Usage), + }) + finish := value.FinishReason.Unified + tokens := messageTokens(usage.Tokens) + part := msgmodel.StepFinishPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + Reason: finish, + Cost: float64(usage.Cost), + Tokens: tokens, + } + if value.Metadata.Provider != nil { + part.Upstream = *value.Metadata.Provider + } + if err := p.store.UpdatePart(ctx, part); err != nil { + return err + } + p.mu.Lock() + p.message.Finish = &finish + p.message.Cost = float64(float64(p.message.Cost) + usage.Cost) + p.message.Tokens = tokens + p.message.Upstream = part.Upstream + message := p.message + p.mu.Unlock() + return p.store.UpdateMessage(ctx, message) +} + +func (p *Processor) setAssistantError(classified retrysched.Err, fallback string) { + message := fallback + if classified.Data.Message != nil && *classified.Data.Message != "" { + message = *classified.Data.Message + } + value := msgmodel.NewUnknownError(message) + if classified.Name == msgmodel.ErrNameAPI { + api := msgmodel.APIError{ + Message: message, ResponseBody: classified.Data.ResponseBody, + } + if classified.Data.IsRetryable != nil { + api.IsRetryable = *classified.Data.IsRetryable + } + if code := classified.Data.StatusCode; code != nil && + *code >= 100 && *code <= 999 && math.Trunc(*code) == *code { + status := uint64(*code) + api.StatusCode = &status + } + value = msgmodel.NewAPIError(api) + } + p.mu.Lock() + p.message.Error = &value + p.mu.Unlock() +} + +func (p *Processor) cleanup(ctx context.Context) error { + p.mu.Lock() + streamAborted := p.streamAborted + if p.current != nil { + end := currentNow() + if p.current.Time == nil { + p.current.Time = &msgmodel.TimeStartEnd{Start: end} + } + p.current.Time.End = &end + if err := p.store.UpdatePart(ctx, *p.current); err != nil { + p.mu.Unlock() + return err + } + p.current = nil + } + for _, id := range p.reasoningOrder { + part, exists := p.reasoning[id] + if !exists { + continue + } + end := currentNow() + part.Time.End = &end + if err := p.store.UpdatePart(ctx, part); err != nil { + p.mu.Unlock() + return err + } + delete(p.reasoning, id) + } + p.reasoningOrder = nil + type pendingSettle struct { + done <-chan struct{} + dispatched bool + } + waiting := make([]pendingSettle, 0, len(p.toolCalls)) + for _, id := range p.toolOrder { + if call := p.toolCalls[id]; call != nil { + waiting = append(waiting, pendingSettle{done: call.done, dispatched: call.dispatched}) + } + } + p.mu.Unlock() + + // A dispatched call on a normally drained turn is waited for without a + // bound; the grace window applies only once ctx is cancelled. After an + // abnormal stream end every call uses the bounded drain. A registration + // that never got its tool-call part has no executor to settle it and + // always uses the bounded drain. + var group sync.WaitGroup + for _, wait := range waiting { + wait := wait + group.Add(1) + go func() { + defer group.Done() + if wait.dispatched && !streamAborted { + select { + case <-wait.done: + return + case <-ctx.Done(): + } + } + timer := time.NewTimer(p.wait) + defer timer.Stop() + select { + case <-wait.done: + case <-timer.C: + } + }() + } + group.Wait() + + p.mu.Lock() + for _, id := range p.toolOrder { + call := p.toolCalls[id] + if call == nil { + continue + } + end := currentNow() + raw, spreadErr := msgmodel.SpreadAbortedToolState(call.part.State, end) + if spreadErr != nil { + p.mu.Unlock() + return spreadErr + } + call.part.State = newRawToolState(raw) + if updateErr := p.store.UpdatePart(ctx, call.part); updateErr != nil { + p.mu.Unlock() + return updateErr + } + } + p.toolCalls = map[string]*toolCallState{} + p.toolOrder = nil + completed := currentNow() + p.message.Time.Completed = &completed + message := p.message + p.mu.Unlock() + return p.store.UpdateMessage(ctx, message) +} + +func messageTokens(tokens calc.UsageTokens) msgmodel.Tokens { + var total *uint64 + if tokens.Total != nil { + value := safeUint(*tokens.Total) + total = &value + } + return msgmodel.Tokens{ + Total: total, + Input: safeUint(tokens.Input), + Output: safeUint(tokens.Output), + Reasoning: safeUint(tokens.Reasoning), + Cache: msgmodel.TokenCache{ + Read: safeUint(tokens.Cache.Read), + Write: safeUint(tokens.Cache.Write), + }, + } +} + +func safeUint(value float64) uint64 { + if math.IsNaN(value) || value <= 0 { + return 0 + } + if math.IsInf(value, 1) || value >= math.MaxUint64 { + return math.MaxUint64 + } + return uint64(value) +} + +// rawToolState keeps a tool state as the JSON object it was assembled from, +// so fields carried over from the previous state survive a status transition +// verbatim, which the typed msgmodel variants cannot represent. +type rawToolState struct { + raw json.RawMessage + status string + input msgmodel.RawObject + metadata msgmodel.RawObject + start uint64 + hasStart bool +} + +func newRawToolState(raw json.RawMessage) rawToolState { + state := rawToolState{raw: append(json.RawMessage(nil), raw...)} + var probe struct { + Status string `json:"status"` + Input msgmodel.RawObject `json:"input"` + Metadata msgmodel.RawObject `json:"metadata"` + Time *struct { + Start uint64 `json:"start"` + } `json:"time"` + } + _ = json.Unmarshal(raw, &probe) + state.status = probe.Status + state.input = probe.Input + state.metadata = probe.Metadata + if probe.Time != nil { + state.start = probe.Time.Start + state.hasStart = true + } + return state +} + +func (s rawToolState) ToolStatus() string { return s.status } +func (s rawToolState) ToolInput() msgmodel.RawObject { return s.input } +func (s rawToolState) ToolMetadata() msgmodel.RawObject { return s.metadata } +func (s rawToolState) StartTime() (uint64, bool) { return s.start, s.hasStart } +func (s rawToolState) MarshalJSON() ([]byte, error) { return append([]byte(nil), s.raw...), nil } + +func runningState(previous msgmodel.ToolState, input msgmodel.RawObject, start uint64) (rawToolState, error) { + status, _ := jsonutil.Marshal(msgmodel.ToolStatusRunning) + timeRaw, err := jsonutil.Marshal(msgmodel.ToolTimeStart{Start: start}) + if err != nil { + return rawToolState{}, err + } + raw, err := msgmodel.SpreadToolState(previous, + msgmodel.RawField{Key: "status", Value: status}, + msgmodel.RawField{Key: "input", Value: json.RawMessage(input)}, + msgmodel.RawField{Key: "time", Value: timeRaw}, + ) + if err != nil { + return rawToolState{}, err + } + return newRawToolState(raw), nil +} + +func completedState(input msgmodel.RawObject, output ToolResult, metadata msgmodel.RawObject, start, end uint64) (rawToolState, error) { + status, _ := jsonutil.Marshal(msgmodel.ToolStatusCompleted) + outputRaw, _ := jsonutil.Marshal(output.Output) + titleRaw, _ := jsonutil.Marshal(output.Title) + timeRaw, err := jsonutil.Marshal(msgmodel.ToolTimeCompleted{Start: start, End: end}) + if err != nil { + return rawToolState{}, err + } + fields := []msgmodel.RawField{ + {Key: "status", Value: status}, + {Key: "input", Value: json.RawMessage(input)}, + {Key: "output", Value: outputRaw}, + {Key: "metadata", Value: json.RawMessage(metadata)}, + {Key: "title", Value: titleRaw}, + {Key: "time", Value: timeRaw}, + } + if output.Attachments != nil { + attachments, marshalErr := jsonutil.Marshal(*output.Attachments) + if marshalErr != nil { + return rawToolState{}, marshalErr + } + fields = append(fields, msgmodel.RawField{Key: "attachments", Value: attachments}) + } + return newRawToolState(msgmodel.SpreadObject(nil, fields...)), nil +} + +func errorState(input msgmodel.RawObject, message string, start, end uint64) (rawToolState, error) { + status, _ := jsonutil.Marshal(msgmodel.ToolStatusError) + messageRaw, _ := jsonutil.Marshal(message) + timeRaw, err := jsonutil.Marshal(msgmodel.ToolTimeSpan{Start: start, End: end}) + if err != nil { + return rawToolState{}, err + } + return newRawToolState(msgmodel.SpreadObject(nil, + msgmodel.RawField{Key: "status", Value: status}, + msgmodel.RawField{Key: "input", Value: json.RawMessage(input)}, + msgmodel.RawField{Key: "error", Value: messageRaw}, + msgmodel.RawField{Key: "time", Value: timeRaw}, + )), nil +} + +func reasoningMetadata(details orclient.ReasoningDetailsView) msgmodel.RawObject { + type envelope struct { + ReasoningDetails orclient.ReasoningDetailsView `json:"reasoning_details"` + } + type metadata struct { + Openrouter envelope `json:"openrouter"` + } + raw, _ := jsonutil.Marshal(metadata{Openrouter: envelope{ReasoningDetails: details}}) + return msgmodel.RawObject(raw) +} + +func errorPartMessage(raw json.RawMessage) string { + var value struct { + Message string `json:"message"` + Data *struct { + Message string `json:"message"` + } `json:"data"` + } + if json.Unmarshal(raw, &value) == nil { + if value.Message != "" { + return value.Message + } + if value.Data != nil && value.Data.Message != "" { + return value.Data.Message + } + } + if len(raw) == 0 { + return "unknown error" + } + return string(raw) +} + +func invalidToolMessage(input json.RawMessage) string { + var value struct { + Error string `json:"error"` + } + if json.Unmarshal(input, &value) == nil && value.Error != "" { + return value.Error + } + return "Invalid tool call" +} diff --git a/internal/seniordev/engine/steploop/reminders.go b/internal/seniordev/engine/steploop/reminders.go new file mode 100644 index 000000000..489e5dcd8 --- /dev/null +++ b/internal/seniordev/engine/steploop/reminders.go @@ -0,0 +1,91 @@ +//go:build !windows + +package steploop + +import ( + "crypto/rand" + "fmt" + "sync" + "time" +) + +var ( + seamMu sync.Mutex + nowMS = func() uint64 { return uint64(time.Now().UnixMilli()) } + idFunc = defaultID + + idMu sync.Mutex + idLastMS uint64 + idCounter uint64 +) + +// SetNowForTesting swaps the millisecond clock and returns a restore closure. +func SetNowForTesting(f func() uint64) func() { + seamMu.Lock() + prev := nowMS + nowMS = f + seamMu.Unlock() + return func() { + seamMu.Lock() + nowMS = prev + seamMu.Unlock() + } +} + +// SetIDFactoryForTesting swaps MessageID/PartID ascending generation. Prefix +// is "msg" or "prt". It returns a restore closure. +func SetIDFactoryForTesting(f func(prefix string) string) func() { + seamMu.Lock() + prev := idFunc + idFunc = f + seamMu.Unlock() + return func() { + seamMu.Lock() + idFunc = prev + seamMu.Unlock() + } +} + +func currentNow() uint64 { + seamMu.Lock() + f := nowMS + seamMu.Unlock() + return f() +} + +func nextID(prefix string) string { + seamMu.Lock() + f := idFunc + seamMu.Unlock() + return f(prefix) +} + +// NewAscendingID lets production adapters mint session-layer records from the +// same ordered sequence as loop-owned messages and parts. +func NewAscendingID(prefix string) string { + return nextID(prefix) +} + +// defaultID mints an ascending ID: the prefix, six bytes of packed +// millisecond time and per-millisecond counter, then 14 random base-62 +// characters (one crypto byte modulo 62 each). +func defaultID(prefix string) string { + idMu.Lock() + defer idMu.Unlock() + ms := currentNow() + if ms != idLastMS { + idLastMS = ms + idCounter = 0 + } + idCounter++ + packed := ms*0x1000 + idCounter + random := make([]byte, 14) + if _, err := rand.Read(random); err != nil { + panic(err) + } + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + for index := range random { + random[index] = alphabet[int(random[index])%len(alphabet)] + } + return fmt.Sprintf("%s_%012x%s", prefix, packed&0xffffffffffff, string(random)) +} diff --git a/internal/seniordev/engine/steploop/steploop_test.go b/internal/seniordev/engine/steploop/steploop_test.go new file mode 100644 index 000000000..4435d7558 --- /dev/null +++ b/internal/seniordev/engine/steploop/steploop_test.go @@ -0,0 +1,763 @@ +//go:build !windows + +package steploop + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "sync" + "testing" + "time" + + "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/jsonutil" + "github.com/Agent-Field/codeaf/internal/seniordev/session/instruction" +) + +func stringPtr(value string) *string { return &value } + +func baseUser(id, agent string, parts msgmodel.Parts) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.TimeCreated{Created: 1}, + Agent: agent, + Model: msgmodel.UserModel{ProviderID: "openrouter", ModelID: "model-1"}, + }, + Parts: parts, + } +} + +func baseAssistant(id, parent, agent, finish string, parts msgmodel.Parts) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.AssistantTime{Created: 2}, + ParentID: parent, + ModelID: "model-1", + ProviderID: "openrouter", + Mode: agent, + Agent: agent, + Path: msgmodel.AssistantPath{Cwd: "/work", Root: "/work"}, + Tokens: msgmodel.Tokens{Cache: msgmodel.TokenCache{}}, + Finish: stringPtr(finish), + }, + Parts: parts, + } +} + +func TestShouldExitTruthTable(t *testing.T) { + finishes := []*string{ + nil, + stringPtr(""), + stringPtr(orclient.FinishStop), + stringPtr(orclient.FinishLength), + stringPtr(orclient.FinishContentFilter), + stringPtr(orclient.FinishToolCalls), + stringPtr(orclient.FinishError), + stringPtr(orclient.FinishOther), + stringPtr("unknown"), + } + for _, finish := range finishes { + label := "unset" + if finish != nil { + label = *finish + if label == "" { + label = "empty" + } + } + for _, pending := range []bool{false, true} { + for _, ordering := range []string{"lt", "eq", "gt"} { + t.Run(label+"/tool="+strconv.FormatFloat(boolNumber(pending), 'f', -1, 64)+"/"+ordering, func(t *testing.T) { + userID := "msg_2" + switch ordering { + case "lt": + userID = "msg_1" + case "gt": + userID = "msg_3" + } + user := msgmodel.User{MessageBase: msgmodel.MessageBase{ID: userID}} + assistant := msgmodel.Assistant{MessageBase: msgmodel.MessageBase{ID: "msg_2"}, Finish: finish} + parts := msgmodel.Parts{} + if pending { + parts = append(parts, msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "prt_1", MessageID: assistant.ID}, + CallID: "call_1", Tool: "fake", State: msgmodel.PendingToolState(), + }) + } + msgs := []msgmodel.WithParts{{Info: assistant, Parts: parts}} + got := ShouldExit(&user, &assistant, msgs) + want := finish != nil && *finish != "" && *finish != orclient.FinishToolCalls && !pending && ordering == "lt" + if got != want { + t.Fatalf("ShouldExit=%v want %v", got, want) + } + }) + } + } + } +} + +func boolNumber(value bool) float64 { + if value { + return 1 + } + return 0 +} + +type memoryStore struct { + mu sync.Mutex + messages []msgmodel.WithParts + events []string +} + +func (s *memoryStore) Messages(_ context.Context, sessionID string) ([]msgmodel.WithParts, error) { + s.mu.Lock() + defer s.mu.Unlock() + raw, err := jsonutil.Marshal(s.messages) + if err != nil { + return nil, err + } + var copied []msgmodel.WithParts + if err := json.Unmarshal(raw, &copied); err != nil { + return nil, err + } + return copied, nil +} + +func (s *memoryStore) UpdateMessage(_ context.Context, info msgmodel.Info) error { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, "message:"+info.MessageRole()+":"+info.MessageID()) + for index := range s.messages { + if s.messages[index].Info.MessageID() == info.MessageID() { + s.messages[index].Info = info + return nil + } + } + s.messages = append(s.messages, msgmodel.WithParts{Info: info, Parts: msgmodel.Parts{}}) + return nil +} + +func (s *memoryStore) UpdatePart(_ context.Context, part msgmodel.Part) error { + s.mu.Lock() + defer s.mu.Unlock() + base := part.Base() + s.events = append(s.events, "part:"+part.PartType()+":"+base.ID) + for mi := range s.messages { + if s.messages[mi].Info.MessageID() != base.MessageID { + continue + } + for partIdx := range s.messages[mi].Parts { + if s.messages[mi].Parts[partIdx].Base().ID == base.ID { + s.messages[mi].Parts[partIdx] = part + return nil + } + } + s.messages[mi].Parts = append(s.messages[mi].Parts, part) + return nil + } + return errors.New("part message not found: " + base.MessageID) +} + +func (s *memoryStore) rawSnapshot() []msgmodel.WithParts { + s.mu.Lock() + defer s.mu.Unlock() + return append([]msgmodel.WithParts(nil), s.messages...) +} + +type scriptedClient struct { + mu sync.Mutex + scripts [][]orclient.StreamPart + requests []orclient.RequestParams +} + +func (c *scriptedClient) Stream(_ context.Context, params orclient.RequestParams) (PartStream, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.requests = append(c.requests, params) + if len(c.scripts) == 0 { + return nil, errors.New("unexpected extra LLM turn") + } + parts := c.scripts[0] + c.scripts = c.scripts[1:] + return &SliceStream{Parts: parts}, nil +} + +func finishPart(reason string) orclient.FinishPart { + return orclient.FinishPart{ + FinishReason: orclient.FinishReason{Unified: reason}, + Usage: calc.LanguageModelV3Usage{}, + } +} + +type immediateTool struct { + mu sync.Mutex + calls []ToolCall +} + +type historyTool struct { + loaded chan []string +} + +func (tool *historyTool) Execute(ctx context.Context, _ ToolCall) (ToolResult, error) { + paths := instruction.Loaded(ToolMessagesFromContext(ctx)).Values() + tool.loaded <- paths + return ToolResult{Output: "nested reminder re-injected", Metadata: msgmodel.RawObject(`{}`)}, nil +} + +type taskControllerSpy struct { + mu sync.Mutex + overflowChecks int + creates int + pruned chan string +} + +func (*taskControllerSpy) ProcessCompaction( + context.Context, TaskInput, msgmodel.CompactionPart, +) (Result, error) { + return ResultContinue, nil +} + +func (s *taskControllerSpy) IsOverflow( + _ context.Context, _ msgmodel.Assistant, _ Model, +) (bool, error) { + s.mu.Lock() + s.overflowChecks++ + s.mu.Unlock() + return false, nil +} + +func (s *taskControllerSpy) CreateCompaction( + context.Context, string, msgmodel.User, bool, +) error { + s.mu.Lock() + s.creates++ + s.mu.Unlock() + return nil +} + +func (s *taskControllerSpy) Prune(_ context.Context, sessionID string) error { + s.pruned <- sessionID + return nil +} + +func (e *immediateTool) Execute(_ context.Context, call ToolCall) (ToolResult, error) { + e.mu.Lock() + e.calls = append(e.calls, call) + e.mu.Unlock() + return ToolResult{Title: "ok", Metadata: msgmodel.RawObject(`{"source":"fake"}`), Output: "value=2"}, nil +} + +func fixedSeams(t *testing.T) { + t.Helper() + restoreNow := SetNowForTesting(func() uint64 { return 1000 }) + sequence := 0 + restoreID := SetIDFactoryForTesting(func(prefix string) string { + sequence++ + return prefix + "_1" + leftPad(sequence, 4) + }) + t.Cleanup(restoreID) + t.Cleanup(restoreNow) +} + +func leftPad(value, width int) string { + text := strconv.Itoa(value) + for len(text) < width { + text = "0" + text + } + return text +} + +func testResolver() ModelResolver { + limits := calc.Model{Limit: calc.ModelLimit{Context: 128000, Output: 32000}} + return ModelResolverFunc(func(_ context.Context, user msgmodel.User) (Model, error) { + return Model{ + Message: msgmodel.Model{ + ProviderID: user.Model.ProviderID, + ID: user.Model.ModelID, + API: msgmodel.ModelAPI{Npm: "@openrouter/ai-sdk-provider", ID: user.Model.ModelID}, + }, + Calc: limits, + Request: orclient.RequestParams{ModelID: user.Model.ModelID}, + }, nil + }) +} + +func TestScriptedMultiTurnToolSequence(t *testing.T) { + fixedSeams(t) + store := &memoryStore{messages: []msgmodel.WithParts{baseUser("msg_0000", "build", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0000", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "double one"}, + })}} + client := &scriptedClient{scripts: [][]orclient.StreamPart{ + { + orclient.ToolInputStartPart{ID: "call_1", ToolName: "double"}, + orclient.ToolCallPart{ToolCallID: "call_1", ToolName: "double", Input: `{"x":1}`}, + finishPart(orclient.FinishToolCalls), + }, + { + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + finishPart(orclient.FinishStop), + }, + }} + executor := &immediateTool{} + cleared := []string{} + + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: executor} + final, err := loop.Run(context.Background(), RunOptions{ + SessionID: "ses_1", Workspace: "/work", Worktree: "/work", + Tools: []ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "double", Description: "double", InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + AfterAssistant: func(_ context.Context, messageID string) { + cleared = append(cleared, messageID) + }, + }) + if err != nil { + t.Fatal(err) + } + if final.Finish == nil || *final.Finish != orclient.FinishStop { + t.Fatalf("final finish = %#v", final.Finish) + } + if len(client.requests) != 2 { + t.Fatalf("LLM turns = %d, want 2", len(client.requests)) + } + if got := modelRoles(client.requests[0].Prompt); strings.Join(got, ",") != "user" { + t.Fatalf("first prompt roles = %v", got) + } + if got := modelRoles(client.requests[1].Prompt); strings.Join(got, ",") != "user,assistant,tool" { + t.Fatalf("second prompt roles = %v", got) + } + messages := store.rawSnapshot() + if len(messages) != 3 { + t.Fatalf("persisted messages = %d, want user + two assistants", len(messages)) + } + assertPartTypes(t, messages[1].Parts, "step-start,tool,step-finish") + assertPartTypes(t, messages[2].Parts, "step-start,text,step-finish") + tool := messages[1].Parts[1].(msgmodel.ToolPart) + if tool.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("tool status = %s", tool.State.ToolStatus()) + } + if len(executor.calls) != 1 || string(executor.calls[0].Input) != `{"x":1}` { + t.Fatalf("tool calls = %#v", executor.calls) + } + if len(cleared) != 2 || cleared[0] == cleared[1] { + t.Fatalf("assistant instruction claims cleared = %v", cleared) + } +} + +func TestToolExecutionUsesPostCompactionHistory(t *testing.T) { + // Read metadata from before a completed compaction is absent from the + // next read's tool context, so its nested instruction reminder can be + // injected again. + fixedSeams(t) + summary := true + compactionUser := baseUser("msg_0002", "coder", msgmodel.Parts{msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "compact", SessionID: "ses_1", MessageID: "msg_0002"}, + Auto: true, + }}) + summaryAssistant := baseAssistant("msg_0003", "msg_0002", "compaction", orclient.FinishStop, msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "summary", SessionID: "ses_1", MessageID: "msg_0003"}, Text: "summary"}, + }) + summaryInfo := summaryAssistant.Info.(msgmodel.Assistant) + summaryInfo.Summary = &summary + summaryAssistant.Info = summaryInfo + oldRead := msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "old-read", SessionID: "ses_1", MessageID: "msg_0001"}, + CallID: "old", Tool: "read", + State: msgmodel.CompletedToolState( + msgmodel.RawObject(`{}`), "old reminder", "read", msgmodel.RawObject(`{"loaded":["/work/src/AGENTS.md"]}`), + 1, 2, nil, + ), + } + store := &memoryStore{messages: []msgmodel.WithParts{ + baseUser("msg_0000", "coder", msgmodel.Parts{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "initial", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "first read", + }}), + baseAssistant("msg_0001", "msg_0000", "coder", orclient.FinishToolCalls, msgmodel.Parts{oldRead}), + compactionUser, summaryAssistant, + baseUser("msg_0004", "coder", msgmodel.Parts{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "again", SessionID: "ses_1", MessageID: "msg_0004"}, Text: "read again", + }}), + }} + client := &scriptedClient{scripts: [][]orclient.StreamPart{ + { + orclient.ToolInputStartPart{ID: "call_2", ToolName: "read"}, + orclient.ToolCallPart{ToolCallID: "call_2", ToolName: "read", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }, + {finishPart(orclient.FinishStop)}, + }} + executor := &historyTool{loaded: make(chan []string, 1)} + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: executor} + if _, err := loop.Run(context.Background(), RunOptions{ + SessionID: "ses_1", Workspace: "/work", Worktree: "/work", + Tools: []ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "read", InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + }); err != nil { + t.Fatal(err) + } + if loaded := <-executor.loaded; len(loaded) != 0 { + t.Fatalf("post-compaction read saw stale loaded paths: %v", loaded) + } +} + +func TestSummaryTurnSkipsRecursiveCompactionAndPrunesOnExit(t *testing.T) { + // A finished summary bypasses the pre-turn overflow check, and a wired + // controller is pruned after the natural exit. + fixedSeams(t) + summary := true + store := &memoryStore{messages: []msgmodel.WithParts{ + baseUser("msg_0001", "coder", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0001", SessionID: "ses_1", MessageID: "msg_0001"}, Text: "original"}, + }), + baseAssistant("msg_0002", "msg_0001", "compaction", orclient.FinishStop, msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0002", SessionID: "ses_1", MessageID: "msg_0002"}, Text: "summary"}, + }), + baseUser("msg_0003", "coder", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0003", SessionID: "ses_1", MessageID: "msg_0003"}, Text: "continue"}, + }), + }} + assistant := store.messages[1].Info.(msgmodel.Assistant) + assistant.Summary = &summary + store.messages[1].Info = assistant + client := &scriptedClient{scripts: [][]orclient.StreamPart{{ + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + finishPart(orclient.FinishStop), + }}} + tasks := &taskControllerSpy{pruned: make(chan string, 1)} + loop := Loop{Store: store, Client: client, Models: testResolver(), Tasks: tasks} + if _, err := loop.Run(context.Background(), RunOptions{ + SessionID: "ses_1", Workspace: "/work", Worktree: "/work", + }); err != nil { + t.Fatal(err) + } + select { + case sessionID := <-tasks.pruned: + if sessionID != "ses_1" { + t.Fatalf("pruned session = %q", sessionID) + } + case <-time.After(time.Second): + t.Fatal("prune fork did not run") + } + tasks.mu.Lock() + defer tasks.mu.Unlock() + if tasks.overflowChecks != 1 { + t.Fatalf("overflow checks = %d, want only the post-response check", tasks.overflowChecks) + } + if tasks.creates != 0 { + t.Fatalf("recursive compactions = %d", tasks.creates) + } +} + +func modelRoles(messages []msgmodel.ModelMessage) []string { + out := make([]string, len(messages)) + for i, message := range messages { + out[i] = message.Role + } + return out +} + +func assertPartTypes(t *testing.T, parts msgmodel.Parts, want string) { + t.Helper() + got := make([]string, len(parts)) + for index, part := range parts { + got[index] = part.PartType() + } + if strings.Join(got, ",") != want { + t.Fatalf("part types = %v, want %s", got, want) + } +} + +type blockingTool struct { + release <-chan struct{} +} + +func (e blockingTool) Execute(_ context.Context, _ ToolCall) (ToolResult, error) { + <-e.release + return ToolResult{Metadata: msgmodel.RawObject("{}")}, nil +} + +func TestCleanupWaitsOutstandingToolsConcurrentlyThenAbortsSpreadStates(t *testing.T) { + // A cancelled ctx grants each outstanding tool the grace window, in + // parallel, then force-writes the aborted state over what was there. + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + release := make(chan struct{}) + defer close(release) + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "a"}}, + {Provider: orclient.Tool{Type: "function", Name: "b"}}, + }, + Executor: blockingTool{release: release}, WaitTimeout: 40 * time.Millisecond, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "a"}, + orclient.ToolCallPart{ToolCallID: "a", ToolName: "a", Input: `{}`}, + orclient.ToolInputStartPart{ID: "b", ToolName: "b"}, + orclient.ToolCallPart{ToolCallID: "b", ToolName: "b", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + if _, err := processor.Process(ctx, stream); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed >= 75*time.Millisecond { + t.Fatalf("two 40ms waits were serial: %s", elapsed) + } + messages := store.rawSnapshot() + parts := messages[0].Parts + assertPartTypes(t, parts, "step-start,tool,tool,step-finish") + for _, raw := range parts { + tool, ok := raw.(msgmodel.ToolPart) + if !ok { + continue + } + if tool.State.ToolStatus() != msgmodel.ToolStatusError { + t.Fatalf("%s status = %s", tool.CallID, tool.State.ToolStatus()) + } + state := jsonString(t, tool.State) + for _, fragment := range []string{`"raw":""`, `"error":"Tool execution aborted"`, `"interrupted":true`} { + if !strings.Contains(state, fragment) { + t.Fatalf("%s state %s missing %s", tool.CallID, state, fragment) + } + } + } +} + +func TestCleanupBoundsDispatchedToolAfterErrorPart(t *testing.T) { + testCleanupBoundsDispatchedToolAfterAbnormalPart(t, orclient.ErrorPart{ + Error: json.RawMessage(`{"message":"provider failed"}`), + }) +} + +func TestCleanupBoundsDispatchedToolAfterAbortPart(t *testing.T) { + testCleanupBoundsDispatchedToolAfterAbnormalPart(t, orclient.AbortPart{ + Reason: "user stopped the stream", HasReason: true, + }) +} + +func testCleanupBoundsDispatchedToolAfterAbnormalPart(t *testing.T, terminal orclient.StreamPart) { + t.Helper() + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + release := make(chan struct{}) + var releaseOnce sync.Once + releaseTool := func() { releaseOnce.Do(func() { close(release) }) } + defer releaseTool() + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "a"}}, + }, + Executor: blockingTool{release: release}, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "a"}, + orclient.ToolCallPart{ToolCallID: "a", ToolName: "a", Input: `{}`}, + terminal, + }} + type processResult struct { + result Result + err error + } + completed := make(chan processResult, 1) + start := time.Now() + go func() { + result, err := processor.Process(context.Background(), stream) + completed <- processResult{result: result, err: err} + }() + var got processResult + select { + case got = <-completed: + case <-time.After(time.Second): + releaseTool() + <-completed + t.Fatal("Process did not bound cleanup after an abnormal stream end") + } + if got.err != nil { + t.Fatal(got.err) + } + if got.result != ResultStop { + t.Fatalf("Process result = %v, want ResultStop", got.result) + } + if elapsed := time.Since(start); elapsed >= time.Second { + t.Fatalf("Process took %s, want bounded abnormal cleanup", elapsed) + } + messages := store.rawSnapshot() + var tool *msgmodel.ToolPart + for _, part := range messages[0].Parts { + if value, ok := part.(msgmodel.ToolPart); ok { + value := value + tool = &value + break + } + } + if tool == nil { + t.Fatal("tool part was not persisted") + } + state := jsonString(t, tool.State) + for _, fragment := range []string{`"error":"Tool execution aborted"`, `"interrupted":true`} { + if !strings.Contains(state, fragment) { + t.Fatalf("state %s missing %s", state, fragment) + } + } +} + +func TestCleanupBoundsUndispatchedToolRegistrationOnHealthyTurn(t *testing.T) { + // A tool-input-start whose tool-call part never arrives (length-truncated + // or errored stream) has no executor and can never settle itself. On a + // healthy turn cleanup must settle it via the bounded drain, not wait + // unbounded, which would deadlock the loop. + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "write"}}, + }, + Executor: &immediateTool{}, + WaitTimeout: 40 * time.Millisecond, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "write"}, + finishPart(orclient.FinishLength), + }} + completed := make(chan error, 1) + go func() { + _, err := processor.Process(context.Background(), stream) + completed <- err + }() + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("cleanup hung on an undispatched tool registration (healthy ctx)") + } + messages := store.rawSnapshot() + parts := messages[0].Parts + assertPartTypes(t, parts, "step-start,tool,step-finish") + tool := parts[1].(msgmodel.ToolPart) + if tool.State.ToolStatus() != msgmodel.ToolStatusError { + t.Fatalf("undispatched registration status = %s, want aborted error", tool.State.ToolStatus()) + } + state := jsonString(t, tool.State) + for _, fragment := range []string{`"error":"Tool execution aborted"`, `"interrupted":true`} { + if !strings.Contains(state, fragment) { + t.Fatalf("state %s missing %s", state, fragment) + } + } +} + +type slowTool struct { + delay time.Duration + output string +} + +func (e slowTool) Execute(_ context.Context, _ ToolCall) (ToolResult, error) { + time.Sleep(e.delay) + return ToolResult{Output: e.output, Metadata: msgmodel.RawObject("{}")}, nil +} + +func TestCleanupWaitsForSlowToolOnHealthyTurnInsteadOfAborting(t *testing.T) { + // A tool whose execution outlives the provider stream still completes + // with its real output on a healthy (non-cancelled) turn, no matter how + // far past WaitTimeout it runs. Aborting it instead hands the model a + // spurious "Tool execution aborted" error and costs a retry. + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "a"}}, + }, + Executor: slowTool{delay: 120 * time.Millisecond, output: "slow but real"}, + WaitTimeout: 5 * time.Millisecond, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "a"}, + orclient.ToolCallPart{ToolCallID: "a", ToolName: "a", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + if _, err := processor.Process(context.Background(), stream); err != nil { + t.Fatal(err) + } + messages := store.rawSnapshot() + parts := messages[0].Parts + assertPartTypes(t, parts, "step-start,tool,step-finish") + tool := parts[1].(msgmodel.ToolPart) + if tool.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("slow tool status = %s, want completed; state = %s", + tool.State.ToolStatus(), jsonString(t, tool.State)) + } + if !strings.Contains(jsonString(t, tool.State), "slow but real") { + t.Fatalf("slow tool output missing: %s", jsonString(t, tool.State)) + } +} + +// The endpoint OpenRouter reports for a call lands on the step-finish part and +// on the assistant message, so a cache miss can be attributed to an endpoint +// switch afterwards. A stream that never reports one leaves both empty. +func TestFinishRecordsReportedUpstream(t *testing.T) { + fixedSeams(t) + store := &memoryStore{messages: []msgmodel.WithParts{baseUser("msg_0000", "build", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0000", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "hello"}, + })}} + served := finishPart(orclient.FinishStop) + served.Metadata.Provider = stringPtr("provider-b") + client := &scriptedClient{scripts: [][]orclient.StreamPart{{ + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + served, + }}} + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: &immediateTool{}} + final, err := loop.Run(context.Background(), RunOptions{SessionID: "ses_1", Workspace: "/work", Worktree: "/work"}) + if err != nil { + t.Fatal(err) + } + if final.Upstream != "provider-b" { + t.Fatalf("assistant upstream = %q, want provider-b", final.Upstream) + } + messages := store.rawSnapshot() + assertPartTypes(t, messages[1].Parts, "step-start,text,step-finish") + finish := messages[1].Parts[2].(msgmodel.StepFinishPart) + if finish.Upstream != "provider-b" { + t.Fatalf("step-finish upstream = %q, want provider-b", finish.Upstream) + } +} diff --git a/internal/seniordev/engine/steploop/types.go b/internal/seniordev/engine/steploop/types.go new file mode 100644 index 000000000..6dca0dafb --- /dev/null +++ b/internal/seniordev/engine/steploop/types.go @@ -0,0 +1,208 @@ +//go:build !windows + +package steploop + +import ( + "context" + "encoding/json" + "io" + "math" + + "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" +) + +// Store is the MessageV2 persistence slice used by the loop and processor. +// Messages returns chronological order and fresh part values. +type Store interface { + Messages(ctx context.Context, sessionID string) ([]msgmodel.WithParts, error) + UpdateMessage(ctx context.Context, info msgmodel.Info) error + UpdatePart(ctx context.Context, part msgmodel.Part) error +} + +// PartStream is one one-request OpenRouter response. +type PartStream interface { + Next() (orclient.StreamPart, error) + Close() error +} + +// LLMClient starts one request. Multi-turn behavior belongs to Loop.Run. +type LLMClient interface { + Stream(ctx context.Context, params orclient.RequestParams) (PartStream, error) +} + +// SliceStream is a deterministic in-memory PartStream useful to embedders and +// tests. Failure is returned after all Parts; Close is idempotent. +type SliceStream struct { + Parts []orclient.StreamPart + Failure error + next int + closed bool +} + +func (s *SliceStream) Next() (orclient.StreamPart, error) { + if s.closed { + return nil, io.EOF + } + if s.next < len(s.Parts) { + part := s.Parts[s.next] + s.next++ + return part, nil + } + if s.Failure != nil { + err := s.Failure + s.Failure = nil + return nil, err + } + return nil, io.EOF +} + +func (s *SliceStream) Close() error { + s.closed = true + return nil +} + +// ToolDefinition is a provider declaration plus the optional validation seam +// consumed by orclient.ParseToolCall. +type ToolDefinition struct { + Provider orclient.Tool + Validate func(input json.RawMessage) error + WaitForResult bool +} + +// ToolCall is the already-repaired, already-validated call handed to a real +// tool implementation. +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + SessionID string `json:"sessionID"` + MessageID string `json:"messageID"` + Agent string `json:"agent,omitempty"` + ModelID string `json:"modelID,omitempty"` +} + +// ToolResult is what a tool returns when it completes. +type ToolResult struct { + Title string + Metadata msgmodel.RawObject + Output string + Attachments *[]msgmodel.FilePart +} + +// ToolExecutor is intentionally the minimal real-tool seam. Resolution and +// declarations are RunOptions.Tools; this interface only performs a call. +type ToolExecutor interface { + Execute(ctx context.Context, call ToolCall) (ToolResult, error) +} + +type toolMessagesContextKey struct{} + +// WithToolMessages gives a tool the persisted session history that was current +// when execution began. Read uses it to deduplicate nested instruction files. +func WithToolMessages(ctx context.Context, messages []msgmodel.WithParts) context.Context { + return context.WithValue(ctx, toolMessagesContextKey{}, messages) +} + +// ToolMessagesFromContext returns the history attached by the live executor. +func ToolMessagesFromContext(ctx context.Context) []msgmodel.WithParts { + messages, _ := ctx.Value(toolMessagesContextKey{}).([]msgmodel.WithParts) + return messages +} + +// Model is the resolved model in the three projections the loop needs: the +// message-conversion view, the budget view and the request parameters. +type Model struct { + Message msgmodel.Model + Calc calc.Model + Request orclient.RequestParams +} + +// ModelResolver resolves the model named by the latest user message. +type ModelResolver interface { + Resolve(ctx context.Context, user msgmodel.User) (Model, error) +} + +// ModelResolverFunc adapts a function to ModelResolver. +type ModelResolverFunc func(context.Context, msgmodel.User) (Model, error) + +func (f ModelResolverFunc) Resolve(ctx context.Context, user msgmodel.User) (Model, error) { + return f(ctx, user) +} + +// TaskInput is the context handed to the compaction branch of the loop. +type TaskInput struct { + SessionID string + Messages []msgmodel.WithParts + User msgmodel.User + Model Model +} + +// TaskController is the narrow seam to the compaction service. The step loop +// owns when each operation runs; the controller owns the operations. +type TaskController interface { + ProcessCompaction(ctx context.Context, input TaskInput, task msgmodel.CompactionPart) (Result, error) + IsOverflow(ctx context.Context, assistant msgmodel.Assistant, model Model) (bool, error) + CreateCompaction(ctx context.Context, sessionID string, user msgmodel.User, overflow bool) error + Prune(ctx context.Context, sessionID string) error +} + +// RunOptions are the session and agent values one Run needs. +type RunOptions struct { + SessionID string + ParentID string + Workspace string + Worktree string + + // MaxSteps is agent.steps. Nil means Infinity. + MaxSteps *float64 + Tools []ToolDefinition + + // InjectReminders may add in-memory-only reminder parts to the prompt + // before each request. It must return fresh values. + InjectReminders func(context.Context, []msgmodel.WithParts, msgmodel.User) ([]msgmodel.WithParts, error) + + // AfterAssistant runs after each processor turn, including stop and + // error turns, with the assistant message ID; the instruction tracker + // uses it to release the claims made for that message. + AfterAssistant func(context.Context, string) + + // AfterTurn observes a fully persisted assistant turn and may stop the loop + // before another provider call. + AfterTurn func(context.Context, msgmodel.Assistant, msgmodel.Parts) error +} + +func (o RunOptions) maxSteps() float64 { + if o.MaxSteps == nil { + return math.Inf(1) + } + return *o.MaxSteps +} + +// Result is what a processed turn asks the loop to do next. +type Result string + +const ( + ResultCompact Result = "compact" + ResultStop Result = "stop" + ResultContinue Result = "continue" +) + +// MaxStepsPrompt is appended to the prompt once the step cap is reached. +const MaxStepsPrompt = `CRITICAL - MAXIMUM STEPS REACHED + +The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. + +STRICT REQUIREMENTS: +1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools) +2. MUST provide a text response summarizing work done so far +3. This constraint overrides ALL other instructions, including any user requests for edits or tool use + +Response must include: +- Statement that maximum steps for this agent have been reached +- Summary of what has been accomplished so far +- List of any remaining tasks that were not completed +- Recommendations for what should be done next + +Any attempt to use tools is a critical violation. Respond with text ONLY.` diff --git a/internal/seniordev/format/format.go b/internal/seniordev/format/format.go new file mode 100644 index 000000000..f6eb8a589 --- /dev/null +++ b/internal/seniordev/format/format.go @@ -0,0 +1,256 @@ +//go:build !windows + +// Formatter service +package format + +import ( + "context" + "errors" + "path/filepath" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type Status struct { + Name string `json:"name"` + Extensions []string `json:"extensions"` + Enabled bool `json:"enabled"` +} + +type FormatterOverride struct { + Key string + Disabled bool + Extensions *[]string + Command *[]string + Environment map[string]string +} + +type Configuration struct { + // Enabled=false (no formatter config) disables every formatter. + Enabled bool + Overrides []FormatterOverride +} + +type CommandRunner func(context.Context, []string, string, map[string]string) (int, error) + +type Service struct { + context Context + deps Dependencies + runner CommandRunner + + mu sync.Mutex + formatters orderedFormatters + commands map[string]cachedCommand +} + +type cachedCommand struct { + command []string + enabled bool + set bool +} + +type orderedFormatters struct { + order []string + items map[string]Info +} + +func (o *orderedFormatters) set(key string, value Info) { + if _, exists := o.items[key]; !exists { + o.order = append(o.order, key) + } + o.items[key] = value +} + +func (o *orderedFormatters) delete(key string) { + if _, exists := o.items[key]; !exists { + return + } + delete(o.items, key) + for i, item := range o.order { + if item == key { + o.order = append(o.order[:i], o.order[i+1:]...) + return + } + } +} + +func (o *orderedFormatters) values() []Info { + out := make([]Info, 0, len(o.order)) + for _, key := range o.order { + out = append(out, o.items[key]) + } + return out +} + +func NewService(instance Context, configuration Configuration, dependencies Dependencies, runner CommandRunner) *Service { + if runner == nil { + runner = func(ctx context.Context, command []string, cwd string, environment map[string]string) (int, error) { + if len(command) == 0 { + return 1, errors.New("Command is required") + } + result, err := util.RunProcess(ctx, command, util.RunOptions{ + ProcessOptions: util.ProcessOptions{Cwd: cwd, Env: environment}, + NoThrow: true, + }) + return result.Code, err + } + } + service := &Service{ + context: instance, + deps: dependencies, + runner: runner, + formatters: orderedFormatters{items: make(map[string]Info)}, + commands: make(map[string]cachedCommand), + } + if !configuration.Enabled { + return service + } + builtins := Builtins(dependencies) + byExportKey := map[string]Info{} + for _, item := range builtins { + byExportKey[item.Key] = item + service.formatters.set(item.Name, item) + } + + linkedDisabled := false + for _, override := range configuration.Overrides { + if (override.Key == "ruff" || override.Key == "uv") && override.Disabled { + linkedDisabled = true + } + } + for _, override := range configuration.Overrides { + name := override.Key + if (name == "ruff" || name == "uv") && linkedDisabled { + service.formatters.delete("ruff") + service.formatters.delete("uv") + continue + } + if override.Disabled { + service.formatters.delete(name) + continue + } + builtIn, exists := byExportKey[name] + info := Info{Key: name, Name: name, Extensions: []string{}} + if exists { + info = builtIn + info.Name = name + } + if override.Extensions != nil { + info.Extensions = append([]string(nil), (*override.Extensions)...) + } + if override.Environment != nil { + environment := map[string]string{} + for key, value := range info.Environment { + environment[key] = value + } + for key, value := range override.Environment { + environment[key] = value + } + info.Environment = environment + } + if !exists || override.Command != nil { + command := override.Command + info.Enabled = func(_ context.Context, _ Context) ([]string, bool, error) { + if command == nil { + return nil, false, nil + } + return append([]string(nil), (*command)...), true, nil + } + } + service.formatters.set(name, info) + } + return service +} + +func (s *Service) getCommand(ctx context.Context, item Info) ([]string, bool, error) { + s.mu.Lock() + cached := s.commands[item.Name] + if cached.set && cached.enabled { + command := append([]string(nil), cached.command...) + s.mu.Unlock() + return command, true, nil + } + s.mu.Unlock() + + command, enabled, err := item.Enabled(ctx, s.context) + if err != nil { + return nil, false, err + } + s.mu.Lock() + s.commands[item.Name] = cachedCommand{ + command: append([]string(nil), command...), enabled: enabled, set: true, + } + s.mu.Unlock() + return command, enabled, nil +} + +func (s *Service) Status(ctx context.Context) ([]Status, error) { + out := []Status{} + for _, formatter := range s.formatters.values() { + _, enabled, err := s.getCommand(ctx, formatter) + if err != nil { + return nil, err + } + out = append(out, Status{ + Name: formatter.Name, Extensions: append([]string(nil), formatter.Extensions...), Enabled: enabled, + }) + } + return out, nil +} + +func (s *Service) File(ctx context.Context, path string) (bool, error) { + extension := fileExtension(path) + type match struct { + item Info + command []string + } + matches := []match{} + for _, formatter := range s.formatters.values() { + if !containsExtension(formatter.Extensions, extension) { + continue + } + command, enabled, err := s.getCommand(ctx, formatter) + if err != nil { + return false, err + } + if enabled { + matches = append(matches, match{item: formatter, command: command}) + } + } + if len(matches) == 0 { + return false, nil + } + for _, match := range matches { + replaced := make([]string, len(match.command)) + for i, argument := range match.command { + replaced[i] = strings.Replace(argument, "$FILE", path, 1) + } + _, _ = s.runner(ctx, replaced, s.context.Directory, match.item.Environment) + } + return true, nil +} + +func containsExtension(extensions []string, target string) bool { + for _, extension := range extensions { + if extension == target { + return true + } + } + return false +} + +// fileExtension returns the final dotted suffix of path's base name; a +// leading dot alone (".bashrc") is not an extension. +func fileExtension(path string) string { + base := filepath.Base(path) + lastDot := strings.LastIndexByte(base, '.') + if lastDot <= 0 { + return "" + } + if base == ".." { + return "" + } + return base[lastDot:] +} diff --git a/internal/seniordev/format/format_test.go b/internal/seniordev/format/format_test.go new file mode 100644 index 000000000..5566a4eb5 --- /dev/null +++ b/internal/seniordev/format/format_test.go @@ -0,0 +1,261 @@ +//go:build !windows + +package format + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +type runCall struct { + command []string + cwd string + environment map[string]string +} + +func TestDisabledConfiguration(t *testing.T) { + service := NewService(Context{}, Configuration{}, Dependencies{}, nil) + status, err := service.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(status) != 0 { + t.Fatalf("status = %#v", status) + } + formatted, err := service.File(context.Background(), "file.go") + if err != nil || formatted { + t.Fatalf("File = %v, %v", formatted, err) + } +} + +func TestCustomFormatterRunsAndReplacesFirstPlaceholder(t *testing.T) { + extensions := []string{".x"} + command := []string{"fmt", "--file=$FILE", "$FILE-$FILE"} + var calls []runCall + service := NewService( + Context{Directory: "/repo"}, + Configuration{Enabled: true, Overrides: []FormatterOverride{{ + Key: "custom", Extensions: &extensions, Command: &command, + Environment: map[string]string{"MODE": "fix"}, + }}}, + Dependencies{}, + func(_ context.Context, command []string, cwd string, environment map[string]string) (int, error) { + calls = append(calls, runCall{command, cwd, environment}) + return 0, nil + }, + ) + formatted, err := service.File(context.Background(), "/repo/a.x") + if err != nil { + t.Fatal(err) + } + if !formatted { + t.Fatal("custom formatter did not match") + } + want := []runCall{{ + command: []string{"fmt", "--file=/repo/a.x", "/repo/a.x-$FILE"}, + cwd: "/repo", environment: map[string]string{"MODE": "fix"}, + }} + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %#v, want %#v", calls, want) + } +} + +func TestAllMatchingFormattersRunAndFailuresAreIgnored(t *testing.T) { + extensions := []string{".x"} + first := []string{"first", "$FILE"} + second := []string{"second", "$FILE"} + var commands [][]string + service := NewService( + Context{Directory: "/repo"}, + Configuration{Enabled: true, Overrides: []FormatterOverride{ + {Key: "first", Extensions: &extensions, Command: &first}, + {Key: "second", Extensions: &extensions, Command: &second}, + }}, + Dependencies{}, + func(_ context.Context, command []string, _ string, _ map[string]string) (int, error) { + commands = append(commands, command) + if command[0] == "first" { + return 1, errors.New("spawn failed") + } + return 9, nil + }, + ) + formatted, err := service.File(context.Background(), "/repo/a.x") + if err != nil || !formatted { + t.Fatalf("File = %v, %v", formatted, err) + } + want := [][]string{{"first", "/repo/a.x"}, {"second", "/repo/a.x"}} + if !reflect.DeepEqual(commands, want) { + t.Fatalf("commands = %#v, want %#v", commands, want) + } +} + +func TestEnabledCommandsCacheOnlySuccessfulProbe(t *testing.T) { + available := false + checks := 0 + dependencies := Dependencies{Which: func(name string) (string, bool) { + if name != "gofmt" { + return "", false + } + checks++ + return "/bin/gofmt", available + }} + service := NewService(Context{}, Configuration{Enabled: true}, dependencies, nil) + for range 2 { + if _, err := service.Status(context.Background()); err != nil { + t.Fatal(err) + } + } + if checks != 2 { + t.Fatalf("false probe checks = %d, want 2", checks) + } + available = true + for range 2 { + if _, err := service.Status(context.Background()); err != nil { + t.Fatal(err) + } + } + if checks != 3 { + t.Fatalf("enabled probe checks = %d, want 3", checks) + } +} + +func TestPrettierDiscoveryAndExecution(t *testing.T) { + root := t.TempDir() + directory := filepath.Join(root, "src") + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(root, "package.json"), + []byte(`{"devDependencies":{"prettier":"3.0.0"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + var calls []runCall + service := NewService( + Context{Directory: directory, Worktree: root}, + Configuration{Enabled: true}, + Dependencies{NpmWhich: func(_ context.Context, name string) (string, bool) { + if name == "prettier" { + return "/npm/prettier", true + } + return "", false + }}, + func(_ context.Context, command []string, cwd string, environment map[string]string) (int, error) { + calls = append(calls, runCall{command, cwd, environment}) + return 0, nil + }, + ) + formatted, err := service.File(context.Background(), filepath.Join(directory, "a.ts")) + if err != nil || !formatted { + t.Fatalf("File = %v, %v", formatted, err) + } + want := []runCall{{ + command: []string{"/npm/prettier", "--write", filepath.Join(directory, "a.ts")}, + cwd: directory, environment: map[string]string{"BUN_BE_BUN": "1"}, + }} + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %#v, want %#v", calls, want) + } +} + +func TestLinkedRuffAndUVDisable(t *testing.T) { + service := NewService( + Context{}, + Configuration{Enabled: true, Overrides: []FormatterOverride{{Key: "uv", Disabled: true}}}, + Dependencies{}, + nil, + ) + status, err := service.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, item := range status { + if item.Name == "ruff" || item.Name == "uv" { + t.Fatalf("linked formatter survived: %#v", item) + } + } +} + +// Every built-in registers under the same string a config override names, so +// disabling one by the name it is known by removes it. These three are the +// cases that used to carry a separate config key and silently survive. +func TestDisablingABuiltinByNameRemovesIt(t *testing.T) { + service := NewService( + Context{}, + Configuration{Enabled: true, Overrides: []FormatterOverride{ + {Key: "clang-format", Disabled: true}, + {Key: "air", Disabled: true}, + {Key: "uv", Disabled: true}, + }}, + Dependencies{}, + nil, + ) + status, err := service.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, item := range status { + switch item.Name { + case "clang-format", "air", "uv": + t.Errorf("%s survived being disabled", item.Name) + } + } +} + +// Every built-in's key and name agree, so an override reaches the formatter +// it names instead of registering a second entry beside it. +func TestEveryBuiltinKeyMatchesItsName(t *testing.T) { + for _, item := range Builtins(Dependencies{}) { + if item.Key != item.Name { + t.Errorf("builtin key %q does not match name %q", item.Key, item.Name) + } + } +} + +func TestEmptyCommandStillCountsAsFormatter(t *testing.T) { + extensions := []string{".x"} + command := []string{} + var calls int + service := NewService( + Context{}, + Configuration{Enabled: true, Overrides: []FormatterOverride{{ + Key: "empty", Extensions: &extensions, Command: &command, + }}}, + Dependencies{}, + func(_ context.Context, command []string, _ string, _ map[string]string) (int, error) { + calls++ + if len(command) != 0 { + t.Fatalf("command = %#v", command) + } + return 1, errors.New("missing command") + }, + ) + formatted, err := service.File(context.Background(), "a.x") + if err != nil || !formatted || calls != 1 { + t.Fatalf("File = %v, %v; calls=%d", formatted, err, calls) + } +} + +func TestFileExtension(t *testing.T) { + cases := map[string]string{ + ".bashrc": "", + "a.ts": ".ts", + "a.": ".", + "..": "", + "...": ".", + "..foo": ".foo", + ".foo.bar": ".bar", + } + for path, want := range cases { + if got := fileExtension(path); got != want { + t.Errorf("fileExtension(%q) = %q, want %q", path, got, want) + } + } +} diff --git a/internal/seniordev/format/formatter.go b/internal/seniordev/format/formatter.go new file mode 100644 index 000000000..6407d8d30 --- /dev/null +++ b/internal/seniordev/format/formatter.go @@ -0,0 +1,300 @@ +//go:build !windows + +// Built-in formatter registry +package format + +import ( + "context" + "encoding/json" + "os" + "slices" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type Context struct { + Directory string + Worktree string +} + +type EnabledFunc func(context.Context, Context) ([]string, bool, error) + +type Info struct { + Key string + Name string + Environment map[string]string + Extensions []string + Enabled EnabledFunc +} + +// Dependencies isolates PATH, npm, filesystem, flags, and process probes. +type Dependencies struct { + Which func(string) (string, bool) + NpmWhich func(context.Context, string) (string, bool) + ExperimentalOxfmt bool +} + +func (d Dependencies) which(command string) (string, bool) { + if d.Which == nil { + return "", false + } + return d.Which(command) +} + +func (d Dependencies) npmWhich(ctx context.Context, pkg string) (string, bool) { + if d.NpmWhich == nil { + return "", false + } + return d.NpmWhich(ctx, pkg) +} + +// Builtins returns the built-in formatters. Every entry's Key, which is what +// a config override names, is also its Name, which is what it registers +// under, so an override reaches the formatter it names. The order is the +// order a file's candidates are collected in. +func Builtins(dependencies Dependencies) []Info { + pathFormatter := func(key, name string, extensions []string, args ...string) Info { + return Info{ + Key: key, Name: name, Extensions: extensions, + Enabled: func(_ context.Context, _ Context) ([]string, bool, error) { + match, ok := dependencies.which(name) + if !ok { + return nil, false, nil + } + command := []string{match} + command = append(command, args...) + command = append(command, "$FILE") + return command, true, nil + }, + } + } + bunEnvironment := map[string]string{"BUN_BE_BUN": "1"} + out := []Info{} + out = append(out, pathFormatter("gofmt", "gofmt", []string{".go"}, "-w")) + out = append(out, pathFormatter("mix", "mix", []string{".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"}, "format")) + out = append(out, Info{ + Key: "prettier", Name: "prettier", Environment: bunEnvironment, + Extensions: prettierExtensions(), + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + items := util.FindUp([]string{"package.json"}, instance.Directory, instance.Worktree) + for _, item := range items { + pkg, err := readPackageJSON(item) + if err != nil { + return nil, false, err + } + if dependencyPresent(pkg, "prettier") { + if bin, ok := dependencies.npmWhich(ctx, "prettier"); ok { + return []string{bin, "--write", "$FILE"}, true, nil + } + } + } + return nil, false, nil + }, + }) + out = append(out, Info{ + Key: "oxfmt", Name: "oxfmt", Environment: bunEnvironment, + Extensions: []string{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + if !dependencies.ExperimentalOxfmt { + return nil, false, nil + } + items := util.FindUp([]string{"package.json"}, instance.Directory, instance.Worktree) + for _, item := range items { + pkg, err := readPackageJSON(item) + if err != nil { + return nil, false, err + } + if dependencyPresent(pkg, "oxfmt") { + if bin, ok := dependencies.npmWhich(ctx, "oxfmt"); ok { + return []string{bin, "$FILE"}, true, nil + } + } + } + return nil, false, nil + }, + }) + out = append(out, Info{ + Key: "biome", Name: "biome", Environment: bunEnvironment, + Extensions: prettierExtensions(), + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + for _, config := range []string{"biome.json", "biome.jsonc"} { + if len(util.FindUp([]string{config}, instance.Directory, instance.Worktree)) > 0 { + if bin, ok := dependencies.npmWhich(ctx, "@biomejs/biome"); ok { + return []string{bin, "format", "--write", "$FILE"}, true, nil + } + } + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("zig", "zig", []string{".zig", ".zon"}, "fmt")) + out = append(out, Info{ + Key: "clang-format", Name: "clang-format", + Extensions: []string{".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + if len(util.FindUp([]string{".clang-format"}, instance.Directory, instance.Worktree)) == 0 { + return nil, false, nil + } + match, ok := dependencies.which("clang-format") + if !ok { + return nil, false, nil + } + return []string{match, "-i", "$FILE"}, true, nil + }, + }) + out = append(out, pathFormatter("ktlint", "ktlint", []string{".kt", ".kts"}, "-F")) + ruff := Info{ + Key: "ruff", Name: "ruff", Extensions: []string{".py", ".pyi"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + if _, ok := dependencies.which("ruff"); !ok { + return nil, false, nil + } + for _, config := range []string{"pyproject.toml", "ruff.toml", ".ruff.toml"} { + found := util.FindUp([]string{config}, instance.Directory, instance.Worktree) + if len(found) == 0 { + continue + } + if config == "pyproject.toml" { + content, err := util.ReadText(found[0]) + if err != nil { + return nil, false, err + } + if strings.Contains(content, "[tool.ruff]") { + return []string{"ruff", "format", "$FILE"}, true, nil + } + } else { + return []string{"ruff", "format", "$FILE"}, true, nil + } + } + for _, dependency := range []string{"requirements.txt", "pyproject.toml", "Pipfile"} { + found := util.FindUp([]string{dependency}, instance.Directory, instance.Worktree) + if len(found) == 0 { + continue + } + content, err := util.ReadText(found[0]) + if err != nil { + return nil, false, err + } + if strings.Contains(content, "ruff") { + return []string{"ruff", "format", "$FILE"}, true, nil + } + } + return nil, false, nil + }, + } + out = append(out, ruff) + out = append(out, Info{ + Key: "air", Name: "air", Extensions: []string{".R"}, + Enabled: func(ctx context.Context, _ Context) ([]string, bool, error) { + air, ok := dependencies.which("air") + if !ok { + return nil, false, nil + } + result, _ := util.TextProcess(ctx, []string{air, "--help"}, util.RunOptions{NoThrow: true}) + firstLine := strings.Split(result.Text, "\n")[0] + if result.Code == 0 && strings.Contains(firstLine, "R language") && strings.Contains(firstLine, "formatter") { + return []string{air, "format", "$FILE"}, true, nil + } + return nil, false, nil + }, + }) + out = append(out, Info{ + Key: "uv", Name: "uv", Extensions: []string{".py", ".pyi"}, + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + if _, enabled, err := ruff.Enabled(ctx, instance); err != nil || enabled { + return nil, false, err + } + uv, ok := dependencies.which("uv") + if !ok { + return nil, false, nil + } + result, _ := util.RunProcess(ctx, []string{uv, "format", "--help"}, util.RunOptions{NoThrow: true}) + if result.Code == 0 { + return []string{uv, "format", "--", "$FILE"}, true, nil + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("rubocop", "rubocop", []string{".rb", ".rake", ".gemspec", ".ru"}, "--autocorrect")) + out = append(out, pathFormatter("standardrb", "standardrb", []string{".rb", ".rake", ".gemspec", ".ru"}, "--fix")) + out = append(out, pathFormatter("htmlbeautifier", "htmlbeautifier", []string{".erb", ".html.erb"})) + out = append(out, pathFormatter("dart", "dart", []string{".dart"}, "format")) + out = append(out, Info{ + Key: "ocamlformat", Name: "ocamlformat", Extensions: []string{".ml", ".mli"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + if _, ok := dependencies.which("ocamlformat"); !ok { + return nil, false, nil + } + if len(util.FindUp([]string{".ocamlformat"}, instance.Directory, instance.Worktree)) > 0 { + return []string{"ocamlformat", "-i", "$FILE"}, true, nil + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("terraform", "terraform", []string{".tf", ".tfvars"}, "fmt")) + out = append(out, pathFormatter("latexindent", "latexindent", []string{".tex"}, "-w", "-s")) + out = append(out, pathFormatter("gleam", "gleam", []string{".gleam"}, "format")) + out = append(out, pathFormatter("shfmt", "shfmt", []string{".sh", ".bash"}, "-w")) + out = append(out, pathFormatter("nixfmt", "nixfmt", []string{".nix"})) + out = append(out, pathFormatter("rustfmt", "rustfmt", []string{".rs"})) + out = append(out, Info{ + Key: "pint", Name: "pint", Extensions: []string{".php"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + items := util.FindUp([]string{"composer.json"}, instance.Directory, instance.Worktree) + for _, item := range items { + data, err := os.ReadFile(item) + if err != nil { + return nil, false, err + } + var composer struct { + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` + } + if err := json.Unmarshal(data, &composer); err != nil { + return nil, false, err + } + if composer.Require["laravel/pint"] != "" || composer.RequireDev["laravel/pint"] != "" { + return []string{"./vendor/bin/pint", "$FILE"}, true, nil + } + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("ormolu", "ormolu", []string{".hs"}, "-i")) + out = append(out, pathFormatter("cljfmt", "cljfmt", []string{".clj", ".cljs", ".cljc", ".edn"}, "fix", "--quiet")) + out = append(out, pathFormatter("dfmt", "dfmt", []string{".d"}, "-i")) + // Sorted by key so the registration order is deterministic. + slices.SortFunc(out, func(left, right Info) int { + return strings.Compare(left.Key, right.Key) + }) + return out +} + +func prettierExtensions() []string { + return []string{ + ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", + ".html", ".htm", ".css", ".scss", ".sass", ".less", ".vue", ".svelte", + ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".md", ".mdx", + ".graphql", ".gql", + } +} + +type packageJSON struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +func readPackageJSON(path string) (packageJSON, error) { + data, err := os.ReadFile(path) + if err != nil { + return packageJSON{}, err + } + var pkg packageJSON + err = json.Unmarshal(data, &pkg) + return pkg, err +} + +func dependencyPresent(pkg packageJSON, name string) bool { + return pkg.Dependencies[name] != "" || pkg.DevDependencies[name] != "" +} diff --git a/internal/seniordev/id/id.go b/internal/seniordev/id/id.go new file mode 100644 index 000000000..465151281 --- /dev/null +++ b/internal/seniordev/id/id.go @@ -0,0 +1,197 @@ +//go:build !windows + +// Package id generates prefixed, time-ordered identifiers. IDs encode a +// millisecond timestamp and per-timestamp counter in six big-endian bytes, +// followed by 14 modulo-biased base62 characters. +package id + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "strings" + "sync" + "time" +) + +const ( + encodedLength = 26 + randomLength = encodedLength - 12 + base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +var prefixes = map[string]string{ + "event": "evt", + "session": "ses", + "message": "msg", + "permission": "per", + "question": "que", + "user": "usr", + "part": "prt", + "pty": "pty", + "tool": "tool", + "workspace": "wrk", + "entry": "ent", + "account": "act", +} + +// Direction selects the timestamp byte ordering used by Create. +type Direction string + +const ( + AscendingDirection Direction = "ascending" + DescendingDirection Direction = "descending" +) + +// Generator owns the monotonic counter and its injectable side effects. +type Generator struct { + mu sync.Mutex + lastTimestamp int64 + counter uint64 + now func() int64 + random io.Reader +} + +// NewGenerator constructs an independent ID generator. Nil inputs use the +// wall clock and crypto/rand. +func NewGenerator(now func() int64, random io.Reader) *Generator { + if now == nil { + now = func() int64 { return time.Now().UnixMilli() } + } + if random == nil { + random = rand.Reader + } + return &Generator{now: now, random: random} +} + +var defaultGenerator = NewGenerator(nil, nil) + +// Prefix returns the short prefix for an entity kind. +func Prefix(kind string) (string, bool) { + value, ok := prefixes[kind] + return value, ok +} + +// SchemaAccepts reports whether value carries the prefix for kind. +func SchemaAccepts(kind, value string) bool { + prefix, ok := Prefix(kind) + return ok && strings.HasPrefix(value, prefix) +} + +// Ascending returns an ascending ID for kind. An absent or empty given value +// generates a new ID; any other value is prefix-checked and returned verbatim. +func Ascending(kind string, given ...string) (string, error) { + return defaultGenerator.generateKind(kind, AscendingDirection, given...) +} + +// Descending returns a descending ID for kind; given is handled as in Ascending. +func Descending(kind string, given ...string) (string, error) { + return defaultGenerator.generateKind(kind, DescendingDirection, given...) +} + +// Ascending generates with this Generator. +func (g *Generator) Ascending(kind string, given ...string) (string, error) { + return g.generateKind(kind, AscendingDirection, given...) +} + +// Descending generates with this Generator. +func (g *Generator) Descending(kind string, given ...string) (string, error) { + return g.generateKind(kind, DescendingDirection, given...) +} + +func (g *Generator) generateKind(kind string, direction Direction, given ...string) (string, error) { + prefix, ok := Prefix(kind) + if !ok { + return "", fmt.Errorf("unknown ID prefix %s", kind) + } + if len(given) == 0 || given[0] == "" { + return g.Create(prefix, direction) + } + if !strings.HasPrefix(given[0], prefix) { + return "", fmt.Errorf("ID %s does not start with %s", given[0], prefix) + } + return given[0], nil +} + +// Create uses the process-global generator and an optional explicit timestamp. +func Create(prefix string, direction Direction, timestamp ...int64) (string, error) { + return defaultGenerator.Create(prefix, direction, timestamp...) +} + +// Create emits an ID from this Generator. State resets only when the current +// timestamp differs from the previous one; moving the clock backwards is +// accepted. +func (g *Generator) Create(prefix string, direction Direction, timestamp ...int64) (string, error) { + current := g.now() + if len(timestamp) > 0 { + current = timestamp[0] + } + + g.mu.Lock() + defer g.mu.Unlock() + if current != g.lastTimestamp { + g.lastTimestamp = current + g.counter = 0 + } + g.counter++ + + // Only the low 48 bits are encoded. Converting to uint64 before the shifts + // gives well-defined two's-complement bits for negative timestamps and for + // the inverted descending value. + now := uint64(current)*0x1000 + g.counter + if direction == DescendingDirection { + now = ^now + } + var timeBytes [6]byte + for i := range 6 { + timeBytes[i] = byte(now >> uint(40-8*i)) + } + random, err := g.randomBase62(randomLength) + if err != nil { + return "", err + } + return prefix + "_" + hex.EncodeToString(timeBytes[:]) + random, nil +} + +func (g *Generator) randomBase62(length int) (string, error) { + bytes := make([]byte, length) + if _, err := io.ReadFull(g.random, bytes); err != nil { + return "", err + } + out := make([]byte, length) + for i, value := range bytes { + out[i] = base62[int(value)%len(base62)] + } + return string(out), nil +} + +// Timestamp extracts the millisecond timestamp from an ascending ID. It does +// not invert descending IDs. +func Timestamp(value string) (int64, error) { + prefix := strings.Split(value, "_")[0] + start := len(prefix) + 1 + end := start + 12 + if start > len(value) { + start = len(value) + } + if end > len(value) { + end = len(value) + } + hexPart := value[start:end] + if hexPart == "" { + return 0, fmt.Errorf("invalid ID %q", value) + } + bytes, err := hex.DecodeString(hexPart) + if err != nil || len(bytes) == 0 { + if err == nil { + err = fmt.Errorf("empty timestamp") + } + return 0, fmt.Errorf("invalid ID %q: %w", value, err) + } + var encoded uint64 + for _, value := range bytes { + encoded = encoded<<8 | uint64(value) + } + return int64(encoded / 0x1000), nil +} diff --git a/internal/seniordev/id/id_test.go b/internal/seniordev/id/id_test.go new file mode 100644 index 000000000..90573f80a --- /dev/null +++ b/internal/seniordev/id/id_test.go @@ -0,0 +1,42 @@ +//go:build !windows + +package id + +import ( + "bytes" + "errors" + "testing" +) + +func TestCounterSpillsIntoTimestampBits(t *testing.T) { + entropy := bytes.NewReader(make([]byte, randomLength*4097)) + generator := NewGenerator(func() int64 { return 10 }, entropy) + var value string + for range 4097 { + var err error + value, err = generator.Create("evt", AscendingDirection, 10) + if err != nil { + t.Fatal(err) + } + } + got, err := Timestamp(value) + if err != nil { + t.Fatal(err) + } + if got != 11 { + t.Fatalf("timestamp after counter spill = %d, want 11", got) + } +} + +func TestRandomFailurePropagates(t *testing.T) { + generator := NewGenerator(func() int64 { return 1 }, failingReader{}) + if _, err := generator.Create("evt", AscendingDirection); !errors.Is(err, errEntropy) { + t.Fatalf("error = %v", err) + } +} + +var errEntropy = errors.New("entropy failed") + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errEntropy } diff --git a/internal/seniordev/jsonutil/jsonutil.go b/internal/seniordev/jsonutil/jsonutil.go new file mode 100644 index 000000000..5085b3aba --- /dev/null +++ b/internal/seniordev/jsonutil/jsonutil.go @@ -0,0 +1,35 @@ +//go:build !windows + +// Package jsonutil wraps encoding/json for the two encodings senior-dev uses +// everywhere: compact and two-space indented, both without HTML escaping so +// that `<`, `>` and `&` inside model-visible text stay readable. +package jsonutil + +import ( + "bytes" + "encoding/json" +) + +// Marshal encodes v as compact JSON without escaping HTML characters. +func Marshal(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +// MarshalIndent encodes v as two-space indented JSON without escaping HTML +// characters. +func MarshalIndent(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} diff --git a/internal/seniordev/modelsdev/models.go b/internal/seniordev/modelsdev/models.go new file mode 100644 index 000000000..d85501a07 --- /dev/null +++ b/internal/seniordev/modelsdev/models.go @@ -0,0 +1,444 @@ +//go:build !windows + +// Package modelsdev loads the models.dev provider/model catalog used to price +// and describe models. The catalog is runtime data: this package does not +// carry a baked model or pricing table. +package modelsdev + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" +) + +const ( + DefaultSource = "https://models.dev" + cacheTTL = 5 * time.Minute + refreshEvery = 60 * time.Minute + fetchTimeout = 10 * time.Second + lockTimeout = 5 * time.Minute + lockPoll = 100 * time.Millisecond +) + +// Cost is the models.dev cost block. Pointer fields preserve the distinction +// between an omitted optional rate and an explicit zero. +type Cost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead *float64 `json:"cache_read,omitempty"` + CacheWrite *float64 `json:"cache_write,omitempty"` + ContextOver200K *Cost `json:"context_over_200k,omitempty"` +} + +type Limit struct { + Context float64 `json:"context"` + Input *float64 `json:"input,omitempty"` + Output float64 `json:"output"` +} + +type Modalities struct { + Input []string `json:"input"` + Output []string `json:"output"` +} + +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Attachment *bool `json:"attachment,omitempty"` + Reasoning *bool `json:"reasoning,omitempty"` + Temperature *bool `json:"temperature,omitempty"` + ToolCall *bool `json:"tool_call,omitempty"` + Modalities *Modalities `json:"modalities,omitempty"` + Cost *Cost `json:"cost,omitempty"` + Limit Limit `json:"limit"` +} + +type Provider struct { + ID string `json:"id"` + Name string `json:"name"` + Models map[string]Model `json:"models"` +} + +type Catalog map[string]Provider + +// Resolve addresses the provider and model maps by their catalog keys. +// Model.id is API metadata, not a lookup alias. +func (catalog Catalog) Resolve(providerID, modelID string) (calc.Model, error) { + provider, ok := catalog[providerID] + if !ok { + return calc.Model{}, fmt.Errorf("models.dev: provider %q not found", providerID) + } + model, ok := provider.Models[modelID] + if !ok { + return calc.Model{}, fmt.Errorf("models.dev: model %q not found for provider %q", modelID, providerID) + } + return calc.Model{Cost: projectCost(model.Cost), Limit: calc.ModelLimit{ + Context: model.Limit.Context, + Input: model.Limit.Input, + Output: model.Limit.Output, + }, Capabilities: projectCapabilities(model)}, nil +} + +func projectCapabilities(model Model) calc.ModelCapabilities { + capabilities := calc.ModelCapabilities{ + Attachment: boolOr(model.Attachment, false), + Reasoning: boolOr(model.Reasoning, false), + // An absent temperature flag is support, not refusal: the request-side + // gate drops a configured temperature only on a declared false. + Temperature: boolOr(model.Temperature, true), + ToolCall: boolOr(model.ToolCall, true), + Input: modalityMap(nil), + Output: modalityMap(nil), + } + if model.Modalities != nil { + capabilities.Input = modalityMap(model.Modalities.Input) + capabilities.Output = modalityMap(model.Modalities.Output) + } + return capabilities +} + +func boolOr(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + +func modalityMap(values []string) map[string]bool { + result := map[string]bool{ + "text": false, "audio": false, "image": false, "video": false, "pdf": false, + } + for _, value := range values { + if _, ok := result[value]; ok { + result[value] = true + } + } + return result +} + +func projectCost(cost *Cost) *calc.ModelCost { + result := &calc.ModelCost{Cache: &calc.CacheCost{}} + if cost == nil { + return result + } + result.Input = cost.Input + result.Output = cost.Output + if cost.CacheRead != nil { + result.Cache.Read = *cost.CacheRead + } + if cost.CacheWrite != nil { + result.Cache.Write = *cost.CacheWrite + } + if cost.ContextOver200K != nil { + over := cost.ContextOver200K + result.ExperimentalOver200K = &calc.Over200KCost{ + Cache: &calc.CacheCost{}, Input: over.Input, Output: over.Output, + } + if over.CacheRead != nil { + result.ExperimentalOver200K.Cache.Read = *over.CacheRead + } + if over.CacheWrite != nil { + result.ExperimentalOver200K.Cache.Write = *over.CacheWrite + } + } + return result +} + +type Options struct { + Source string + CatalogPath string + CacheDir string + DisableFetch bool + HTTPClient *http.Client + Version string + Now func() time.Time + Sleep func(context.Context, time.Duration) error + LockTimeout time.Duration + LockPoll time.Duration +} + +type Client struct { + options Options + cachePath string + + mu sync.Mutex + loaded bool + catalog Catalog + loadErr error +} + +// New constructs a catalog client. Empty options select models.dev and the +// user cache directory. +func New(options Options) (*Client, error) { + if options.Source == "" { + options.Source = DefaultSource + } + options.Source = strings.TrimRight(options.Source, "/") + if options.CacheDir == "" { + base, err := os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf("models.dev cache directory: %w", err) + } + options.CacheDir = filepath.Join(base, "senior-dev") + } + if options.HTTPClient == nil { + options.HTTPClient = http.DefaultClient + } + if options.Now == nil { + options.Now = time.Now + } + if options.Sleep == nil { + options.Sleep = sleepContext + } + if options.LockTimeout <= 0 { + options.LockTimeout = lockTimeout + } + if options.LockPoll <= 0 { + options.LockPoll = lockPoll + } + name := "models.json" + if options.Source != DefaultSource { + digest := sha1.Sum([]byte(options.Source)) + name = "models-" + hex.EncodeToString(digest[:]) + ".json" + } + return &Client{options: options, cachePath: filepath.Join(options.CacheDir, name)}, nil +} + +// NewFromEnv reads the catalog settings from the environment. Only "true" and +// "1" (case-insensitive) enable SENIOR_DEV_DISABLE_MODELS_FETCH. +func NewFromEnv(version string) (*Client, error) { + return New(Options{ + Source: os.Getenv("SENIOR_DEV_MODELS_URL"), + CatalogPath: os.Getenv("SENIOR_DEV_MODELS_PATH"), + DisableFetch: truthy(os.Getenv("SENIOR_DEV_DISABLE_MODELS_FETCH")), + Version: version, + }) +} + +func truthy(value string) bool { + value = strings.ToLower(value) + return value == "true" || value == "1" +} + +// CachePath is exposed for diagnostics and tests. +func (client *Client) CachePath() string { return client.cachePath } + +// Get is memoized for the process lifetime. Population is disk -> (no bundled +// snapshot) -> disabled-fetch empty catalog -> network. +func (client *Client) Get(ctx context.Context) (Catalog, error) { + client.mu.Lock() + defer client.mu.Unlock() + if client.loaded { + return client.catalog, client.loadErr + } + client.catalog, client.loadErr = client.populate(ctx) + client.loaded = true + return client.catalog, client.loadErr +} + +func (client *Client) populate(ctx context.Context) (Catalog, error) { + path := client.cachePath + if client.options.CatalogPath != "" { + path = client.options.CatalogPath + } + if catalog, err := readCatalog(path); err == nil && catalog != nil { + return catalog, nil + } + // No catalog is bundled: with fetching disabled the catalog is empty. + if client.options.DisableFetch { + return Catalog{}, nil + } + var catalog Catalog + err := client.withFileLock(ctx, func() error { + raw, err := client.fetch(ctx) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &catalog); err != nil { + return fmt.Errorf("models.dev decode: %w", err) + } + return writeCache(client.cachePath, raw) + }) + return catalog, err +} + +// Refresh refetches a stale catalog: freshness is checked on the generated +// cache path (not SENIOR_DEV_MODELS_PATH), checked again under the cross-process +// lock, and fetch errors leave the memoized catalog untouched. +func (client *Client) Refresh(ctx context.Context, force bool) error { + if !force && client.fresh() { + return nil + } + var catalog Catalog + err := client.withFileLock(ctx, func() error { + if !force && client.fresh() { + return nil + } + raw, err := client.fetch(ctx) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &catalog); err != nil { + return fmt.Errorf("models.dev decode: %w", err) + } + return writeCache(client.cachePath, raw) + }) + if err != nil || catalog == nil { + return err + } + client.mu.Lock() + client.catalog = catalog + client.loadErr = nil + client.loaded = true + client.mu.Unlock() + return nil +} + +// StartRefresh performs the startup refresh and repeats one hour after each +// completion. +func (client *Client) StartRefresh(ctx context.Context, report func(error)) { + if client.options.DisableFetch { + return + } + go func() { + for { + if err := client.Refresh(ctx, false); err != nil && report != nil { + report(err) + } + timer := time.NewTimer(refreshEvery) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + }() +} + +func (client *Client) fresh() bool { + info, err := os.Stat(client.cachePath) + if err != nil { + return false + } + return client.options.Now().Sub(info.ModTime()) < cacheTTL +} + +func readCatalog(path string) (Catalog, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var catalog Catalog + if err := json.Unmarshal(raw, &catalog); err != nil { + return nil, err + } + return catalog, nil +} + +func writeCache(path string, raw []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, raw, 0o644) +} + +func (client *Client) fetch(ctx context.Context) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + var last error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + if err := client.options.Sleep(ctx, time.Duration(1<<(attempt-1))*200*time.Millisecond); err != nil { + return nil, err + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.options.Source+"/api.json", nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "senior-dev/"+client.options.Version) + response, err := client.options.HTTPClient.Do(request) + if err != nil { + last = err + continue + } + raw, readErr := io.ReadAll(response.Body) + closeErr := response.Body.Close() + if readErr != nil { + last = readErr + continue + } + if closeErr != nil { + last = closeErr + continue + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + last = fmt.Errorf("models.dev: GET %s: %s", request.URL, response.Status) + if response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests && response.StatusCode < 500 { + return nil, last + } + continue + } + return raw, nil + } + if last == nil { + last = errors.New("models.dev: fetch failed") + } + return nil, last +} + +func (client *Client) withFileLock(ctx context.Context, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(client.cachePath), 0o755); err != nil { + return err + } + lock, err := os.OpenFile(client.cachePath+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lock.Close() + timer := time.NewTimer(client.options.LockTimeout) + defer timer.Stop() + for { + err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return fmt.Errorf("Timed out waiting for lock: models-dev:%s", client.cachePath) + case <-time.After(client.options.LockPoll): + } + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck + return fn() +} + +func sleepContext(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 + } +} diff --git a/internal/seniordev/modelsdev/models_test.go b/internal/seniordev/modelsdev/models_test.go new file mode 100644 index 000000000..aa6f23fe5 --- /dev/null +++ b/internal/seniordev/modelsdev/models_test.go @@ -0,0 +1,297 @@ +//go:build !windows + +package modelsdev + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func fixture(t *testing.T) []byte { + t.Helper() + raw, err := os.ReadFile("testdata/catalog.json") + if err != nil { + t.Fatal(err) + } + return raw +} + +func response(request *http.Request, status int, body []byte) *http.Response { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Body: io.NopCloser(strings.NewReader(string(body))), + Request: request, + Header: make(http.Header), + } +} + +func TestCatalogProjectsCostsAndLimits(t *testing.T) { + client, err := New(Options{ + CatalogPath: "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) + } + model, err := catalog.Resolve("openrouter", "fixture/vendor-model") + if err != nil { + t.Fatal(err) + } + if model.Cost == nil || model.Cost.Input != 1.25 || model.Cost.Output != 4.5 || + model.Cost.Cache == nil || model.Cost.Cache.Read != 0.125 || model.Cost.Cache.Write != 1.5 || + model.Cost.ExperimentalOver200K == nil || model.Cost.ExperimentalOver200K.Input != 2.5 || + model.Limit.Context != 240_000 || model.Limit.Input == nil || *model.Limit.Input != 220_000 || + model.Limit.Output != 12_000 { + t.Fatalf("projected model = %#v", model) + } + if !model.Capabilities.Attachment || !model.Capabilities.Reasoning || + !model.Capabilities.Temperature || !model.Capabilities.ToolCall || + !model.Capabilities.Input["text"] || !model.Capabilities.Input["image"] || + model.Capabilities.Input["audio"] || !model.Capabilities.Output["text"] || + model.Capabilities.Output["image"] { + t.Fatalf("projected capabilities = %#v", model.Capabilities) + } + withoutCost, err := catalog.Resolve("openrouter", "fixture/no-cost") + if err != nil { + t.Fatal(err) + } + if withoutCost.Cost == nil || withoutCost.Cost.Input != 0 || withoutCost.Cost.Output != 0 { + t.Fatalf("missing cost projection = %#v", withoutCost.Cost) + } + if withoutCost.Capabilities.Attachment || withoutCost.Capabilities.Reasoning || + !withoutCost.Capabilities.Temperature || !withoutCost.Capabilities.ToolCall || + withoutCost.Capabilities.Input["text"] || withoutCost.Capabilities.Output["text"] { + t.Fatalf("missing modalities/default projection = %#v", withoutCost.Capabilities) + } + if _, err := catalog.Resolve("openrouter", "fixture/unknown"); err == nil { + t.Fatal("unknown model unexpectedly resolved") + } + // An absent temperature flag must read as support: the request-side gate + // drops a configured temperature on false, and many models.dev entries + // omit the flag entirely. + defaults := projectCapabilities(Model{}) + if defaults.Attachment || defaults.Reasoning || !defaults.Temperature || !defaults.ToolCall || + defaults.Input["text"] || defaults.Output["text"] { + t.Fatalf("absent capability defaults = %#v", defaults) + } +} + +func TestGetFetchesOnceAndReusesFreshDiskCache(t *testing.T) { + var mu sync.Mutex + requests := 0 + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + mu.Lock() + requests++ + mu.Unlock() + if request.URL.String() != "https://catalog.example/api.json" { + t.Fatalf("request URL = %s", request.URL) + } + if request.Header.Get("User-Agent") != "senior-dev/test-version" { + t.Fatalf("User-Agent = %q", request.Header.Get("User-Agent")) + } + return response(request, http.StatusOK, fixture(t)), nil + }) + options := Options{ + Source: "https://catalog.example", CacheDir: t.TempDir(), + HTTPClient: &http.Client{Transport: transport}, Version: "test-version", + Sleep: func(context.Context, time.Duration) error { return nil }, + } + first, err := New(options) + if err != nil { + t.Fatal(err) + } + if first.options.LockTimeout != 5*time.Minute { + t.Fatalf("default lock timeout = %v, want 5m", first.options.LockTimeout) + } + if _, err := first.Get(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := first.Get(context.Background()); err != nil { + t.Fatal(err) + } + second, err := New(options) + if err != nil { + t.Fatal(err) + } + if _, err := second.Get(context.Background()); err != nil { + t.Fatal(err) + } + if err := second.Refresh(context.Background(), false); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if requests != 1 { + t.Fatalf("catalog requests = %d, want 1", requests) + } + if filepath.Base(first.CachePath()) != "models-871608c17971dbac7e10503763c16cb91f1a52f7.json" { + t.Fatalf("custom-source cache path = %s", first.CachePath()) + } +} + +func TestColdUnreachableCatalogReturnsErrorAndDisabledFetchReturnsEmpty(t *testing.T) { + requests := 0 + client, err := New(Options{ + Source: "https://unreachable.example", CacheDir: t.TempDir(), + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requests++ + return nil, errors.New("offline") + })}, + Sleep: func(context.Context, time.Duration) error { return nil }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := client.Get(context.Background()); err == nil || !strings.Contains(err.Error(), "offline") { + t.Fatalf("cold unreachable error = %v", err) + } + if requests != 3 { + t.Fatalf("transient fetch attempts = %d, want 3", requests) + } + + disabled, err := New(Options{ + CacheDir: t.TempDir(), DisableFetch: true, + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("disabled catalog performed network I/O") + return nil, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + catalog, err := disabled.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(catalog) != 0 { + t.Fatalf("disabled catalog = %#v", catalog) + } + if _, err := catalog.Resolve("openrouter", "fixture/vendor-model"); err == nil { + t.Fatal("model resolved from disabled empty catalog") + } +} + +func TestStaleDiskCatalogWinsOverUnreachableNetwork(t *testing.T) { + cacheDir := t.TempDir() + cachePath := filepath.Join(cacheDir, "models.json") + if err := os.WriteFile(cachePath, fixture(t), 0o644); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-24 * time.Hour) + if err := os.Chtimes(cachePath, stale, stale); err != nil { + t.Fatal(err) + } + client, err := New(Options{ + CacheDir: cacheDir, + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("initial Get fetched instead of using the stale disk catalog") + return nil, errors.New("offline") + })}, + }) + if err != nil { + t.Fatal(err) + } + catalog, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := catalog.Resolve("openrouter", "fixture/vendor-model"); err != nil { + t.Fatal(err) + } +} + +func TestCatalogLockHonorsCancellationAndTimeout(t *testing.T) { + cacheDir := t.TempDir() + client, err := New(Options{ + CacheDir: cacheDir, LockTimeout: 30 * time.Millisecond, LockPoll: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(client.CachePath()), 0o755); err != nil { + t.Fatal(err) + } + lock, err := os.OpenFile(client.CachePath()+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatal(err) + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := client.withFileLock(ctx, func() error { return nil }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled lock error = %v", err) + } + started := time.Now() + err = client.withFileLock(context.Background(), func() error { return nil }) + if err == nil || !strings.Contains(err.Error(), "Timed out waiting for lock: models-dev:") { + t.Fatalf("timeout error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("short test timeout took %v", elapsed) + } +} + +func TestRefreshLockTimeoutKeepsMemoizedCatalog(t *testing.T) { + client, err := New(Options{ + CatalogPath: "testdata/catalog.json", CacheDir: t.TempDir(), + LockTimeout: 20 * time.Millisecond, LockPoll: time.Millisecond, + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("timed-out refresh reached the network") + return nil, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + before, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + lock, err := os.OpenFile(client.CachePath()+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatal(err) + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck + if err := client.Refresh(context.Background(), true); err == nil || + !strings.Contains(err.Error(), "Timed out waiting for lock") { + t.Fatalf("refresh timeout error = %v", err) + } + after, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(after) != len(before) { + t.Fatalf("catalog size after timeout = %d, want %d", len(after), len(before)) + } + if _, err := after.Resolve("openrouter", "fixture/vendor-model"); err != nil { + t.Fatalf("memoized catalog lost after refresh timeout: %v", err) + } +} diff --git a/internal/seniordev/modelsdev/testdata/catalog.json b/internal/seniordev/modelsdev/testdata/catalog.json new file mode 100644 index 000000000..2ac056dba --- /dev/null +++ b/internal/seniordev/modelsdev/testdata/catalog.json @@ -0,0 +1,53 @@ +{ + "openrouter": { + "id": "openrouter", + "name": "OpenRouter", + "env": ["OPENROUTER_API_KEY"], + "models": { + "fixture/vendor-model": { + "id": "fixture/vendor-model", + "name": "Fixture Vendor Model", + "family": "fixture", + "release_date": "2026-01-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "cost": { + "input": 1.25, + "output": 4.5, + "cache_read": 0.125, + "cache_write": 1.5, + "context_over_200k": { + "input": 2.5, + "output": 9, + "cache_read": 0.25, + "cache_write": 3 + } + }, + "limit": { + "context": 240000, + "input": 220000, + "output": 12000 + }, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + }, + "fixture/no-cost": { + "id": "fixture/no-cost", + "name": "Fixture Model Without Cost", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": false, + "temperature": true, + "tool_call": true, + "limit": { + "context": 64000, + "output": 4096 + } + } + } + } +} diff --git a/internal/seniordev/netpolicy/blackhole.go b/internal/seniordev/netpolicy/blackhole.go new file mode 100644 index 000000000..947e177e1 --- /dev/null +++ b/internal/seniordev/netpolicy/blackhole.go @@ -0,0 +1,128 @@ +//go:build !windows + +package netpolicy + +import ( + "errors" + "fmt" + "net" + "sync" + "time" +) + +const noProxyHosts = "localhost,127.0.0.1,::1" + +// ShellProxyEnv returns the environment entries that gate proxy-honoring +// network clients (curl, wget, pip, npm, git-over-HTTPS) in shell children, +// or nil when the policy leaves bash open. Entries are meant to be appended +// AFTER os.Environ(): exec dedup is last-entry-wins, so they override any +// proxy the parent environment carries. +// +// The proxy address is a local black-hole listener that answers every +// connection with an explicit 403 naming the policy, so a blocked command +// fails with a legible, non-retryable error instead of a hang. If the +// listener cannot be (re)established the entries point at 127.0.0.1:1 +// instead — connection refused, still fail-closed. +func ShellProxyEnv(p Policy) []string { + if !p.Restricted() { + return nil + } + address, err := BlackholeAddr() + if err != nil { + address = "127.0.0.1:1" + } + proxy := "http://" + address + return []string{ + "HTTP_PROXY=" + proxy, + "HTTPS_PROXY=" + proxy, + "http_proxy=" + proxy, + "https_proxy=" + proxy, + "NO_PROXY=" + noProxyHosts, + "no_proxy=" + noProxyHosts, + } +} + +var blackhole struct { + mu sync.Mutex + address string + listener net.Listener +} + +// BlackholeAddr returns the address of the per-process black-hole listener, +// starting or restarting it as needed. Callers invoke this once per shell +// spawn, so a listener that died costs at most one command of ECONNREFUSED +// (still fail-closed) before the next spawn restores the legible 403. +func BlackholeAddr() (string, error) { + blackhole.mu.Lock() + defer blackhole.mu.Unlock() + if blackhole.address != "" && blackholeAlive(blackhole.address) { + return blackhole.address, nil + } + // Close a listener that failed its health check before replacing it, so + // a spurious dial timeout cannot leak sockets and serving goroutines. + if blackhole.listener != nil { + _ = blackhole.listener.Close() + blackhole.listener = nil + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + blackhole.address = "" + return "", err + } + blackhole.address = listener.Addr().String() + blackhole.listener = listener + go serveBlackhole(listener) + return blackhole.address, nil +} + +func blackholeAlive(address string) bool { + connection, err := net.DialTimeout("tcp", address, 250*time.Millisecond) + if err != nil { + return false + } + _ = connection.Close() + return true +} + +func serveBlackhole(listener net.Listener) { + body := "[network-policy] network access is restricted for this run (" + EnvMode + "). " + + "This proxy rejects all traffic. Do not retry, and do not attempt the same " + + "access through other commands - work from local repository content instead.\n" + response := fmt.Sprintf( + "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + len(body), body, + ) + // Closing the listener on exit is load-bearing: the dial-based health + // check in blackholeAlive succeeds against any open listen socket (the + // kernel completes handshakes from the backlog even with no accept loop), + // so a listener abandoned open would pass health checks forever while + // hanging every proxied client instead of serving the fail-fast 403. + defer listener.Close() + backoff := 5 * time.Millisecond + for { + connection, err := listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + // Transient pressure (EMFILE and friends): back off and keep + // serving rather than dying under exactly the load that most + // needs the legible refusal. Mirrors net/http Server.Serve. + time.Sleep(backoff) + if backoff < time.Second { + backoff *= 2 + } + continue + } + backoff = 5 * time.Millisecond + go func(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(2 * time.Second)) + // Absorb the request (or CONNECT) line so clients that wait to + // finish writing before reading do not see a send error. + buffer := make([]byte, 1024) + _, _ = c.Read(buffer) + _, _ = c.Write([]byte(response)) + }(connection) + } +} diff --git a/internal/seniordev/netpolicy/blackhole_test.go b/internal/seniordev/netpolicy/blackhole_test.go new file mode 100644 index 000000000..4798e7d49 --- /dev/null +++ b/internal/seniordev/netpolicy/blackhole_test.go @@ -0,0 +1,79 @@ +//go:build !windows + +package netpolicy + +import ( + "bufio" + "net" + "net/http" + "strings" + "testing" + "time" +) + +func TestBlackholeAnswersWithPolicy403(t *testing.T) { + address, err := BlackholeAddr() + if err != nil { + t.Skipf("sandbox blocks loopback listeners: %v", err) + } + connection, err := net.DialTimeout("tcp", address, time.Second) + if err != nil { + t.Fatalf("dial blackhole: %v", err) + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := connection.Write([]byte("GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")); err != nil { + t.Fatalf("write request: %v", err) + } + response, err := http.ReadResponse(bufio.NewReader(connection), nil) + if err != nil { + t.Fatalf("read response: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", response.StatusCode) + } + body := make([]byte, 512) + n, _ := response.Body.Read(body) + if !strings.Contains(string(body[:n]), "[network-policy]") { + t.Fatalf("body %q missing policy marker", body[:n]) + } +} + +func TestBlackholeAddrIsStableWhileAlive(t *testing.T) { + first, err := BlackholeAddr() + if err != nil { + t.Skipf("sandbox blocks loopback listeners: %v", err) + } + second, err := BlackholeAddr() + if err != nil { + t.Fatalf("second BlackholeAddr: %v", err) + } + if first != second { + t.Fatalf("address changed while alive: %q then %q", first, second) + } +} + +func TestShellProxyEnv(t *testing.T) { + if entries := ShellProxyEnv(Policy{Mode: ModeAllow}); entries != nil { + t.Fatalf("allow mode should inject nothing, got %v", entries) + } + + entries := ShellProxyEnv(Policy{Mode: ModeOff}) + if len(entries) != 6 { + t.Fatalf("off mode entries = %v, want 6", entries) + } + for _, want := range []string{"HTTP_PROXY=http://127.0.0.1:", "HTTPS_PROXY=http://127.0.0.1:", "http_proxy=", "https_proxy=", "NO_PROXY=localhost,127.0.0.1,::1", "no_proxy="} { + found := false + for _, entry := range entries { + if strings.HasPrefix(entry, strings.SplitN(want, "=", 2)[0]+"=") && strings.Contains(entry, strings.SplitN(want, "=", 2)[1]) { + found = true + break + } + } + if !found { + t.Fatalf("entries %v missing %q", entries, want) + } + } + +} diff --git a/internal/seniordev/netpolicy/netpolicy.go b/internal/seniordev/netpolicy/netpolicy.go new file mode 100644 index 000000000..e03a3c5b8 --- /dev/null +++ b/internal/seniordev/netpolicy/netpolicy.go @@ -0,0 +1,168 @@ +//go:build !windows + +// Package netpolicy makes senior-dev aware of runs where agent-initiated network +// access is unavailable, so agents stop wasting cycles attempting it. It +// governs the builtin web tools (webfetch, websearch) and the environment +// handed to bash children. The model plane (the LLM client) and the +// AgentField control-plane reporter are deliberately outside its scope: that +// traffic is senior-dev's own infrastructure, not agent-initiated, and a run +// cannot function without it. +// +// The policy is read from the environment, following the pipeline's existing +// SENIOR_DEV_* precedent: +// +// SENIOR_DEV_NET=allow current behavior (default when unset) +// SENIOR_DEV_NET=off agent-initiated egress is unavailable for this run +// +// A value of SENIOR_DEV_NET that parses to neither fails CLOSED to off: a typo in +// a flag that exists to forbid network access must not silently grant it. The +// parse problem is preserved on the Policy so callers can surface it; the +// senior-dev binary refuses to start on it, so a run is never silently degraded +// by a typo either. +// +// Containment is not this package's job - that belongs to the environment the +// run executes in (for example a sandbox that only lets the LLM backend and +// control plane through). What this package delivers under off is legibility +// and economy: +// the web tools disappear from the model's tool list, in-process HTTP fails +// instantly with an explicit no-retry policy error instead of a sandbox +// timeout, and proxy-honoring bash clients (curl, wget, pip, npm, +// git-over-HTTPS) get a millisecond 403 from a local black-hole listener +// rather than a DNS or connect stall. Clients that ignore proxy variables +// simply fail against the outer sandbox instead - slower, but still contained. +package netpolicy + +import ( + "fmt" + "net" + "net/http" + "os" + "strings" +) + +// Mode is the egress posture. +type Mode string + +const ( + // ModeAllow leaves every egress path open. + ModeAllow Mode = "allow" + // ModeOff declares agent-initiated egress unavailable. + ModeOff Mode = "off" +) + +// EnvMode is the environment variable the policy is read from. +const EnvMode = "SENIOR_DEV_NET" + +// Policy is an immutable snapshot of the egress policy. +type Policy struct { + Mode Mode + // Warning is non-empty when the environment held an unrecognized value + // and the policy failed closed because of it. + Warning string +} + +// Current reads the policy from the process environment. +func Current() Policy { + return FromLookup(os.Getenv) +} + +// FromLookup parses a policy from an environment accessor, for tests and +// embedders that do not own the process environment. +func FromLookup(getenv func(string) string) Policy { + raw := strings.TrimSpace(strings.ToLower(getenv(EnvMode))) + switch raw { + case "", string(ModeAllow): + return Policy{Mode: ModeAllow} + case string(ModeOff): + return Policy{Mode: ModeOff} + default: + return Policy{ + Mode: ModeOff, + Warning: fmt.Sprintf( + "%s=%q is not one of allow/off", + EnvMode, getenv(EnvMode), + ), + } + } +} + +// Restricted reports whether the policy restricts egress at all. Callers on +// hot paths use it to skip wrapping entirely under the default policy. +func (p Policy) Restricted() bool { + return p.Mode == ModeOff +} + +func hostOnly(host string) string { + if trimmed, _, err := net.SplitHostPort(host); err == nil { + return trimmed + } + // A bare IPv6 literal without a port fails SplitHostPort; unwrap brackets. + return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") +} + +// BlockedError is the model-facing refusal for one blocked host. It is a +// distinct type so callers can recover it with errors.As after net/http wraps +// it (a refusal on a redirect hop comes back from Client.Do inside a +// *url.Error) and surface the policy text instead of a generic transport +// failure. +type BlockedError struct { + Host string + message string +} + +func (err *BlockedError) Error() string { return err.message } + +// HostError builds the refusal for one blocked host. The [network-policy] +// prefix and the no-retry framing follow the [environment-signal] convention +// in internal/tool/shell_env_signal.go: the point is to stop an agent from +// burning turns retrying a request that policy, not transient failure, +// rejected. +func (p Policy) HostError(host string) error { + name := hostOnly(host) + return &BlockedError{Host: name, message: fmt.Sprintf( + "[network-policy] request to %q blocked: this run has network access disabled (%s=off). "+ + "This is policy, not a transient failure - do not retry and do not attempt "+ + "the same access through bash or other tools; "+ + "work from local repository content instead", + name, EnvMode, + )} +} + +// EnvironmentNotice is the per-turn system-prompt paragraph that tells agents +// up front that the network is unavailable, so the first fetch attempt never +// happens instead of merely failing fast. Empty under the default policy. +func (p Policy) EnvironmentNotice() string { + if !p.Restricted() { + return "" + } + return "Network access is disabled for this run: external fetches, package installs, " + + "and any other network commands will fail. Do not attempt them or retry them; " + + "work only from content already available in the repository and this environment." +} + +// Transport wraps base so every request is refused before it dials while the +// policy is restricted. Redirect hops re-enter the transport, so each hop is +// covered. A nil base means http.DefaultTransport, mirroring net/http. +func (p Policy) Transport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + if !p.Restricted() { + return base + } + return policyTransport{policy: p, base: base} +} + +type policyTransport struct { + policy Policy + base http.RoundTripper +} + +func (t policyTransport) RoundTrip(request *http.Request) (*http.Response, error) { + // The RoundTripper contract makes the transport responsible for closing + // the body once it has been handed the request. + if request.Body != nil { + _ = request.Body.Close() + } + return nil, t.policy.HostError(request.URL.Host) +} diff --git a/internal/seniordev/netpolicy/netpolicy_test.go b/internal/seniordev/netpolicy/netpolicy_test.go new file mode 100644 index 000000000..6e9f688fb --- /dev/null +++ b/internal/seniordev/netpolicy/netpolicy_test.go @@ -0,0 +1,100 @@ +//go:build !windows + +package netpolicy + +import ( + "net/http" + "strings" + "testing" +) + +func lookup(values map[string]string) func(string) string { + return func(name string) string { return values[name] } +} + +func TestFromLookupModes(t *testing.T) { + cases := []struct { + name string + env map[string]string + mode Mode + warning bool + }{ + {name: "unset defaults to allow", env: nil, mode: ModeAllow}, + {name: "explicit allow", env: map[string]string{EnvMode: "allow"}, mode: ModeAllow}, + {name: "off", env: map[string]string{EnvMode: "off"}, mode: ModeOff}, + {name: "case and space folded", env: map[string]string{EnvMode: " OFF "}, mode: ModeOff}, + { + name: "typo fails closed", + env: map[string]string{EnvMode: "on"}, + mode: ModeOff, warning: true, + }, + { + name: "unknown mode name fails closed", + env: map[string]string{EnvMode: "allowlist"}, + mode: ModeOff, warning: true, + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + policy := FromLookup(lookup(test.env)) + if policy.Mode != test.mode { + t.Fatalf("mode = %q, want %q", policy.Mode, test.mode) + } + if (policy.Warning != "") != test.warning { + t.Fatalf("warning = %q, want present=%v", policy.Warning, test.warning) + } + }) + } +} + +type recordingTransport struct{ dialed bool } + +func (t *recordingTransport) RoundTrip(*http.Request) (*http.Response, error) { + t.dialed = true + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestTransportEnforcesPolicy(t *testing.T) { + base := &recordingTransport{} + + client := &http.Client{Transport: Policy{Mode: ModeOff}.Transport(base)} + _, err := client.Get("http://example.com/") + if err == nil { + t.Fatal("off-mode request unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "[network-policy]") { + t.Fatalf("off-mode error missing policy marker: %v", err) + } + if base.dialed { + t.Fatal("off-mode request reached the base transport") + } + + // Unrestricted policy must return the base transport untouched. + if (Policy{Mode: ModeAllow}).Transport(base) != http.RoundTripper(base) { + t.Fatal("allow-mode Transport(base) should be the base transport") + } + if (Policy{Mode: ModeAllow}).Transport(nil) != http.RoundTripper(http.DefaultTransport) { + t.Fatal("allow-mode Transport(nil) should be http.DefaultTransport") + } +} + +func TestHostErrorSteersAwayFromRetry(t *testing.T) { + err := Policy{Mode: ModeOff}.HostError("mcp.exa.ai:443") + for _, want := range []string{"[network-policy]", "mcp.exa.ai", "do not retry", "SENIOR_DEV_NET"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q missing %q", err, want) + } + } +} + +func TestEnvironmentNotice(t *testing.T) { + if notice := (Policy{Mode: ModeAllow}).EnvironmentNotice(); notice != "" { + t.Fatalf("allow mode should carry no notice, got %q", notice) + } + notice := Policy{Mode: ModeOff}.EnvironmentNotice() + for _, want := range []string{"Network access is disabled", "Do not attempt"} { + if !strings.Contains(notice, want) { + t.Fatalf("notice %q missing %q", notice, want) + } + } +} diff --git a/internal/seniordev/patch/diff_contract_test.go b/internal/seniordev/patch/diff_contract_test.go new file mode 100644 index 000000000..15539d8df --- /dev/null +++ b/internal/seniordev/patch/diff_contract_test.go @@ -0,0 +1,46 @@ +//go:build !windows + +package patch + +import ( + "fmt" + "strings" + "testing" +) + +func TestUnifiedDiffUsesRealLineAlignment(t *testing.T) { + // An insertion must not masquerade as a replacement. + got := GenerateTwoFilesPatch("x", "a\nb\nc\nd\ne\nf\ng\n", "a\nb\nX\nc\nd\ne\nf\ng\n") + want := "Index: x\n===================================================================\n--- x\n+++ x\n" + + "@@ -1,6 +1,7 @@\n a\n b\n+X\n c\n d\n e\n f\n" + if got != want { + t.Fatalf("insertion diff:\n%s\nwant:\n%s", got, want) + } + if got := GenerateTwoFilesPatch("x", "a\n", ""); got != "Index: x\n===================================================================\n--- x\n+++ x\n@@ -1,1 +0,0 @@\n-a\n" { + t.Fatalf("deletion diff: %q", got) + } +} + +func TestTwoFilesPatchUnchangedEmptyFileIsHeaderOnly(t *testing.T) { + // Two identical empty versions still yield a header-only patch. + want := "Index: empty\n===================================================================\n--- empty\n+++ empty\n" + if got := GenerateTwoFilesPatch("empty", "", ""); got != want { + t.Fatalf("unchanged empty patch = %q, want %q", got, want) + } +} + +func TestLineDiffLargePathologicalInputIsBounded(t *testing.T) { + // A 10k-line replacement must not allocate an old-by-new matrix; the + // bounded linear-space path still returns a patch. + var oldContent, newContent strings.Builder + for index := 0; index < 10_000; index++ { + fmt.Fprintf(&oldContent, "old-%05d\n", index) + fmt.Fprintf(&newContent, "new-%05d\n", index) + } + got := GenerateTwoFilesPatch("large", oldContent.String(), newContent.String()) + if !strings.HasPrefix(got, "Index: large\n") || + !strings.Contains(got, "-old-00000") || + !strings.Contains(got, "+new-09999") { + t.Fatalf("large replacement patch was not generated: prefix=%q len=%d", got[:min(len(got), 80)], len(got)) + } +} diff --git a/internal/seniordev/patch/io_test.go b/internal/seniordev/patch/io_test.go new file mode 100644 index 000000000..8a0e4b40b --- /dev/null +++ b/internal/seniordev/patch/io_test.go @@ -0,0 +1,72 @@ +//go:build !windows + +package patch + +import ( + "os" + "path/filepath" + "testing" +) + +func TestApplyPatchAndAffectedPaths(t *testing.T) { + root := t.TempDir() + add := filepath.Join(root, "nested", "add.txt") + update := filepath.Join(root, "update.txt") + deletePath := filepath.Join(root, "delete.txt") + if err := os.WriteFile(update, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(deletePath, []byte("gone\n"), 0o644); err != nil { + t.Fatal(err) + } + patchText := "*** Begin Patch\n" + + "*** Add File: " + add + "\n+x\n" + + "*** Update File: " + update + "\n@@\n-old\n+new\n" + + "*** Delete File: " + deletePath + "\n" + + "*** End Patch" + affected, err := ApplyPatch(patchText) + if err != nil { + t.Fatalf("ApplyPatch: %v", err) + } + if len(affected.Added) != 1 || affected.Added[0] != add || + len(affected.Modified) != 1 || affected.Modified[0] != update || + len(affected.Deleted) != 1 || affected.Deleted[0] != deletePath { + t.Fatalf("affected = %#v", affected) + } + data, err := os.ReadFile(add) + if err != nil || string(data) != "x" { + t.Fatalf("add content=%q err=%v", data, err) + } + data, err = os.ReadFile(update) + if err != nil || string(data) != "new\n" { + t.Fatalf("update content=%q err=%v", data, err) + } +} + +func TestMaybeParseApplyPatchVerified(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "file.txt") + if err := os.WriteFile(path, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + patchText := "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n*** End Patch" + result := MaybeParseApplyPatchVerified([]string{"apply_patch", patchText}, root) + if result.Type != VerifiedBody || result.Action == nil { + t.Fatalf("result = %#v", result) + } + change, ok := result.Action.Changes[path] + if !ok || change.Type != "update" || change.NewContent != "new\n" { + t.Fatalf("change = %#v, ok=%v", change, ok) + } + implicit := MaybeParseApplyPatchVerified([]string{patchText}, root) + if implicit.Type != VerifiedCorrectnessError || implicit.Err == nil || implicit.Err.Error() != ErrorImplicitInvocation { + t.Fatalf("implicit = %#v", implicit) + } +} + +func TestApplyHunksRejectsEmpty(t *testing.T) { + _, err := ApplyHunksToFiles(nil) + if err == nil || err.Error() != "No files were modified." { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/seniordev/patch/patch.go b/internal/seniordev/patch/patch.go new file mode 100644 index 000000000..33a1ab1bd --- /dev/null +++ b/internal/seniordev/patch/patch.go @@ -0,0 +1,1038 @@ +//go:build !windows + +// Package patch implements the apply_patch format: the patch parser, fuzzy +// chunk matching, direct filesystem application, and detection of apply_patch +// invocations inside shell commands. +package patch + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + MaybeBody = "Body" + MaybeShellParseError = "ShellParseError" + MaybePatchParseError = "PatchParseError" + MaybeNotApplyPatch = "NotApplyPatch" + + VerifiedBody = "Body" + VerifiedShellParseError = "ShellParseError" + VerifiedCorrectnessError = "CorrectnessError" + VerifiedNotApplyPatch = "NotApplyPatch" + + ErrorParseError = "ParseError" + ErrorIOError = "IoError" + ErrorComputeReplacements = "ComputeReplacements" + ErrorImplicitInvocation = "ImplicitInvocation" +) + +// UpdateFileChunk is the parser's line-oriented update block. +type UpdateFileChunk struct { + OldLines []string `json:"old_lines"` + NewLines []string `json:"new_lines"` + ChangeContext string `json:"change_context,omitempty"` +} + +// Hunk is one add, delete, or update section. +type Hunk struct { + Type string + Path string + Contents string + MovePath string + Chunks []UpdateFileChunk +} + +func (h Hunk) MarshalJSON() ([]byte, error) { + switch h.Type { + case "add": + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + Contents string `json:"contents"` + }{h.Type, h.Path, h.Contents}) + case "delete": + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + }{h.Type, h.Path}) + case "update": + if h.MovePath != "" { + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + MovePath string `json:"move_path"` + Chunks []UpdateFileChunk `json:"chunks"` + }{h.Type, h.Path, h.MovePath, nonNilChunks(h.Chunks)}) + } + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + Chunks []UpdateFileChunk `json:"chunks"` + }{h.Type, h.Path, nonNilChunks(h.Chunks)}) + default: + return nil, fmt.Errorf("unknown hunk type %q", h.Type) + } +} + +func nonNilChunks(chunks []UpdateFileChunk) []UpdateFileChunk { + if chunks == nil { + return []UpdateFileChunk{} + } + return chunks +} + +// ParseResult is the parsed hunk list. +type ParseResult struct { + Hunks []Hunk `json:"hunks"` +} + +// ApplyPatchArgs is a detected apply_patch invocation. +type ApplyPatchArgs struct { + Patch string `json:"patch"` + Hunks []Hunk `json:"hunks"` + Workdir string `json:"workdir,omitempty"` +} + +// MaybeResult is the discriminated result of MaybeParseApplyPatch. +type MaybeResult struct { + Type string + Args *ApplyPatchArgs + Err error +} + +// ApplyPatchFileUpdate is DeriveNewContentsFromChunks' result. +type ApplyPatchFileUpdate struct { + UnifiedDiff string `json:"unified_diff"` + Content string `json:"content"` + BOM bool `json:"bom"` +} + +// AffectedPaths records filesystem application results. +type AffectedPaths struct { + Added []string `json:"added"` + Modified []string `json:"modified"` + Deleted []string `json:"deleted"` +} + +// ApplyPatchFileChange is one previewed file change. +type ApplyPatchFileChange struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + UnifiedDiff string `json:"unified_diff,omitempty"` + MovePath string `json:"move_path,omitempty"` + NewContent string `json:"new_content,omitempty"` +} + +// ApplyPatchAction is the verified patch preview. +type ApplyPatchAction struct { + Changes map[string]ApplyPatchFileChange + Patch string + CWD string +} + +// VerifiedResult is the discriminated result of MaybeParseApplyPatchVerified. +type VerifiedResult struct { + Type string + Action *ApplyPatchAction + Err error +} + +// ParsePatch parses the stripped patch envelope. +func ParsePatch(patchText string) (ParseResult, error) { + cleaned := stripHeredoc(strings.TrimSpace(patchText)) + lines := strings.Split(cleaned, "\n") + beginIndex := -1 + endIndex := -1 + for i, line := range lines { + if beginIndex == -1 && strings.TrimSpace(line) == "*** Begin Patch" { + beginIndex = i + } + if endIndex == -1 && strings.TrimSpace(line) == "*** End Patch" { + endIndex = i + } + } + if beginIndex == -1 || endIndex == -1 || beginIndex >= endIndex { + return ParseResult{}, errors.New("Invalid patch format: missing Begin/End markers") + } + + hunks := []Hunk{} + for i := beginIndex + 1; i < endIndex; { + path, movePath, next, kind, ok := parsePatchHeader(lines, i) + if !ok { + i++ + continue + } + switch kind { + case "add": + content, after := parseAddFileContent(lines, next) + hunks = append(hunks, Hunk{Type: "add", Path: path, Contents: content}) + i = after + case "delete": + hunks = append(hunks, Hunk{Type: "delete", Path: path}) + i = next + case "update": + chunks, after := parseUpdateFileChunks(lines, next) + hunks = append(hunks, Hunk{ + Type: "update", + Path: path, + MovePath: movePath, + Chunks: chunks, + }) + i = after + default: + i++ + } + } + return ParseResult{Hunks: hunks}, nil +} + +func parsePatchHeader(lines []string, index int) (path string, movePath string, next int, kind string, ok bool) { + line := lines[index] + for _, item := range []struct { + prefix string + kind string + }{ + {"*** Add File:", "add"}, + {"*** Delete File:", "delete"}, + {"*** Update File:", "update"}, + } { + if !strings.HasPrefix(line, item.prefix) { + continue + } + path = strings.TrimSpace(line[len(item.prefix):]) + if path == "" { + return "", "", 0, "", false + } + next = index + 1 + if item.kind == "update" && next < len(lines) && strings.HasPrefix(lines[next], "*** Move to:") { + movePath = strings.TrimSpace(lines[next][len("*** Move to:"):]) + next++ + } + return path, movePath, next, item.kind, true + } + return "", "", 0, "", false +} + +func parseUpdateFileChunks(lines []string, start int) ([]UpdateFileChunk, int) { + chunks := []UpdateFileChunk{} + i := start + for i < len(lines) && !strings.HasPrefix(lines[i], "***") { + if !strings.HasPrefix(lines[i], "@@") { + i++ + continue + } + contextLine := strings.TrimSpace(lines[i][2:]) + i++ + oldLines := []string{} + newLines := []string{} + for i < len(lines) && !strings.HasPrefix(lines[i], "@@") && !strings.HasPrefix(lines[i], "***") { + changeLine := lines[i] + switch { + case strings.HasPrefix(changeLine, " "): + oldLines = append(oldLines, changeLine[1:]) + newLines = append(newLines, changeLine[1:]) + case strings.HasPrefix(changeLine, "-"): + oldLines = append(oldLines, changeLine[1:]) + case strings.HasPrefix(changeLine, "+"): + newLines = append(newLines, changeLine[1:]) + } + i++ + } + chunks = append(chunks, UpdateFileChunk{ + OldLines: oldLines, + NewLines: newLines, + ChangeContext: contextLine, + }) + } + return chunks, i +} + +func parseAddFileContent(lines []string, start int) (string, int) { + var content strings.Builder + i := start + for i < len(lines) && !strings.HasPrefix(lines[i], "***") { + if strings.HasPrefix(lines[i], "+") { + content.WriteString(lines[i][1:]) + content.WriteByte('\n') + } + i++ + } + out := content.String() + out = strings.TrimSuffix(out, "\n") + return out, i +} + +func stripHeredoc(input string) string { + headerEnd := strings.IndexByte(input, '\n') + if headerEnd < 0 { + return input + } + header := input[:headerEnd] + rest := input[headerEnd+1:] + if strings.HasPrefix(header, "cat") { + after := header[len("cat"):] + if after == "" || !isSpaceRune(firstRune(after)) { + return input + } + header = strings.TrimLeftFunc(after, isSpaceRune) + } + if !strings.HasPrefix(header, "<<") { + return input + } + header = header[2:] + if len(header) > 0 && (header[0] == '\'' || header[0] == '"') { + header = header[1:] + } + end := 0 + for end < len(header) && isASCIIWord(header[end]) { + end++ + } + if end == 0 { + return input + } + delimiter := header[:end] + header = header[end:] + if len(header) > 0 && (header[0] == '\'' || header[0] == '"') { + header = header[1:] + } + if strings.TrimSpace(header) != "" { + return input + } + suffix := "\n" + delimiter + position := strings.Index(rest, suffix) + for position >= 0 { + after := rest[position+len(suffix):] + if strings.TrimSpace(after) == "" { + return rest[:position] + } + next := strings.Index(rest[position+1:], suffix) + if next < 0 { + break + } + position += next + 1 + } + return input +} + +func firstRune(value string) rune { + for _, r := range value { + return r + } + return 0 +} + +func isASCIIWord(value byte) bool { + return value >= 'a' && value <= 'z' || + value >= 'A' && value <= 'Z' || + value >= '0' && value <= '9' || + value == '_' +} + +// MaybeParseApplyPatch detects direct and bash-heredoc invocations. +func MaybeParseApplyPatch(argv []string) MaybeResult { + if len(argv) == 2 && (argv[0] == "apply_patch" || argv[0] == "applypatch") { + parsed, err := ParsePatch(argv[1]) + if err != nil { + return MaybeResult{Type: MaybePatchParseError, Err: err} + } + return MaybeResult{ + Type: MaybeBody, + Args: &ApplyPatchArgs{Patch: argv[1], Hunks: parsed.Hunks}, + } + } + if len(argv) == 3 && argv[0] == "bash" && argv[1] == "-lc" { + if content, ok := extractApplyPatchHeredoc(argv[2]); ok { + parsed, err := ParsePatch(content) + if err != nil { + return MaybeResult{Type: MaybePatchParseError, Err: err} + } + return MaybeResult{ + Type: MaybeBody, + Args: &ApplyPatchArgs{Patch: content, Hunks: parsed.Hunks}, + } + } + } + return MaybeResult{Type: MaybeNotApplyPatch} +} + +func extractApplyPatchHeredoc(script string) (string, bool) { + for start := 0; start < len(script); { + index := strings.Index(script[start:], "apply_patch") + if index < 0 { + return "", false + } + index += start + len("apply_patch") + for index < len(script) && isSpaceRune(firstRune(script[index:])) { + _, size := runeAt(script[index:]) + index += size + } + if !strings.HasPrefix(script[index:], "<<") { + start = index + continue + } + index += 2 + if index >= len(script) || (script[index] != '\'' && script[index] != '"') { + return "", false + } + index++ + delimiterStart := index + for index < len(script) && isASCIIWord(script[index]) { + index++ + } + if delimiterStart == index { + return "", false + } + delimiter := script[delimiterStart:index] + if index >= len(script) || (script[index] != '\'' && script[index] != '"') { + return "", false + } + index++ + for index < len(script) && script[index] != '\n' { + if !isSpaceRune(firstRune(script[index:])) { + return "", false + } + _, size := runeAt(script[index:]) + index += size + } + if index >= len(script) { + return "", false + } + bodyStart := index + 1 + endMarker := "\n" + delimiter + bodyEnd := strings.Index(script[bodyStart:], endMarker) + if bodyEnd < 0 { + return "", false + } + return script[bodyStart : bodyStart+bodyEnd], true + } + return "", false +} + +func runeAt(value string) (rune, int) { + for _, r := range value { + return r, len(string(r)) + } + return 0, 0 +} + +// DeriveNewContentsFromChunks reads filePath and applies update chunks. +func DeriveNewContentsFromChunks(filePath string, chunks []UpdateFileChunk) (ApplyPatchFileUpdate, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return ApplyPatchFileUpdate{}, fmt.Errorf("Failed to read file %s: %v", filePath, err) + } + bom, originalText := splitBOM(strings.ToValidUTF8(string(data), "\uFFFD")) + originalLines := strings.Split(originalText, "\n") + if len(originalLines) > 0 && originalLines[len(originalLines)-1] == "" { + originalLines = originalLines[:len(originalLines)-1] + } + replacements, err := computeReplacements(originalLines, filePath, chunks) + if err != nil { + return ApplyPatchFileUpdate{}, err + } + newLines := applyReplacements(originalLines, replacements) + if len(newLines) == 0 || newLines[len(newLines)-1] != "" { + newLines = append(newLines, "") + } + nextBOM, newContent := splitBOM(strings.Join(newLines, "\n")) + return ApplyPatchFileUpdate{ + UnifiedDiff: generateUnifiedDiff(originalText, newContent), + Content: newContent, + BOM: bom || nextBOM, + }, nil +} + +type replacement struct { + start int + oldLength int + newSegment []string +} + +func computeReplacements(originalLines []string, filePath string, chunks []UpdateFileChunk) ([]replacement, error) { + replacements := []replacement{} + lineIndex := 0 + for _, chunk := range chunks { + if chunk.ChangeContext != "" { + contextIndex := seekSequence(originalLines, []string{chunk.ChangeContext}, lineIndex) + if contextIndex == -1 { + return nil, fmt.Errorf("Failed to find context '%s' in %s", chunk.ChangeContext, filePath) + } + lineIndex = contextIndex + 1 + } + if len(chunk.OldLines) == 0 { + insertionIndex := len(originalLines) + if len(originalLines) > 0 && originalLines[len(originalLines)-1] == "" { + insertionIndex-- + } + replacements = append(replacements, replacement{ + start: insertionIndex, oldLength: 0, newSegment: append([]string(nil), chunk.NewLines...), + }) + continue + } + pattern := append([]string(nil), chunk.OldLines...) + newSlice := append([]string(nil), chunk.NewLines...) + found := seekSequence(originalLines, pattern, lineIndex) + if found == -1 && len(pattern) > 0 && pattern[len(pattern)-1] == "" { + pattern = pattern[:len(pattern)-1] + if len(newSlice) > 0 && newSlice[len(newSlice)-1] == "" { + newSlice = newSlice[:len(newSlice)-1] + } + found = seekSequence(originalLines, pattern, lineIndex) + } + if found == -1 { + return nil, fmt.Errorf( + "Failed to find expected lines in %s:\n%s", + filePath, + strings.Join(chunk.OldLines, "\n"), + ) + } + replacements = append(replacements, replacement{ + start: found, oldLength: len(pattern), newSegment: newSlice, + }) + lineIndex = found + len(pattern) + } + sort.SliceStable(replacements, func(i, j int) bool { + return replacements[i].start < replacements[j].start + }) + return replacements, nil +} + +func applyReplacements(lines []string, replacements []replacement) []string { + result := append([]string(nil), lines...) + for i := len(replacements) - 1; i >= 0; i-- { + item := replacements[i] + before := append([]string(nil), result[:item.start]...) + after := append([]string(nil), result[item.start+item.oldLength:]...) + result = append(before, item.newSegment...) + result = append(result, after...) + } + return result +} + +type comparator func(string, string) bool + +func tryMatch(lines []string, pattern []string, start int, compare comparator) int { + for i := start; i <= len(lines)-len(pattern); i++ { + if sequenceMatches(lines, pattern, i, compare) { + return i + } + } + return -1 +} + +func sequenceMatches(lines []string, pattern []string, start int, compare comparator) bool { + for j := range pattern { + if !compare(lines[start+j], pattern[j]) { + return false + } + } + return true +} + +func seekSequence(lines []string, pattern []string, start int) int { + if len(pattern) == 0 { + return -1 + } + if found := tryMatch(lines, pattern, start, func(a, b string) bool { return a == b }); found != -1 { + return found + } + if found := tryMatch(lines, pattern, start, func(a, b string) bool { + return trimEnd(a) == trimEnd(b) + }); found != -1 { + return found + } + if found := tryMatch(lines, pattern, start, func(a, b string) bool { + return strings.TrimSpace(a) == strings.TrimSpace(b) + }); found != -1 { + return found + } + return tryMatch(lines, pattern, start, func(a, b string) bool { + return normalizeUnicode(strings.TrimSpace(a)) == normalizeUnicode(strings.TrimSpace(b)) + }) +} + +func trimEnd(value string) string { + return strings.TrimRightFunc(value, isSpaceRune) +} + +func normalizeUnicode(value string) string { + var out strings.Builder + for _, r := range value { + switch { + case r >= 0x2018 && r <= 0x201b: + out.WriteByte('\'') + case r >= 0x201c && r <= 0x201f: + out.WriteByte('"') + case r >= 0x2010 && r <= 0x2015: + out.WriteByte('-') + case r == 0x2026: + out.WriteString("...") + case r == 0x00a0: + out.WriteByte(' ') + default: + out.WriteRune(r) + } + } + return out.String() +} + +type diffLine struct { + text string + newline bool +} + +type diffOperation struct { + kind byte + line diffLine +} + +type lineMatch struct { + old int + new int +} + +const maxLineDiffCells int64 = 8_000_000 + +func contentLines(content string) []diffLine { + if content == "" { + return nil + } + parts := strings.SplitAfter(content, "\n") + lines := make([]diffLine, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + newline := strings.HasSuffix(part, "\n") + lines = append(lines, diffLine{text: strings.TrimSuffix(part, "\n"), newline: newline}) + } + return lines +} + +func equalDiffLine(left, right diffLine) bool { + return left.text == right.text && left.newline == right.newline +} + +func lineOperations(oldContent, newContent string) []diffOperation { + oldLines, newLines := contentLines(oldContent), contentLines(newContent) + prefix := 0 + for prefix < len(oldLines) && prefix < len(newLines) && + equalDiffLine(oldLines[prefix], newLines[prefix]) { + prefix++ + } + suffix := 0 + for suffix < len(oldLines)-prefix && suffix < len(newLines)-prefix && + equalDiffLine(oldLines[len(oldLines)-suffix-1], newLines[len(newLines)-suffix-1]) { + suffix++ + } + operations := make([]diffOperation, 0, len(oldLines)+len(newLines)) + for _, line := range oldLines[:prefix] { + operations = append(operations, diffOperation{kind: ' ', line: line}) + } + oldMiddle := oldLines[prefix : len(oldLines)-suffix] + newMiddle := newLines[prefix : len(newLines)-suffix] + matches, ok := linearLCSMatches(oldMiddle, newMiddle) + if !ok { + for _, line := range oldMiddle { + operations = append(operations, diffOperation{kind: '-', line: line}) + } + for _, line := range newMiddle { + operations = append(operations, diffOperation{kind: '+', line: line}) + } + } else { + oldCursor, newCursor := 0, 0 + for _, match := range matches { + for oldCursor < match.old { + operations = append(operations, diffOperation{kind: '-', line: oldMiddle[oldCursor]}) + oldCursor++ + } + for newCursor < match.new { + operations = append(operations, diffOperation{kind: '+', line: newMiddle[newCursor]}) + newCursor++ + } + operations = append(operations, diffOperation{kind: ' ', line: oldMiddle[match.old]}) + oldCursor, newCursor = match.old+1, match.new+1 + } + for oldCursor < len(oldMiddle) { + operations = append(operations, diffOperation{kind: '-', line: oldMiddle[oldCursor]}) + oldCursor++ + } + for newCursor < len(newMiddle) { + operations = append(operations, diffOperation{kind: '+', line: newMiddle[newCursor]}) + newCursor++ + } + } + for _, line := range oldLines[len(oldLines)-suffix:] { + operations = append(operations, diffOperation{kind: ' ', line: line}) + } + return operations +} + +func linearLCSMatches(oldLines, newLines []diffLine) ([]lineMatch, bool) { + budget := maxLineDiffCells + matches := make([]lineMatch, 0) + if len(newLines) <= len(oldLines) { + if !appendLCSMatches(oldLines, newLines, 0, 0, false, &budget, &matches) { + return nil, false + } + } else if !appendLCSMatches(newLines, oldLines, 0, 0, true, &budget, &matches) { + return nil, false + } + return matches, true +} + +// appendLCSMatches is Hirschberg's linear-space LCS. The work budget falls +// back to a whole-range replacement before pathological inputs consume +// unbounded CPU. +func appendLCSMatches( + left, right []diffLine, + leftOffset, rightOffset int, + swapped bool, + budget *int64, + matches *[]lineMatch, +) bool { + if len(left) == 0 || len(right) == 0 { + return true + } + if len(left) == 1 { + if int64(len(right)) > *budget { + return false + } + *budget -= int64(len(right)) + for index := range right { + if !equalDiffLine(left[0], right[index]) { + continue + } + if swapped { + *matches = append(*matches, lineMatch{old: rightOffset + index, new: leftOffset}) + } else { + *matches = append(*matches, lineMatch{old: leftOffset, new: rightOffset + index}) + } + return true + } + return true + } + cells := int64(len(left)) * int64(len(right)) + if cells > *budget/2 { + return false + } + middle := len(left) / 2 + forward := lcsPrefixLengths(left[:middle], right) + backward := lcsSuffixLengths(left[middle:], right) + *budget -= cells * 2 + split := 0 + best := -1 + for index := 0; index <= len(right); index++ { + value := forward[index] + backward[index] + if value > best { + best = value + split = index + } + } + return appendLCSMatches( + left[:middle], right[:split], leftOffset, rightOffset, swapped, budget, matches, + ) && appendLCSMatches( + left[middle:], right[split:], leftOffset+middle, rightOffset+split, swapped, budget, matches, + ) +} + +func lcsPrefixLengths(left, right []diffLine) []int { + previous, current := make([]int, len(right)+1), make([]int, len(right)+1) + for _, leftLine := range left { + for index, rightLine := range right { + if equalDiffLine(leftLine, rightLine) { + current[index+1] = previous[index] + 1 + } else if previous[index+1] >= current[index] { + current[index+1] = previous[index+1] + } else { + current[index+1] = current[index] + } + } + previous, current = current, previous + clear(current) + } + return previous +} + +func lcsSuffixLengths(left, right []diffLine) []int { + previous, current := make([]int, len(right)+1), make([]int, len(right)+1) + for leftIndex := len(left) - 1; leftIndex >= 0; leftIndex-- { + for rightIndex := len(right) - 1; rightIndex >= 0; rightIndex-- { + if equalDiffLine(left[leftIndex], right[rightIndex]) { + current[rightIndex] = previous[rightIndex+1] + 1 + } else if previous[rightIndex] >= current[rightIndex+1] { + current[rightIndex] = previous[rightIndex] + } else { + current[rightIndex] = current[rightIndex+1] + } + } + previous, current = current, previous + clear(current) + } + return previous +} + +func generateUnifiedDiff(oldContent string, newContent string) string { + if oldContent == newContent { + return "" + } + operations := lineOperations(oldContent, newContent) + oldBefore, newBefore := make([]int, len(operations)+1), make([]int, len(operations)+1) + for i, operation := range operations { + oldBefore[i+1], newBefore[i+1] = oldBefore[i], newBefore[i] + if operation.kind != '+' { + oldBefore[i+1]++ + } + if operation.kind != '-' { + newBefore[i+1]++ + } + } + var diff strings.Builder + for cursor := 0; cursor < len(operations); { + first := cursor + for first < len(operations) && operations[first].kind == ' ' { + first++ + } + if first == len(operations) { + break + } + start := first + for count := 0; start > 0 && count < 4; count++ { + start-- + } + lastChange := first + for scan := first + 1; scan < len(operations); { + next := scan + for next < len(operations) && operations[next].kind == ' ' { + next++ + } + if next == len(operations) || next-lastChange-1 > 8 { + break + } + lastChange = next + scan = next + 1 + } + end := lastChange + 1 + for count := 0; end < len(operations) && operations[end].kind == ' ' && count < 4; count++ { + end++ + } + oldCount, newCount := oldBefore[end]-oldBefore[start], newBefore[end]-newBefore[start] + oldStart, newStart := oldBefore[start]+1, newBefore[start]+1 + if oldCount == 0 { + oldStart = oldBefore[start] + } + if newCount == 0 { + newStart = newBefore[start] + } + fmt.Fprintf(&diff, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount) + for _, operation := range operations[start:end] { + diff.WriteByte(operation.kind) + diff.WriteString(operation.line.text) + diff.WriteByte('\n') + if !operation.line.newline { + diff.WriteString("\\ No newline at end of file\n") + } + } + cursor = end + } + return diff.String() +} + +// GenerateTwoFilesPatch wraps the line diff in an Index/---/+++ header. +func GenerateTwoFilesPatch(filePath, oldContent, newContent string) string { + diff := generateUnifiedDiff(oldContent, newContent) + return "Index: " + filePath + "\n" + + "===================================================================\n" + + "--- " + filePath + "\n" + + "+++ " + filePath + "\n" + diff +} + +// GenerateUnifiedDiff exposes the patch package's mutation preview to live +// edit/write tools. +func GenerateUnifiedDiff(oldContent string, newContent string) string { + return generateUnifiedDiff(oldContent, newContent) +} + +// ApplyHunksToFiles applies already-parsed hunks to their literal paths. +func ApplyHunksToFiles(hunks []Hunk) (AffectedPaths, error) { + if len(hunks) == 0 { + return AffectedPaths{}, errors.New("No files were modified.") + } + out := AffectedPaths{Added: []string{}, Modified: []string{}, Deleted: []string{}} + for _, hunk := range hunks { + switch hunk.Type { + case "add": + dir := filepath.Dir(hunk.Path) + if dir != "." && dir != string(filepath.Separator) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return AffectedPaths{}, err + } + } + if err := os.WriteFile(hunk.Path, []byte(hunk.Contents), 0o644); err != nil { + return AffectedPaths{}, err + } + out.Added = append(out.Added, hunk.Path) + case "delete": + if err := os.Remove(hunk.Path); err != nil { + return AffectedPaths{}, err + } + out.Deleted = append(out.Deleted, hunk.Path) + case "update": + update, err := DeriveNewContentsFromChunks(hunk.Path, hunk.Chunks) + if err != nil { + return AffectedPaths{}, err + } + target := hunk.Path + if hunk.MovePath != "" { + target = hunk.MovePath + dir := filepath.Dir(target) + if dir != "." && dir != string(filepath.Separator) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return AffectedPaths{}, err + } + } + } + if err := os.WriteFile(target, []byte(joinBOM(update.Content, update.BOM)), 0o644); err != nil { + return AffectedPaths{}, err + } + if hunk.MovePath != "" { + if err := os.Remove(hunk.Path); err != nil { + return AffectedPaths{}, err + } + } + out.Modified = append(out.Modified, target) + } + } + return out, nil +} + +// ApplyPatch parses and applies patchText. +func ApplyPatch(patchText string) (AffectedPaths, error) { + parsed, err := ParsePatch(patchText) + if err != nil { + return AffectedPaths{}, err + } + return ApplyHunksToFiles(parsed.Hunks) +} + +// MaybeParseApplyPatchVerified previews a detected patch against cwd. +func MaybeParseApplyPatchVerified(argv []string, cwd string) VerifiedResult { + if len(argv) == 1 { + if _, err := ParsePatch(argv[0]); err == nil { + return VerifiedResult{Type: VerifiedCorrectnessError, Err: errors.New(ErrorImplicitInvocation)} + } + } + result := MaybeParseApplyPatch(argv) + switch result.Type { + case MaybeBody: + effectiveCWD := cwd + if result.Args.Workdir != "" { + effectiveCWD = filepath.Join(cwd, result.Args.Workdir) + } + effectiveCWD = filepath.Clean(effectiveCWD) + changes := map[string]ApplyPatchFileChange{} + for _, hunk := range result.Args.Hunks { + targetPath := hunk.Path + if hunk.Type == "update" && hunk.MovePath != "" { + targetPath = hunk.MovePath + } + resolvedPath := resolve(effectiveCWD, targetPath) + switch hunk.Type { + case "add": + changes[resolvedPath] = ApplyPatchFileChange{Type: "add", Content: hunk.Contents} + case "delete": + deletePath := resolve(effectiveCWD, hunk.Path) + content, err := os.ReadFile(deletePath) + if err != nil { + return VerifiedResult{ + Type: VerifiedCorrectnessError, + Err: fmt.Errorf("Failed to read file for deletion: %s", deletePath), + } + } + changes[resolvedPath] = ApplyPatchFileChange{Type: "delete", Content: string(content)} + case "update": + updatePath := resolve(effectiveCWD, hunk.Path) + update, err := DeriveNewContentsFromChunks(updatePath, hunk.Chunks) + if err != nil { + return VerifiedResult{Type: VerifiedCorrectnessError, Err: err} + } + changes[resolvedPath] = ApplyPatchFileChange{ + Type: "update", + UnifiedDiff: update.UnifiedDiff, + MovePath: optionalResolved(effectiveCWD, hunk.MovePath), + NewContent: update.Content, + } + } + } + return VerifiedResult{ + Type: VerifiedBody, + Action: &ApplyPatchAction{ + Changes: changes, + Patch: result.Args.Patch, + CWD: effectiveCWD, + }, + } + case MaybePatchParseError: + return VerifiedResult{Type: VerifiedCorrectnessError, Err: result.Err} + default: + return VerifiedResult{Type: VerifiedNotApplyPatch} + } +} + +func resolve(cwd string, path string) string { + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + return filepath.Clean(filepath.Join(cwd, path)) +} + +func optionalResolved(cwd string, path string) string { + if path == "" { + return "" + } + return resolve(cwd, path) +} + +func splitBOM(value string) (bool, string) { + if strings.HasPrefix(value, "\ufeff") { + return true, value[len("\ufeff"):] + } + return false, value +} + +func joinBOM(value string, bom bool) string { + _, value = splitBOM(value) + if bom { + return "\ufeff" + value + } + return value +} + +// isSpaceRune is the whitespace set used when trimming patch lines: ASCII +// controls, the Unicode space separators, the line/paragraph separators and +// the BOM. +func isSpaceRune(r rune) bool { + if r >= 0x0009 && r <= 0x000d { + return true + } + if r >= 0x2000 && r <= 0x200a { + return true + } + switch r { + case 0x0020, 0x00a0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff: + return true + default: + return false + } +} + +// Ensure the custom Hunk marshaler satisfies encoding/json. +var _ json.Marshaler = Hunk{} diff --git a/internal/seniordev/permission/permission.go b/internal/seniordev/permission/permission.go new file mode 100644 index 000000000..6ffad4d8c --- /dev/null +++ b/internal/seniordev/permission/permission.go @@ -0,0 +1,619 @@ +//go:build !windows + +// Package permission evaluates tool permission rules. Rule and YAML-object +// order are preserved because the last matching rule wins. +package permission + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + ActionAllow = "allow" + ActionDeny = "deny" + ActionAsk = "ask" +) + +type ruleOrder uint8 + +const ( + orderPermissionPatternAction ruleOrder = iota + orderPermissionActionPattern + orderActionPermissionPattern +) + +// Rule is one permission/pattern/action entry. +type Rule struct { + Permission string + Pattern string + Action string + order ruleOrder +} + +func (r Rule) MarshalJSON() ([]byte, error) { + switch r.order { + case orderPermissionActionPattern: + return jsonutil.Marshal(struct { + Permission string `json:"permission"` + Action string `json:"action"` + Pattern string `json:"pattern"` + }{r.Permission, r.Action, r.Pattern}) + case orderActionPermissionPattern: + return jsonutil.Marshal(struct { + Action string `json:"action"` + Permission string `json:"permission"` + Pattern string `json:"pattern"` + }{r.Action, r.Permission, r.Pattern}) + default: + return jsonutil.Marshal(struct { + Permission string `json:"permission"` + Pattern string `json:"pattern"` + Action string `json:"action"` + }{r.Permission, r.Pattern, r.Action}) + } +} + +func (r *Rule) UnmarshalJSON(data []byte) error { + fields, err := orderedJSONFields(data) + if err != nil { + return err + } + order := make([]string, 0, len(fields)) + for _, field := range fields { + order = append(order, field.key) + switch field.key { + case "permission": + if err := json.Unmarshal(field.value, &r.Permission); err != nil { + return err + } + case "pattern": + if err := json.Unmarshal(field.value, &r.Pattern); err != nil { + return err + } + case "action": + if err := json.Unmarshal(field.value, &r.Action); err != nil { + return err + } + } + } + switch strings.Join(order, ",") { + case "permission,action,pattern": + r.order = orderPermissionActionPattern + case "action,permission,pattern": + r.order = orderActionPermissionPattern + default: + r.order = orderPermissionPatternAction + } + return nil +} + +// Ruleset is evaluated in slice order; the last matching rule wins. +type Ruleset []Rule + +// PatternAction is one nested config entry. +type PatternAction struct { + Pattern string + Action string +} + +// ConfigEntry is one permission entry in config source order. +type ConfigEntry struct { + Permission string + Action *string + Patterns []PatternAction +} + +// Config keeps permission entries in the order the config listed them. +type Config struct { + Entries []ConfigEntry +} + +// ParseConfigJSON decodes a permission config object without losing key +// order. Integer-like keys are ordered first, numerically. +func ParseConfigJSON(data []byte) (Config, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) > 0 && trimmed[0] == '"' { + var action string + if err := json.Unmarshal(trimmed, &action); err != nil { + return Config{}, err + } + return Config{Entries: []ConfigEntry{{ + Permission: "*", + Action: stringPointer(action), + }}}, nil + } + fields, err := orderedJSONFields(trimmed) + if err != nil { + return Config{}, err + } + fields = jsObjectFieldOrder(fields) + config := Config{Entries: make([]ConfigEntry, 0, len(fields))} + for _, field := range fields { + entry := ConfigEntry{Permission: field.key} + var action string + if err := json.Unmarshal(field.value, &action); err == nil { + entry.Action = &action + config.Entries = append(config.Entries, entry) + continue + } + nested, err := orderedJSONFields(field.value) + if err != nil { + return Config{}, fmt.Errorf("permission %q: %w", field.key, err) + } + nested = jsObjectFieldOrder(nested) + entry.Patterns = make([]PatternAction, 0, len(nested)) + for _, pattern := range nested { + if err := json.Unmarshal(pattern.value, &action); err != nil { + return Config{}, fmt.Errorf("permission %q pattern %q: %w", field.key, pattern.key, err) + } + entry.Patterns = append(entry.Patterns, PatternAction{Pattern: pattern.key, Action: action}) + } + config.Entries = append(config.Entries, entry) + } + return config, nil +} + +func (c *Config) UnmarshalJSON(data []byte) error { + value, err := ParseConfigJSON(data) + if err != nil { + return err + } + *c = value + return nil +} + +type jsonField struct { + key string + value json.RawMessage + index int +} + +func orderedJSONFields(data []byte) ([]jsonField, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + token, err := decoder.Token() + if err != nil { + return nil, err + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return nil, errors.New("expected JSON object") + } + fields := []jsonField{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := keyToken.(string) + if !ok { + return nil, errors.New("expected object key") + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + fields = append(fields, jsonField{key: key, value: value, index: len(fields)}) + } + if _, err := decoder.Token(); err != nil { + return nil, err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return nil, errors.New("trailing JSON") + } + return nil, err + } + return fields, nil +} + +func jsObjectFieldOrder(fields []jsonField) []jsonField { + out := append([]jsonField(nil), fields...) + sort.SliceStable(out, func(i, j int) bool { + left, leftOK := arrayIndex(out[i].key) + right, rightOK := arrayIndex(out[j].key) + switch { + case leftOK && rightOK: + return left < right + case leftOK: + return true + case rightOK: + return false + default: + return out[i].index < out[j].index + } + }) + return out +} + +func arrayIndex(value string) (uint64, bool) { + if value == "" || (len(value) > 1 && value[0] == '0') { + return 0, false + } + number, err := strconv.ParseUint(value, 10, 32) + if err != nil || number == 1<<32-1 { + return 0, false + } + if strconv.FormatUint(number, 10) != value { + return 0, false + } + return number, true +} + +func stringPointer(value string) *string { + return &value +} + +// FromConfig flattens an ordered config object. +func FromConfig(config Config) Ruleset { + out := Ruleset{} + for _, entry := range config.Entries { + if entry.Action != nil { + out = append(out, Rule{ + Permission: entry.Permission, + Action: *entry.Action, + Pattern: "*", + order: orderPermissionActionPattern, + }) + continue + } + for _, pattern := range entry.Patterns { + out = append(out, Rule{ + Permission: entry.Permission, + Pattern: expand(pattern.Pattern), + Action: pattern.Action, + order: orderPermissionPatternAction, + }) + } + } + return out +} + +// Evaluate returns the last matching rule across the flattened rulesets. +func Evaluate(permission string, pattern string, rulesets ...Ruleset) Rule { + for i := len(rulesets) - 1; i >= 0; i-- { + rules := rulesets[i] + for j := len(rules) - 1; j >= 0; j-- { + rule := rules[j] + if WildcardMatch(permission, rule.Permission) && WildcardMatch(pattern, rule.Pattern) { + return rule + } + } + } + return Rule{ + Action: ActionAllow, + Permission: permission, + Pattern: "*", + order: orderActionPermissionPattern, + } +} + +// Merge concatenates rulesets without deduplication. +func Merge(rulesets ...Ruleset) Ruleset { + out := Ruleset{} + for _, rules := range rulesets { + out = append(out, rules...) + } + return out +} + +// StringSet is an insertion-ordered string set. +type StringSet struct { + order []string + values map[string]struct{} +} + +func newStringSet() StringSet { + return StringSet{order: []string{}, values: map[string]struct{}{}} +} + +func (s *StringSet) add(value string) { + if _, ok := s.values[value]; ok { + return + } + s.values[value] = struct{}{} + s.order = append(s.order, value) +} + +// Has reports set membership. +func (s StringSet) Has(value string) bool { + _, ok := s.values[value] + return ok +} + +// Values returns the members in insertion order. +func (s StringSet) Values() []string { + out := make([]string, len(s.order)) + copy(out, s.order) + return out +} + +// Disabled returns tools disabled by a final whole-permission deny. +func Disabled(tools []string, rules Ruleset) StringSet { + out := newStringSet() + for _, tool := range tools { + permission := tool + if tool == "edit" || tool == "write" || tool == "apply_patch" { + permission = "edit" + } + var match *Rule + for i := len(rules) - 1; i >= 0; i-- { + if WildcardMatch(permission, rules[i].Permission) { + copy := rules[i] + match = © + break + } + } + if match != nil && match.Pattern == "*" && match.Action == ActionDeny { + out.add(tool) + } + } + return out +} + +// WildcardMatch matches value against pattern: * spans any number of UTF-16 +// code units, ? spans one, and a trailing " *" is optional as a unit. +func WildcardMatch(value string, pattern string) bool { + value = strings.ReplaceAll(value, `\`, "/") + pattern = strings.ReplaceAll(pattern, `\`, "/") + if strings.HasSuffix(pattern, " *") { + if wildcardUnits(value, pattern[:len(pattern)-2]) { + return true + } + } + return wildcardUnits(value, pattern) +} + +func wildcardUnits(value string, pattern string) bool { + input := utf16.Encode([]rune(value)) + glob := utf16.Encode([]rune(pattern)) + table := make([][]bool, len(glob)+1) + for i := range table { + table[i] = make([]bool, len(input)+1) + } + table[0][0] = true + for i := 1; i <= len(glob); i++ { + if glob[i-1] == '*' { + table[i][0] = table[i-1][0] + } + for j := 1; j <= len(input); j++ { + switch glob[i-1] { + case '*': + table[i][j] = table[i-1][j] || table[i][j-1] + case '?': + table[i][j] = table[i-1][j-1] + default: + table[i][j] = table[i-1][j-1] && glob[i-1] == input[j-1] + } + } + } + return table[len(glob)][len(input)] +} + +func expand(pattern string) string { + home, _ := os.UserHomeDir() + switch { + case strings.HasPrefix(pattern, "~/"): + return home + pattern[1:] + case pattern == "~": + return home + case strings.HasPrefix(pattern, "$HOME/"): + return home + pattern[5:] + case strings.HasPrefix(pattern, "$HOME"): + return home + pattern[5:] + default: + return pattern + } +} + +// DeniedError is returned when Ask finds a deny rule. +type DeniedError struct { + Ruleset Ruleset +} + +func (e DeniedError) Error() string { + data, err := jsonutil.Marshal(e.Ruleset) + if err != nil { + panic(err) + } + return "The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules " + string(data) +} + +// Service is the autonomous permission evaluator. Literal ask and allow both +// proceed; only deny returns an error. +type Service struct { + Approved Ruleset +} + +// AskInput is the tool-context request shape evaluated by the live registry. +type AskInput struct { + Request + Ruleset Ruleset `json:"ruleset"` +} + +// Evaluate preserves the full request metadata while applying autonomous Ask. +func (s *Service) Evaluate(input AskInput) error { + return s.Ask(input.Permission, input.Patterns, input.Ruleset) +} + +// Ask evaluates every pattern against request rules followed by approvals. +func (s *Service) Ask(permission string, patterns []string, rules Ruleset) error { + for _, pattern := range patterns { + rule := Evaluate(permission, pattern, rules, s.Approved) + if rule.Action != ActionDeny { + continue + } + relevant := Ruleset{} + for _, candidate := range rules { + if WildcardMatch(permission, candidate.Permission) { + relevant = append(relevant, candidate) + } + } + return DeniedError{Ruleset: relevant} + } + return nil +} + +// Pending returns no requests: the autonomous evaluator never queues one. +func (s *Service) Pending() []Request { + return []Request{} +} + +// Request is the value-level pending request shape. +type Request struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Permission string `json:"permission"` + Patterns []string `json:"patterns"` + Metadata map[string]any `json:"metadata"` + Always []string `json:"always"` +} + +// RulesetFromFrontmatter parses the permission mapping of an agent Markdown +// document while preserving YAML source order. +func RulesetFromFrontmatter(markdown string) (Ruleset, error) { + config, err := ConfigFromFrontmatter(markdown) + if err != nil { + return nil, err + } + return FromConfig(config), nil +} + +// ConfigFromFrontmatter parses the subset of YAML used by agent permission +// frontmatter: ordered scalar actions and one nested pattern/action mapping. +func ConfigFromFrontmatter(markdown string) (Config, error) { + lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n") + if len(lines) == 0 || lines[0] != "---" { + return Config{}, errors.New("missing YAML frontmatter") + } + end := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + end = i + break + } + } + if end == -1 { + return Config{}, errors.New("unterminated YAML frontmatter") + } + config := Config{Entries: []ConfigEntry{}} + permissionIndex := -1 + for i := 1; i < end; i++ { + indent := leadingSpaces(lines[i]) + key, value, ok := yamlKeyValue(strings.TrimSpace(lines[i])) + if !ok { + continue + } + if indent == 0 && key == "permission" { + if value != "" { + action := yamlScalar(value) + config.Entries = append(config.Entries, ConfigEntry{ + Permission: "*", + Action: stringPointer(action), + }) + return config, nil + } + permissionIndex = i + break + } + } + if permissionIndex == -1 { + return config, nil + } + + var current *ConfigEntry + for i := permissionIndex + 1; i < end; i++ { + line := lines[i] + if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + indent := leadingSpaces(line) + if indent < 2 { + break + } + key, value, ok := yamlKeyValue(strings.TrimSpace(line)) + if !ok { + continue + } + switch indent { + case 2: + entry := ConfigEntry{Permission: yamlScalar(key)} + if value != "" { + action := yamlScalar(value) + entry.Action = &action + } else { + entry.Patterns = []PatternAction{} + } + config.Entries = append(config.Entries, entry) + current = &config.Entries[len(config.Entries)-1] + default: + if indent >= 4 && current != nil && current.Action == nil { + current.Patterns = append(current.Patterns, PatternAction{ + Pattern: yamlScalar(key), + Action: yamlScalar(value), + }) + } + } + } + return config, nil +} + +func leadingSpaces(value string) int { + count := 0 + for count < len(value) && value[count] == ' ' { + count++ + } + return count +} + +func yamlKeyValue(value string) (string, string, bool) { + quoted := byte(0) + escaped := false + for i := 0; i < len(value); i++ { + char := value[i] + if quoted != 0 { + if quoted == '"' && char == '\\' && !escaped { + escaped = true + continue + } + if char == quoted && !escaped { + quoted = 0 + } + escaped = false + continue + } + if char == '\'' || char == '"' { + quoted = char + continue + } + if char == ':' { + return strings.TrimSpace(value[:i]), strings.TrimSpace(value[i+1:]), true + } + } + return "", "", false +} + +func yamlScalar(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + if decoded, err := strconv.Unquote(value); err == nil { + return decoded + } + } + if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + return strings.ReplaceAll(value[1:len(value)-1], "''", "'") + } + if index := strings.Index(value, " #"); index >= 0 { + value = value[:index] + } + return strings.TrimSpace(value) +} diff --git a/internal/seniordev/permission/permission_test.go b/internal/seniordev/permission/permission_test.go new file mode 100644 index 000000000..7acb33cf1 --- /dev/null +++ b/internal/seniordev/permission/permission_test.go @@ -0,0 +1,75 @@ +//go:build !windows + +package permission + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" +) + +func TestWildcardUTF16AndTrailingOptional(t *testing.T) { + cases := []struct { + value string + pattern string + want bool + }{ + {"git", "git *", true}, + {"git status", "git *", true}, + {"gitstatus", "git *", false}, + {"a/b", `a\b`, true}, + {"💩", "?", false}, + {"💩", "??", true}, + {"a\nb", "a*b", true}, + } + for _, test := range cases { + if got := WildcardMatch(test.value, test.pattern); got != test.want { + t.Errorf("WildcardMatch(%q, %q) = %v, want %v", test.value, test.pattern, got, test.want) + } + } +} + +func TestAutonomousAskTreatsAskAsAllowAndDenyAsError(t *testing.T) { + config, err := ParseConfigJSON([]byte(`{"read":{"*":"ask","secret":"deny"}}`)) + if err != nil { + t.Fatal(err) + } + rules := FromConfig(config) + service := &Service{} + if err := service.Ask("read", []string{"public"}, rules); err != nil { + t.Fatalf("ask should proceed: %v", err) + } + err = service.Ask("read", []string{"secret"}, rules) + var denied DeniedError + if !errors.As(err, &denied) { + t.Fatalf("error = %T %v", err, err) + } + want := "The user has specified a rule which prevents you from using this specific tool call. " + + "Here are some of the relevant rules " + + `[{"permission":"read","pattern":"*","action":"ask"},{"permission":"read","pattern":"secret","action":"deny"}]` + if err.Error() != want { + t.Fatalf("error = %q", err) + } + if len(service.Pending()) != 0 { + t.Fatalf("autonomous service has pending requests") + } +} + +func TestEveryBakedAgentPermissionFrontmatterParses(t *testing.T) { + for _, name := range baked.ListBakedAgents() { + t.Run(name, func(t *testing.T) { + markdown, ok := baked.GetBakedAgentMarkdown(name) + if !ok { + t.Fatal("missing markdown") + } + rules, err := RulesetFromFrontmatter(markdown) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rules) == 0 { + t.Fatal("permission rules unexpectedly empty") + } + }) + } +} diff --git a/internal/seniordev/project/context.go b/internal/seniordev/project/context.go new file mode 100644 index 000000000..79f96ddc2 --- /dev/null +++ b/internal/seniordev/project/context.go @@ -0,0 +1,114 @@ +//go:build !windows + +// Package project resolves the directory and project a tool call runs +// against. The instance travels on context.Context so concurrent scheduler +// leaves cannot bleed working directories into one another. +package project + +import ( + "context" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/seniordev/core" +) + +type ID string + +const GlobalID ID = "global" + +type Icon struct { + URL *string `json:"url,omitempty"` + Override *string `json:"override,omitempty"` + Color *string `json:"color,omitempty"` +} + +type Commands struct { + Start *string `json:"start,omitempty"` +} + +type Time struct { + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Initialized *int64 `json:"initialized,omitempty"` +} + +// Info is the public project record. +type Info struct { + ID ID `json:"id"` + Worktree string `json:"worktree"` + VCS *string `json:"vcs,omitempty"` + Name *string `json:"name,omitempty"` + Icon *Icon `json:"icon,omitempty"` + Commands *Commands `json:"commands,omitempty"` + Time Time `json:"time"` + Sandboxes []string `json:"sandboxes"` +} + +// InstanceContext is the execution boundary a tool call resolves paths +// against: which directory it runs in, and which project that is. +type InstanceContext struct { + Directory string `json:"directory"` + Worktree string `json:"worktree"` + Project Info `json:"project"` +} + +type instanceContextKey struct{} + +// WithContext binds an instance to ctx. The value is immutable by convention. +func WithContext(ctx context.Context, instance InstanceContext) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, instanceContextKey{}, instance) +} + +// FromContext returns the bound instance. +func FromContext(ctx context.Context) (InstanceContext, bool) { + if ctx == nil { + return InstanceContext{}, false + } + instance, ok := ctx.Value(instanceContextKey{}).(InstanceContext) + return instance, ok +} + +// Directory returns the active tool cwd, falling back when no instance is +// bound. +func Directory(ctx context.Context, fallback string) string { + if instance, ok := FromContext(ctx); ok && instance.Directory != "" { + return instance.Directory + } + return fallback +} + +// ContainsPath reports whether path is inside the instance directory or its +// worktree; a non-git worktree of "/" does not count as containing anything. +func ContainsPath(path string, instance InstanceContext) bool { + if core.Contains(instance.Directory, path) { + return true + } + if instance.Worktree == "/" { + return false + } + return core.Contains(instance.Worktree, path) +} + +// RedirectIntoDirectory remaps an absolute original-worktree path into the +// active isolated directory. +func RedirectIntoDirectory(path string, instance InstanceContext) string { + if instance.Directory == instance.Worktree || instance.Worktree == "/" { + return path + } + if core.Contains(instance.Directory, path) || !core.Contains(instance.Worktree, path) { + return path + } + relative, err := filepath.Rel(instance.Worktree, path) + if err != nil { + return path + } + return filepath.Join(instance.Directory, relative) +} + +// Provide invokes fn with an instance-bound context. +func Provide[T any](ctx context.Context, instance InstanceContext, fn func(context.Context) (T, error)) (T, error) { + return fn(WithContext(ctx, instance)) +} diff --git a/internal/seniordev/project/store.go b/internal/seniordev/project/store.go new file mode 100644 index 000000000..d28ec6510 --- /dev/null +++ b/internal/seniordev/project/store.go @@ -0,0 +1,270 @@ +//go:build !windows + +package project + +// Instance store lifecycle and project discovery. The bootstrap step is +// injected. + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type LoadInput struct { + Directory string + Worktree string + Project *Info +} + +type Bootstrap interface { + Run(ctx context.Context, instance InstanceContext) error +} + +type BootstrapFunc func(context.Context, InstanceContext) error + +func (f BootstrapFunc) Run(ctx context.Context, instance InstanceContext) error { + return f(ctx, instance) +} + +type Discoverer interface { + FromDirectory(ctx context.Context, directory string) (Info, string, error) +} + +type DiscovererFunc func(context.Context, string) (Info, string, error) + +func (f DiscovererFunc) FromDirectory(ctx context.Context, directory string) (Info, string, error) { + return f(ctx, directory) +} + +type storeEntry struct { + ready chan struct{} + value InstanceContext + err error +} + +// Store caches one booted instance per resolved active directory. +type Store struct { + discover Discoverer + bootstrap Bootstrap + + mu sync.Mutex + cache map[string]*storeEntry +} + +func NewStore(discover Discoverer, bootstrap Bootstrap) *Store { + if discover == nil { + discover = DiscovererFunc(Discover) + } + return &Store{discover: discover, bootstrap: bootstrap, cache: map[string]*storeEntry{}} +} + +func resolvedDirectory(path string) string { + absolute, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(absolute) +} + +func (s *Store) boot(ctx context.Context, input LoadInput) (InstanceContext, error) { + instance := InstanceContext{Directory: input.Directory} + if input.Project != nil && input.Worktree != "" { + instance.Worktree = input.Worktree + instance.Project = *input.Project + } else { + project, sandbox, err := s.discover.FromDirectory(ctx, input.Directory) + if err != nil { + return InstanceContext{}, err + } + instance.Project, instance.Worktree = project, sandbox + } + if s.bootstrap != nil { + if err := s.bootstrap.Run(WithContext(ctx, instance), instance); err != nil { + return InstanceContext{}, err + } + } + return instance, nil +} + +func (s *Store) Load(ctx context.Context, input LoadInput) (InstanceContext, error) { + input.Directory = resolvedDirectory(input.Directory) + s.mu.Lock() + if entry := s.cache[input.Directory]; entry != nil { + s.mu.Unlock() + select { + case <-ctx.Done(): + return InstanceContext{}, ctx.Err() + case <-entry.ready: + return entry.value, entry.err + } + } + entry := &storeEntry{ready: make(chan struct{})} + s.cache[input.Directory] = entry + s.mu.Unlock() + + entry.value, entry.err = s.boot(ctx, input) + if entry.err != nil { + s.mu.Lock() + if s.cache[input.Directory] == entry { + delete(s.cache, input.Directory) + } + s.mu.Unlock() + } + close(entry.ready) + return entry.value, entry.err +} + +func (s *Store) Reload(ctx context.Context, input LoadInput) (InstanceContext, error) { + input.Directory = resolvedDirectory(input.Directory) + entry := &storeEntry{ready: make(chan struct{})} + s.mu.Lock() + s.cache[input.Directory] = entry + s.mu.Unlock() + entry.value, entry.err = s.boot(ctx, input) + if entry.err != nil { + s.mu.Lock() + if s.cache[input.Directory] == entry { + delete(s.cache, input.Directory) + } + s.mu.Unlock() + } + close(entry.ready) + return entry.value, entry.err +} + +func (s *Store) Dispose(instance InstanceContext) { + s.mu.Lock() + delete(s.cache, filepath.Clean(instance.Directory)) + s.mu.Unlock() +} + +func (s *Store) DisposeAll() { + s.mu.Lock() + s.cache = map[string]*storeEntry{} + s.mu.Unlock() +} + +func (s *Store) Provide(ctx context.Context, input LoadInput, fn func(context.Context) error) error { + instance, err := s.Load(ctx, input) + if err != nil { + return err + } + return fn(WithContext(ctx, instance)) +} + +func runGit(ctx context.Context, cwd string, args ...string) (string, bool) { + command := exec.CommandContext(ctx, "git", args...) + command.Dir = cwd + var stdout bytes.Buffer + command.Stdout = &stdout + command.Stderr = &bytes.Buffer{} + if err := command.Run(); err != nil { + return "", false + } + return strings.TrimSpace(stdout.String()), true +} + +func findDotGit(directory string) string { + current := directory + for { + if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { + return filepath.Join(current, ".git") + } + parent := filepath.Dir(current) + if parent == current { + return "" + } + current = parent + } +} + +func readCachedProjectID(dotGit string) ID { + data, err := os.ReadFile(filepath.Join(dotGit, "senior-dev")) + if err != nil { + return "" + } + return ID(strings.TrimSpace(string(data))) +} + +// Discover resolves the project for directory: inside a git repository it +// yields the worktree and a stable project id, elsewhere the global project. +func Discover(ctx context.Context, directory string) (Info, string, error) { + directory = resolvedDirectory(directory) + now := time.Now().UnixMilli() + dotGit := findDotGit(directory) + if dotGit == "" { + return Info{ + ID: GlobalID, Worktree: "/", Time: Time{Created: now, Updated: now}, + Sandboxes: []string{"/"}, + }, "/", nil + } + sandbox := filepath.Dir(dotGit) + projectID := readCachedProjectID(dotGit) + commonRaw, gitOK := runGit(ctx, sandbox, "rev-parse", "--git-common-dir") + if !gitOK { + if projectID == "" { + projectID = GlobalID + } + vcs := "git" + return Info{ + ID: projectID, Worktree: sandbox, VCS: &vcs, + Time: Time{Created: now, Updated: now}, Sandboxes: []string{sandbox}, + }, sandbox, nil + } + common := commonRaw + if !filepath.IsAbs(common) { + common = filepath.Join(sandbox, common) + } + common = filepath.Clean(common) + bareRaw, bareOK := runGit(ctx, sandbox, "config", "--bool", "core.bare") + isBare := bareOK && bareRaw == "true" + worktree := filepath.Dir(common) + if common == sandbox { + worktree = sandbox + } else if isBare { + worktree = common + } + if projectID == "" { + projectID = readCachedProjectID(common) + } + if projectID == "" { + rootsRaw, _ := runGit(ctx, sandbox, "rev-list", "--max-parents=0", "HEAD") + roots := []string{} + for _, root := range strings.Split(rootsRaw, "\n") { + if root = strings.TrimSpace(root); root != "" { + roots = append(roots, root) + } + } + sort.Strings(roots) + if len(roots) > 0 { + projectID = ID(roots[0]) + _ = os.WriteFile(filepath.Join(common, "senior-dev"), []byte(projectID), 0o644) + } + } + if projectID == "" { + projectID = GlobalID + } + vcs := "git" + return Info{ + ID: projectID, Worktree: worktree, VCS: &vcs, + Time: Time{Created: now, Updated: now}, Sandboxes: []string{sandbox}, + }, sandbox, nil +} + +var ErrNoInstance = errors.New("project: no instance in context") + +func Require(ctx context.Context) (InstanceContext, error) { + instance, ok := FromContext(ctx) + if !ok { + return InstanceContext{}, ErrNoInstance + } + return instance, nil +} diff --git a/internal/seniordev/project/store_test.go b/internal/seniordev/project/store_test.go new file mode 100644 index 000000000..311251dfe --- /dev/null +++ b/internal/seniordev/project/store_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package project + +import ( + "context" + "sync" + "sync/atomic" + "testing" +) + +func TestStoreCoalescesConcurrentLoadsAndBindsBootstrapContext(t *testing.T) { + directory := t.TempDir() + var discovers atomic.Int32 + var bootstraps atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + discover := DiscovererFunc(func(_ context.Context, directory string) (Info, string, error) { + if discovers.Add(1) == 1 { + close(started) + } + <-release + return Info{ID: "p", Worktree: directory, Sandboxes: []string{directory}}, directory, nil + }) + bootstrap := BootstrapFunc(func(ctx context.Context, instance InstanceContext) error { + bootstraps.Add(1) + got, ok := FromContext(ctx) + if !ok || got.Directory != instance.Directory { + t.Errorf("bootstrap context = %#v, %v", got, ok) + } + return nil + }) + store := NewStore(discover, bootstrap) + var wait sync.WaitGroup + wait.Add(2) + results := make(chan InstanceContext, 2) + for index := range 2 { + if index == 1 { + <-started + } + go func() { + defer wait.Done() + value, err := store.Load(context.Background(), LoadInput{Directory: directory}) + if err != nil { + t.Errorf("Load: %v", err) + return + } + results <- value + }() + } + close(release) + wait.Wait() + close(results) + if discovers.Load() != 1 || bootstraps.Load() != 1 { + t.Fatalf("duplicate work: discover=%d bootstrap=%d", discovers.Load(), bootstraps.Load()) + } +} diff --git a/internal/seniordev/question/question.go b/internal/seniordev/question/question.go new file mode 100644 index 000000000..07ba7f4c1 --- /dev/null +++ b/internal/seniordev/question/question.go @@ -0,0 +1,254 @@ +//go:build !windows + +// Question lifecycle service +package question + +import ( + "context" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" +) + +// Event contains the three question bus definitions. +var Event = struct { + Asked bus.Definition + Replied bus.Definition + Rejected bus.Definition +}{ + Asked: bus.Define("question.asked", "QuestionRequest"), + Replied: bus.Define("question.replied", "QuestionReplied"), + Rejected: bus.Define("question.rejected", "QuestionRejected"), +} + +// RejectedError is returned when a question is dismissed or the service is +// finalized. +type RejectedError struct{} + +// Error is the model-visible dismissal message. +func (*RejectedError) Error() string { return "The user dismissed this question" } + +// Publisher is the narrow bus surface used by Service. +type Publisher interface { + Publish(bus.Definition, any, ...bus.PublishOptions) +} + +// IDGenerator creates a new ascending question identifier. +type IDGenerator func() (QuestionID, error) + +// AskInput is the input accepted by Service.Ask. +type AskInput struct { + SessionID string + Questions []Info + Tool *Tool +} + +// ReplyInput is the input accepted by Service.Reply. +type ReplyInput struct { + RequestID QuestionID + Answers []Answer +} + +type pendingResult struct { + answers []Answer + err error +} + +type pendingEntry struct { + info Request + deferred chan pendingResult +} + +// Service owns the insertion-ordered pending-question map. +type Service struct { + mu sync.Mutex + pending map[QuestionID]*pendingEntry + order []QuestionID + publisher Publisher + createID IDGenerator + closed bool +} + +// NewService constructs a question service. Nil dependencies use the +// package-level bus and QuestionID generator. +func NewService(publisher Publisher, createID IDGenerator) *Service { + if publisher == nil { + publisher = bus.Default + } + if createID == nil { + createID = func() (QuestionID, error) { return AscendingQuestionID() } + } + return &Service{ + pending: make(map[QuestionID]*pendingEntry), + publisher: publisher, + createID: createID, + } +} + +// Ask registers and publishes a request, then waits for a reply, rejection, or +// context cancellation. The pending entry is removed on every exit path. +func (s *Service) Ask(ctx context.Context, input AskInput) ([]Answer, error) { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return nil, &RejectedError{} + } + requestID, err := s.createID() + if err != nil { + return nil, err + } + request := Request{ + ID: requestID, + SessionID: input.SessionID, + Questions: cloneInfos(input.Questions), + Tool: cloneTool(input.Tool), + } + entry := &pendingEntry{info: request, deferred: make(chan pendingResult, 1)} + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil, &RejectedError{} + } + if _, exists := s.pending[requestID]; !exists { + s.order = append(s.order, requestID) + } + s.pending[requestID] = entry + s.mu.Unlock() + + defer s.delete(requestID) + s.publisher.Publish(Event.Asked, request) + + select { + case result := <-entry.deferred: + return result.answers, result.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Reply publishes the reply and resolves its pending Ask. Unknown request IDs +// are ignored. +func (s *Service) Reply(input ReplyInput) { + entry := s.take(input.RequestID) + if entry == nil { + return + } + s.publisher.Publish(Event.Replied, Replied{ + SessionID: entry.info.SessionID, + RequestID: entry.info.ID, + Answers: cloneAnswers(input.Answers), + }) + entry.deferred <- pendingResult{answers: input.Answers} +} + +// Reject publishes the rejection and fails its pending Ask. Unknown request +// IDs are ignored. +func (s *Service) Reject(requestID QuestionID) { + entry := s.take(requestID) + if entry == nil { + return + } + s.publisher.Publish(Event.Rejected, Rejected{ + SessionID: entry.info.SessionID, + RequestID: entry.info.ID, + }) + entry.deferred <- pendingResult{err: &RejectedError{}} +} + +// List returns pending requests in insertion order. +func (s *Service) List() []Request { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]Request, 0, len(s.order)) + for _, requestID := range s.order { + if entry := s.pending[requestID]; entry != nil { + result = append(result, entry.info) + } + } + return result +} + +// Close rejects every waiter, clears the pending map, and makes subsequent +// asks fail as dismissed. +func (s *Service) Close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + entries := make([]*pendingEntry, 0, len(s.order)) + for _, requestID := range s.order { + if entry := s.pending[requestID]; entry != nil { + entries = append(entries, entry) + } + } + s.pending = make(map[QuestionID]*pendingEntry) + s.order = nil + s.mu.Unlock() + + for _, entry := range entries { + entry.deferred <- pendingResult{err: &RejectedError{}} + } +} + +func (s *Service) take(requestID QuestionID) *pendingEntry { + s.mu.Lock() + defer s.mu.Unlock() + entry := s.pending[requestID] + if entry == nil { + return nil + } + delete(s.pending, requestID) + s.removeOrder(requestID) + return entry +} + +func (s *Service) delete(requestID QuestionID) { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.pending[requestID]; !exists { + return + } + delete(s.pending, requestID) + s.removeOrder(requestID) +} + +func (s *Service) removeOrder(requestID QuestionID) { + for index, item := range s.order { + if item == requestID { + s.order = append(s.order[:index], s.order[index+1:]...) + return + } + } +} + +func cloneTool(tool *Tool) *Tool { + if tool == nil { + return nil + } + result := *tool + return &result +} + +func cloneInfos(questions []Info) []Info { + result := make([]Info, len(questions)) + for index, question := range questions { + result[index] = question + result[index].Options = append([]Option{}, question.Options...) + } + return result +} + +func cloneAnswers(answers []Answer) []Answer { + result := make([]Answer, len(answers)) + for index, answer := range answers { + result[index] = append(Answer{}, answer...) + } + return result +} + +// Default is the package-level service backed by bus.Default. +var Default = NewService(nil, nil) diff --git a/internal/seniordev/question/question_test.go b/internal/seniordev/question/question_test.go new file mode 100644 index 000000000..e4739f61f --- /dev/null +++ b/internal/seniordev/question/question_test.go @@ -0,0 +1,264 @@ +//go:build !windows + +package question + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" +) + +type askResult struct { + answers []Answer + err error +} + +func isolatedService(ids ...QuestionID) (*Service, *bus.Bus, <-chan bus.Payload) { + instanceBus := bus.New( + bus.Context{}, + bus.WithIDGenerator(func() string { return "evt_local" }), + ) + events := make(chan bus.Payload, 32) + instanceBus.SubscribeAllCallback(func(payload bus.Payload) { events <- payload }) + index := 0 + var idMutex sync.Mutex + service := NewService(instanceBus, func() (QuestionID, error) { + idMutex.Lock() + defer idMutex.Unlock() + value := ids[index] + index++ + return value, nil + }) + return service, instanceBus, events +} + +func askAsync(service *Service, input AskInput) <-chan askResult { + result := make(chan askResult, 1) + go func() { + answers, err := service.Ask(context.Background(), input) + result <- askResult{answers: answers, err: err} + }() + return result +} + +func receiveEvent(t *testing.T, events <-chan bus.Payload) bus.Payload { + t.Helper() + select { + case event := <-events: + return event + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for event") + return bus.Payload{} + } +} + +func receiveAsk(t *testing.T, result <-chan askResult) askResult { + t.Helper() + select { + case value := <-result: + return value + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Ask") + return askResult{} + } +} + +func TestAskReplyLifecycleAndEvents(t *testing.T) { + service, _, events := isolatedService("que_1") + question := Info{ + Question: "Continue?", + Header: "Confirm", + Options: []Option{{Label: "Yes", Description: "Continue"}}, + } + result := askAsync(service, AskInput{ + SessionID: "ses_1", + Questions: []Info{question}, + Tool: &Tool{MessageID: "msg_1", CallID: "call-1"}, + }) + + asked := receiveEvent(t, events) + if asked.Type != Event.Asked.Type { + t.Fatalf("event type = %q", asked.Type) + } + request, ok := asked.Properties.(Request) + if !ok || request.ID != "que_1" || request.SessionID != "ses_1" || + request.Tool == nil || request.Tool.CallID != "call-1" { + t.Fatalf("asked payload = %#v", asked.Properties) + } + listed := service.List() + if len(listed) != 1 || listed[0].ID != "que_1" { + t.Fatalf("List = %#v", listed) + } + + answers := []Answer{{"Yes"}, {"custom"}} + service.Reply(ReplyInput{RequestID: "que_1", Answers: answers}) + replied := receiveEvent(t, events) + if replied.Type != Event.Replied.Type { + t.Fatalf("event type = %q", replied.Type) + } + properties, ok := replied.Properties.(Replied) + if !ok || properties.SessionID != "ses_1" || properties.RequestID != "que_1" || + !reflect.DeepEqual(properties.Answers, answers) { + t.Fatalf("replied payload = %#v", replied.Properties) + } + answers[0][0] = "mutated" + if properties.Answers[0][0] != "Yes" { + t.Fatal("published answers alias Reply input") + } + + got := receiveAsk(t, result) + if got.err != nil || got.answers[0][0] != "mutated" { + t.Fatalf("Ask = %#v", got) + } + if len(service.List()) != 0 { + t.Fatalf("pending after reply = %#v", service.List()) + } +} + +func TestRejectLifecycleAndMessage(t *testing.T) { + service, _, events := isolatedService("que_2") + result := askAsync(service, AskInput{SessionID: "ses_2", Questions: []Info{}}) + _ = receiveEvent(t, events) + + service.Reject("que_2") + rejected := receiveEvent(t, events) + properties, ok := rejected.Properties.(Rejected) + if rejected.Type != Event.Rejected.Type || !ok || + properties.SessionID != "ses_2" || properties.RequestID != "que_2" { + t.Fatalf("rejected event = %#v", rejected) + } + got := receiveAsk(t, result) + var rejectedError *RejectedError + if !errors.As(got.err, &rejectedError) { + t.Fatalf("Ask error = %v", got.err) + } + if got.err.Error() != "The user dismissed this question" { + t.Fatalf("message = %q", got.err) + } +} + +func TestUnknownReplyAndRejectAreNoOps(t *testing.T) { + service, _, events := isolatedService("que_unused") + service.Reply(ReplyInput{RequestID: "que_unknown", Answers: []Answer{{"x"}}}) + service.Reject("que_unknown") + select { + case event := <-events: + t.Fatalf("unexpected event %#v", event) + default: + } +} + +func TestListPreservesInsertionOrder(t *testing.T) { + service, _, events := isolatedService("que_1", "que_2", "que_3") + var results []<-chan askResult + for _, sessionID := range []string{"ses_1", "ses_2", "ses_3"} { + results = append(results, askAsync(service, AskInput{ + SessionID: sessionID, Questions: []Info{}, + })) + _ = receiveEvent(t, events) + } + listed := service.List() + var ids []QuestionID + for _, request := range listed { + ids = append(ids, request.ID) + } + if want := []QuestionID{"que_1", "que_2", "que_3"}; !reflect.DeepEqual(ids, want) { + t.Fatalf("ids = %#v, want %#v", ids, want) + } + for index, requestID := range ids { + service.Reply(ReplyInput{RequestID: requestID, Answers: []Answer{}}) + _ = receiveEvent(t, events) + if got := receiveAsk(t, results[index]); got.err != nil { + t.Fatal(got.err) + } + } +} + +func TestSynchronousAskedSubscriberCanReply(t *testing.T) { + instanceBus := bus.New( + bus.Context{}, + bus.WithIDGenerator(func() string { return "evt_local" }), + ) + var service *Service + instanceBus.SubscribeCallback(Event.Asked, func(payload bus.Payload) { + request := payload.Properties.(Request) + service.Reply(ReplyInput{RequestID: request.ID, Answers: []Answer{{"Immediately"}}}) + }) + service = NewService(instanceBus, func() (QuestionID, error) { return "que_sync", nil }) + answers, err := service.Ask(context.Background(), AskInput{ + SessionID: "ses_1", Questions: []Info{}, + }) + if err != nil || !reflect.DeepEqual(answers, []Answer{{"Immediately"}}) { + t.Fatalf("Ask = %#v, %v", answers, err) + } +} + +func TestContextCancellationRemovesPending(t *testing.T) { + service, _, events := isolatedService("que_cancel") + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan askResult, 1) + go func() { + answers, err := service.Ask(ctx, AskInput{SessionID: "ses_1", Questions: []Info{}}) + result <- askResult{answers: answers, err: err} + }() + _ = receiveEvent(t, events) + cancel() + got := receiveAsk(t, result) + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("Ask error = %v", got.err) + } + if len(service.List()) != 0 { + t.Fatalf("pending after cancel = %#v", service.List()) + } +} + +func TestCloseRejectsAllWithoutPublishingRejectedEvents(t *testing.T) { + service, _, events := isolatedService("que_1", "que_2") + first := askAsync(service, AskInput{SessionID: "ses_1", Questions: []Info{}}) + _ = receiveEvent(t, events) + second := askAsync(service, AskInput{SessionID: "ses_2", Questions: []Info{}}) + _ = receiveEvent(t, events) + + service.Close() + for _, result := range []<-chan askResult{first, second} { + got := receiveAsk(t, result) + var rejected *RejectedError + if !errors.As(got.err, &rejected) { + t.Fatalf("Ask error = %v", got.err) + } + } + if len(service.List()) != 0 { + t.Fatalf("pending after Close = %#v", service.List()) + } + select { + case event := <-events: + t.Fatalf("Close published event %#v", event) + default: + } + _, err := service.Ask(context.Background(), AskInput{}) + var rejected *RejectedError + if !errors.As(err, &rejected) { + t.Fatalf("Ask after Close error = %v", err) + } +} + +func TestConcurrentUnknownOperations(t *testing.T) { + service, _, _ := isolatedService("que_unused") + var wait sync.WaitGroup + for index := range 100 { + wait.Add(1) + go func() { + defer wait.Done() + requestID := QuestionID("que_" + string(rune(index))) + service.Reply(ReplyInput{RequestID: requestID}) + service.Reject(requestID) + _ = service.List() + }() + } + wait.Wait() +} diff --git a/internal/seniordev/question/schema.go b/internal/seniordev/question/schema.go new file mode 100644 index 000000000..5226e7382 --- /dev/null +++ b/internal/seniordev/question/schema.go @@ -0,0 +1,281 @@ +//go:build !windows + +// Question payload types and their JSON acceptance checks. +package question + +import ( + "bytes" + "encoding/json" + + idpkg "github.com/Agent-Field/codeaf/internal/seniordev/id" +) + +// QuestionID is the branded string used to identify a pending question. +type QuestionID string + +// Option is one selectable response. +type Option struct { + Label string `json:"label"` + Description string `json:"description"` +} + +// Info describes a question presented to a user. +type Info struct { + Question string `json:"question"` + Header string `json:"header"` + Options []Option `json:"options"` + Multiple *bool `json:"multiple,omitempty"` + Custom *bool `json:"custom,omitempty"` +} + +// Prompt is the tool-facing question shape before the custom-answer flag is +// added. +type Prompt struct { + Question string `json:"question"` + Header string `json:"header"` + Options []Option `json:"options"` + Multiple *bool `json:"multiple,omitempty"` +} + +// Tool links a question request to its originating tool call. +type Tool struct { + MessageID string `json:"messageID"` + CallID string `json:"callID"` +} + +// Request is the payload of question.asked. +type Request struct { + ID QuestionID `json:"id"` + SessionID string `json:"sessionID"` + Questions []Info `json:"questions"` + Tool *Tool `json:"tool,omitempty"` +} + +// Answer contains the selected labels for one question. +type Answer []string + +// Reply is the HTTP/tool reply body. +type Reply struct { + Answers []Answer `json:"answers"` +} + +// Replied is the payload of question.replied. +type Replied struct { + SessionID string `json:"sessionID"` + RequestID QuestionID `json:"requestID"` + Answers []Answer `json:"answers"` +} + +// Rejected is the payload of question.rejected. +type Rejected struct { + SessionID string `json:"sessionID"` + RequestID QuestionID `json:"requestID"` +} + +// AscendingQuestionID returns a new ascending question ID, or validates and +// returns given. +func AscendingQuestionID(given ...string) (QuestionID, error) { + value, err := idpkg.Ascending("question", given...) + return QuestionID(value), err +} + +// SchemaAccepts reports whether raw is a valid JSON encoding of the named +// question type. Mode "strict" additionally requires branded ID strings to carry +// their prefix; mode "basic" checks structure only. +func SchemaAccepts(kind, mode string, raw json.RawMessage) bool { + if mode != "basic" && mode != "strict" { + return false + } + strict := mode == "strict" + switch kind { + case "option": + return acceptsOption(raw) + case "info": + return acceptsInfo(raw) + case "prompt": + return acceptsPrompt(raw) + case "tool": + return acceptsTool(raw, strict) + case "request": + return acceptsRequest(raw, strict) + case "answer": + return acceptsAnswer(raw) + case "reply": + return acceptsReply(raw) + case "questionID": + value, ok := rawString(raw) + return ok && (!strict || idpkg.SchemaAccepts("question", value)) + default: + return false + } +} + +func acceptsOption(raw json.RawMessage) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + _, labelOK := requiredString(object, "label") + _, descriptionOK := requiredString(object, "description") + return labelOK && descriptionOK +} + +func acceptsInfo(raw json.RawMessage) bool { + object, ok := rawObject(raw) + if !ok || !acceptsBase(object) { + return false + } + return optionalBool(object, "custom") +} + +func acceptsPrompt(raw json.RawMessage) bool { + object, ok := rawObject(raw) + return ok && acceptsBase(object) +} + +func acceptsBase(object map[string]json.RawMessage) bool { + if _, ok := requiredString(object, "question"); !ok { + return false + } + if _, ok := requiredString(object, "header"); !ok { + return false + } + options, ok := rawArray(object["options"]) + if !ok { + return false + } + for _, option := range options { + if !acceptsOption(option) { + return false + } + } + return optionalBool(object, "multiple") +} + +func acceptsTool(raw json.RawMessage, strict bool) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + messageID, ok := requiredString(object, "messageID") + if !ok || (strict && !idpkg.SchemaAccepts("message", messageID)) { + return false + } + _, ok = requiredString(object, "callID") + return ok +} + +func acceptsRequest(raw json.RawMessage, strict bool) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + requestID, ok := requiredString(object, "id") + if !ok || (strict && !idpkg.SchemaAccepts("question", requestID)) { + return false + } + sessionID, ok := requiredString(object, "sessionID") + if !ok || (strict && !idpkg.SchemaAccepts("session", sessionID)) { + return false + } + questions, ok := rawArray(object["questions"]) + if !ok { + return false + } + for _, question := range questions { + if !acceptsInfo(question) { + return false + } + } + if tool, exists := object["tool"]; exists && !acceptsTool(tool, strict) { + return false + } + return true +} + +func acceptsAnswer(raw json.RawMessage) bool { + answers, ok := rawArray(raw) + if !ok { + return false + } + for _, answer := range answers { + if _, ok := rawString(answer); !ok { + return false + } + } + return true +} + +func acceptsReply(raw json.RawMessage) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + answers, ok := rawArray(object["answers"]) + if !ok { + return false + } + for _, answer := range answers { + if !acceptsAnswer(answer) { + return false + } + } + return true +} + +func rawObject(raw json.RawMessage) (map[string]json.RawMessage, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil, false + } + var value map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &value); err != nil || value == nil { + return nil, false + } + return value, true +} + +func rawArray(raw json.RawMessage) ([]json.RawMessage, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '[' { + return nil, false + } + var value []json.RawMessage + if err := json.Unmarshal(trimmed, &value); err != nil { + return nil, false + } + return value, true +} + +func rawString(raw json.RawMessage) (string, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '"' { + return "", false + } + var value string + if json.Unmarshal(trimmed, &value) != nil { + return "", false + } + return value, true +} + +func requiredString(object map[string]json.RawMessage, key string) (string, bool) { + raw, exists := object[key] + if !exists { + return "", false + } + return rawString(raw) +} + +func optionalBool(object map[string]json.RawMessage, key string) bool { + raw, exists := object[key] + if !exists { + return true + } + trimmed := bytes.TrimSpace(raw) + if !bytes.Equal(trimmed, []byte("true")) && !bytes.Equal(trimmed, []byte("false")) { + return false + } + var value bool + return json.Unmarshal(trimmed, &value) == nil +} diff --git a/internal/seniordev/router/adaptive/adaptive.go b/internal/seniordev/router/adaptive/adaptive.go new file mode 100644 index 000000000..706357b13 --- /dev/null +++ b/internal/seniordev/router/adaptive/adaptive.go @@ -0,0 +1,1111 @@ +//go:build !windows + +// Package adaptive is the adaptive model router: it picks a model for each +// call from the configured pool for the caller's tier (high, low, frontier) +// by sampling a score from each candidate's recorded reliability, speed, +// price and current load, and it learns from every registered outcome +// (latency, throughput, rate limits and other failures, each with its own +// cooldown). +// +// A tier whose pool is empty resolves to the high pool, so a run configured +// with nothing but a high pool routes every tier on it. +// +// A single router-wide mutex serialises every public method; Pick takes and +// releases it around each TryPick and never holds it across a sleep. +package adaptive + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" +) + +// ── injectable ambient dependencies ────────────────────────────────────── + +// nowMillis is the wall clock in milliseconds. +var nowMillis = func() float64 { return float64(time.Now().UnixMilli()) } + +// SetClockForTesting pins the clock. Returns a restore func. +func SetClockForTesting(f func() float64) func() { + prev := nowMillis + nowMillis = f + return func() { nowMillis = prev } +} + +// randomFloat is the unseeded random source used when no RandomSeed is +// configured. +var randomFloat = func() float64 { return rand.Float64() } + +// SetRandomForTesting pins the unseeded random source. Returns a restore func. +func SetRandomForTesting(f func() float64) func() { + prev := randomFloat + randomFloat = f + return func() { randomFloat = prev } +} + +// sleepMillis is the backoff sleep inside Pick, interruptible by the signal. +var sleepMillis = defaultSleep + +// SetSleeperForTesting pins the pick() backoff sleep. Returns a restore func. +func SetSleeperForTesting(f func(ms float64, signal *AbortSignal)) func() { + prev := sleepMillis + sleepMillis = f + return func() { sleepMillis = prev } +} + +func defaultSleep(ms float64, signal *AbortSignal) { + d := time.Duration(0) + if !math.IsNaN(ms) && ms > 0 { + if ms > 1e15 { + ms = 1e15 + } + d = time.Duration(ms * float64(time.Millisecond)) + } + timer := time.NewTimer(d) + defer timer.Stop() + if signal == nil { + <-timer.C + return + } + select { + case <-timer.C: + case <-signal.Done(): + } +} + +// AbortSignal lets a caller interrupt a blocking Pick. +type AbortSignal struct { + mu sync.Mutex + aborted bool + done chan struct{} +} + +func NewAbortSignal() *AbortSignal { return &AbortSignal{done: make(chan struct{})} } + +func (s *AbortSignal) Abort() { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if !s.aborted { + s.aborted = true + close(s.done) + } +} + +func (s *AbortSignal) Aborted() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.aborted +} + +func (s *AbortSignal) Done() <-chan struct{} { + if s == nil { + return nil + } + return s.done +} + +// ── ModelTier ──────────────────────────────────────────────────────────── + +// ModelTier names one of the three configurable model pools. A caller asks +// for a tier; EffectiveTier turns that request into the tier actually routed +// on, which is the high tier whenever the requested pool is empty. +type ModelTier string + +const ( + ModelTierHigh ModelTier = "high" + ModelTierLow ModelTier = "low" + ModelTierFrontier ModelTier = "frontier" +) + +// ── ModelCandidate ─────────────────────────────────────────────────────── + +// ModelCandidate is one routable model. +type ModelCandidate struct { + // ID is the full model id, e.g. "openrouter/qwen/qwen3.6-plus". + ID string `json:"id"` + // Tier is the pool the candidate was configured into. + Tier ModelTier `json:"tier"` + PromptUSDPerMtok float64 `json:"prompt_usd_per_mtok"` + CompletionUSDPerMtok float64 `json:"completion_usd_per_mtok"` + // Priority is the config index: lower = preferred when stats are absent. + Priority float64 `json:"priority"` +} + +// ── event / config / choice shapes ─────────────────────────────────────── + +// AdaptiveRouteEvent describes one registered outcome or cancellation. +type AdaptiveRouteEvent struct { + Slot string `json:"slot"` + Tier ModelTier `json:"tier"` + Model string `json:"model"` + PreviousModel string `json:"previous_model"` + Switched bool `json:"switched"` + Reason string `json:"reason"` + Score float64 `json:"score"` + ElapsedS float64 `json:"elapsed_s"` + Attempts float64 `json:"attempts"` + Successes float64 `json:"successes"` + Failures float64 `json:"failures"` + RateLimits float64 `json:"rate_limits"` + LatencyEwma float64 `json:"latency_ewma"` + ToksecEwma float64 `json:"toksec_ewma"` + Error string `json:"error"` +} + +// AdaptiveRouterConfig configures a router. A nil pointer field takes its +// default, and so does an empty high pool; an empty low or frontier pool +// stays empty and degrades to high instead. +type AdaptiveRouterConfig struct { + HighModels []ModelCandidate `json:"high_models"` + // LowModels and FrontierModels have no default: an unset pool stays empty + // and every caller asking for it routes on HIGH instead. + LowModels []ModelCandidate `json:"low_models"` + FrontierModels []ModelCandidate `json:"frontier_models"` + MaxAttempts *float64 `json:"max_attempts"` + // RandomSeed makes the router's sampling reproducible. Nil uses the + // process-wide random source. + RandomSeed *float64 `json:"random_seed"` + OnEvent func(AdaptiveRouteEvent) `json:"-"` +} + +type modelStats struct { + ModelID string + Slot string + Tier ModelTier + Attempts float64 + Successes float64 + Failures float64 + RateLimits float64 + Inflight float64 + CooldownUntilMs float64 + LatencyEwma float64 + ToksecEwma float64 + QualityEwma float64 + RewardCount float64 + LastError string +} + +// RouteChoice is a picked candidate together with why it was picked. +type RouteChoice struct { + Slot string `json:"slot"` + // Tier is the tier actually routed on, after degradation. + Tier ModelTier `json:"tier"` + Candidate ModelCandidate `json:"candidate"` + Score float64 `json:"score"` + PreviousModel string `json:"previous_model"` + Switched bool `json:"switched"` + Reason string `json:"reason"` +} + +// ── defaults ───────────────────────────────────────────────────────────── + +func defaultPool(tier ModelTier, rows [][3]any) []ModelCandidate { + out := make([]ModelCandidate, 0, len(rows)) + for _, row := range rows { + out = append(out, ModelCandidate{ + ID: row[0].(string), + Tier: tier, + PromptUSDPerMtok: row[1].(float64), + CompletionUSDPerMtok: row[2].(float64), + }) + } + return out +} + +func DefaultHighModels() []ModelCandidate { + return defaultPool(ModelTierHigh, [][3]any{ + {"openrouter/deepseek/deepseek-v4-flash-0731", 0.09, 0.18}, + {"openrouter/qwen/qwen3.6-plus", 0.325, 1.95}, + {"openrouter/qwen/qwen3.5-plus-20260420", 0.4, 2.4}, + {"openrouter/deepseek/deepseek-v4-pro", 0.435, 0.87}, + {"openrouter/moonshotai/kimi-k2.6", 0.74, 3.49}, + {"openrouter/z-ai/glm-5.1", 0.98, 3.08}, + {"openrouter/minimax/minimax-m2.7", 0.2, 1.2}, + {"openrouter/qwen/qwen3-coder-next", 0.14, 0.8}, + }) +} + +// ── normalization ──────────────────────────────────────────────────────── + +// NormalizeCandidateModel trims a model id; the "openrouter/" prefix stays. +func NormalizeCandidateModel(model string) string { + return strings.TrimSpace(model) +} + +// statsKey identifies a stats row. A leading "openrouter/" is stripped so +// the prefixed and bare spellings of one model share their history. +func statsKey(slot string, candidate ModelCandidate) string { + id := strings.TrimSpace(candidate.ID) + id = strings.TrimPrefix(id, "openrouter/") + return strings.TrimSpace(slot) + ":" + id +} + +type normalizedConfig struct { + HighModels []ModelCandidate + LowModels []ModelCandidate + FrontierModels []ModelCandidate + MaxAttempts float64 + OnEvent func(AdaptiveRouteEvent) +} + +// normalizePool trims ids, drops blank entries, stamps the tier and numbers +// priorities by position. +func normalizePool(in []ModelCandidate, tier ModelTier) []ModelCandidate { + out := make([]ModelCandidate, 0, len(in)) + for _, c := range in { + id := NormalizeCandidateModel(c.ID) + if id == "" { + continue + } + normalized := c + normalized.ID = id + normalized.Tier = tier + normalized.Priority = float64(len(out)) + normalized.PromptUSDPerMtok = finiteOrZero(c.PromptUSDPerMtok) + normalized.CompletionUSDPerMtok = finiteOrZero(c.CompletionUSDPerMtok) + out = append(out, normalized) + } + return out +} + +func normalizeConfig(cfg AdaptiveRouterConfig) normalizedConfig { + high := cfg.HighModels + if len(high) == 0 { + high = DefaultHighModels() + } + maxAttempts := 3.0 + if cfg.MaxAttempts != nil && *cfg.MaxAttempts > 0 { + maxAttempts = *cfg.MaxAttempts + } + onEvent := cfg.OnEvent + if onEvent == nil { + onEvent = func(AdaptiveRouteEvent) {} + } + return normalizedConfig{ + HighModels: normalizePool(high, ModelTierHigh), + LowModels: normalizePool(cfg.LowModels, ModelTierLow), + FrontierModels: normalizePool(cfg.FrontierModels, ModelTierFrontier), + MaxAttempts: maxAttempts, + OnEvent: onEvent, + } +} + +// ── error classification ───────────────────────────────────────────────── + +// StatusCoder is implemented by errors that carry an HTTP status. +type StatusCoder interface { + ErrorStatusCode() (float64, bool) +} + +// Namer is implemented by errors with a symbolic name; "AbortError" marks a +// timeout regardless of message text. +type Namer interface { + ErrorName() string +} + +// Detailer is implemented by errors that carry extra text the classifiers +// should search, such as a provider's raw error body. +type Detailer interface { + ErrorDetail() string +} + +// errorText is the text the classifiers match against: the error message +// plus any detail an error exposes. +func errorText(err error) string { + if err == nil { + return "" + } + text := err.Error() + var detailer Detailer + if errors.As(err, &detailer) { + if detail := detailer.ErrorDetail(); detail != "" && !strings.Contains(text, detail) { + text += " " + detail + } + } + return text +} + +func statusCodeOf(err error) (float64, bool) { + var coder StatusCoder + if err != nil && errors.As(err, &coder) { + return coder.ErrorStatusCode() + } + return 0, false +} + +func IsLikelyRateLimit(err error) bool { + if code, ok := statusCodeOf(err); ok && code == 429 { + return true + } + text := strings.ToLower(errorText(err)) + return containsAny(text, "429", "rate limit", "rate_limit", "too many requests") +} + +func IsLikelyProviderIncompatible(err error) bool { + text := strings.ToLower(errorText(err)) + return containsAny(text, + "no endpoints found", + "can handle the requested parameters", + "unsupported parameter", + "unsupported parameters", + "does not support tools", + "doesn't support tools", + "does not support structured", + "doesn't support structured", + ) +} + +func IsLikelyTimeout(err error) bool { + var namer Namer + if err != nil && errors.As(err, &namer) && namer.ErrorName() == "AbortError" { + return true + } + text := strings.ToLower(errorText(err)) + return containsAny(text, "timeout", "timed out", "deadline exceeded") +} + +func IsLikelyStructuredFailure(err error) bool { + text := strings.ToLower(errorText(err)) + if containsAny(text, "structured output failed", "response_format", "json_schema", "invalid json") { + return true + } + if strings.Contains(text, "json") && containsAny(text, "unmarshal", "decode", "parse") { + return true + } + if strings.Contains(text, "schema") && containsAny(text, "validation", "parse", "400") { + return true + } + return false +} + +// IsLikelyTransientProviderError covers HTTP 5xx and generic upstream +// provider errors that are neither rate limits nor schema issues. +func IsLikelyTransientProviderError(err error) bool { + if code, ok := statusCodeOf(err); ok && code >= 500 && code <= 599 { + return true + } + text := strings.ToLower(errorText(err)) + return containsAny(text, + "provider returned error", + `error_type":"unmapped"`, + "upstream", + "bad gateway", + "service unavailable", + "gateway timeout", + ) +} + +func IsRetryableRouteError(err error) bool { + return IsLikelyRateLimit(err) || + IsLikelyProviderIncompatible(err) || + IsLikelyTimeout(err) || + IsLikelyStructuredFailure(err) || + IsLikelyTransientProviderError(err) +} + +func containsAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} + +// ── math helpers ───────────────────────────────────────────────────────── + +func updateEwma(prev float64, next float64, alpha float64) float64 { + if prev <= 0 { + return next + } + return alpha*next + (1-alpha)*prev +} + +func updateRewardEwma(prev float64, next float64, count float64, alpha float64) float64 { + if count <= 0 { + return next + } + return alpha*next + (1-alpha)*prev +} + +func rateLimitCooldownSeconds(count float64) float64 { + if count <= 0 { + return 10.0 + } + return 10 * math.Pow(2, math.Min(count, 5)-1) +} + +// immediateFailureReward maps a failure to a quality reward, or reports +// that the failure carries no signal about the model's quality. +func immediateFailureReward(err error) (float64, bool) { + if IsLikelyRateLimit(err) || IsLikelyProviderIncompatible(err) { + return -1.0, true + } + if IsLikelyTimeout(err) { + return -0.95, true + } + if IsLikelyStructuredFailure(err) { + return -0.9, true + } + if IsLikelyTransientProviderError(err) { + return -0.85, true + } + if strings.Contains(strings.ToLower(errorText(err)), "schema") { + return -0.9, true + } + return 0, false +} + +// ── sampling ───────────────────────────────────────────────────────────── + +// makeRng returns the router's random source: a seeded generator when a +// seed is configured, otherwise the package source. +func makeRng(seed *float64) func() float64 { + if seed == nil { + return func() float64 { return randomFloat() } + } + return rand.New(rand.NewSource(int64(*seed))).Float64 +} + +// gauss is a Box-Muller standard normal draw. +func gauss(rng func() float64) float64 { + u, v := 0.0, 0.0 + for u == 0 { + u = rng() + } + for v == 0 { + v = rng() + } + return math.Sqrt(-2.0*math.Log(u)) * math.Cos(2.0*math.Pi*v) +} + +// sampleGamma is Marsaglia & Tsang, recursive for shape < 1. +func sampleGamma(rng func() float64, shape float64) float64 { + if shape <= 0 { + return 0 + } + if shape < 1 { + u := math.Max(rng(), 1e-12) + return sampleGamma(rng, shape+1) * math.Pow(u, 1/shape) + } + d := shape - 1.0/3.0 + c := 1.0 / math.Sqrt(9*d) + for { + x := gauss(rng) + v := 1 + c*x + if v <= 0 { + continue + } + v = v * v * v + u := rng() + if u < 1-0.0331*x*x*x*x { + return d * v + } + if math.Log(u) < 0.5*x*x+d*(1-v+math.Log(v)) { + return d * v + } + } +} + +func sampleBeta(rng func() float64, alpha float64, beta float64) float64 { + x := sampleGamma(rng, alpha) + y := sampleGamma(rng, beta) + if x <= 0 && y <= 0 { + return 0.5 + } + return x / (x + y) +} + +// ── TryPickResult ──────────────────────────────────────────────────────── + +// TryPickResult is the non-blocking pick outcome. Ok==false means every +// candidate in the pool is cooling. +type TryPickResult struct { + Ok bool `json:"ok"` + Choice RouteChoice `json:"choice,omitzero"` + Reason string `json:"reason,omitempty"` + RetryAfterMs float64 `json:"retryAfterMs,omitempty"` +} + +// ── the router ─────────────────────────────────────────────────────────── + +type AdaptiveModelRouter struct { + mu sync.Mutex + cfg normalizedConfig + rng func() float64 + // stats is keyed by statsKey(slot, candidate). + stats map[string]*modelStats + last map[string]string +} + +func NewAdaptiveModelRouter(cfg AdaptiveRouterConfig) *AdaptiveModelRouter { + return &AdaptiveModelRouter{ + cfg: normalizeConfig(cfg), + rng: makeRng(cfg.RandomSeed), + stats: map[string]*modelStats{}, + last: map[string]string{}, + } +} + +// MaxAttempts is the configured attempt budget per call. +func (r *AdaptiveModelRouter) MaxAttempts() float64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.cfg.MaxAttempts +} + +// CandidatesForTier is the pool the tier routes on, after degradation. +func (r *AdaptiveModelRouter) CandidatesForTier(tier ModelTier) []ModelCandidate { + r.mu.Lock() + defer r.mu.Unlock() + return r.candidatesForTier(r.effectiveTierLocked(tier)) +} + +// candidatesForTier takes an already-degraded tier. +func (r *AdaptiveModelRouter) candidatesForTier(tier ModelTier) []ModelCandidate { + switch tier { + case ModelTierLow: + return r.cfg.LowModels + case ModelTierFrontier: + return r.cfg.FrontierModels + default: + return r.cfg.HighModels + } +} + +// EffectiveTier resolves the tier a caller should actually route on: a tier +// whose pool is empty degrades to HIGH, so no run depends on LOW or FRONTIER +// being configured, and an unrecognised tier routes on HIGH. +func (r *AdaptiveModelRouter) EffectiveTier(tier ModelTier) ModelTier { + r.mu.Lock() + defer r.mu.Unlock() + return r.effectiveTierLocked(tier) +} + +func (r *AdaptiveModelRouter) effectiveTierLocked(tier ModelTier) ModelTier { + switch tier { + case ModelTierLow: + if len(r.cfg.LowModels) > 0 { + return ModelTierLow + } + case ModelTierFrontier: + if len(r.cfg.FrontierModels) > 0 { + return ModelTierFrontier + } + } + return ModelTierHigh +} + +// TryPick is the non-blocking pick. Pass at most one nowMs to override the +// clock. +func (r *AdaptiveModelRouter) TryPick(slot string, tier ModelTier, nowMs ...float64) TryPickResult { + r.mu.Lock() + defer r.mu.Unlock() + return r.tryPickLocked(slot, tier, nowMs...) +} + +func (r *AdaptiveModelRouter) tryPickLocked( + slot string, tier ModelTier, nowMsOpt ...float64, +) TryPickResult { + nowMs := nowMillis() + if len(nowMsOpt) > 0 { + nowMs = nowMsOpt[0] + } + // Degrade once, here: the pool, the score weighting and the tier on the + // choice all read the tier actually routed on. + tier = r.effectiveTierLocked(tier) + + candidates := r.candidatesForTier(tier) + if len(candidates) == 0 { + // No candidates configured at all: choose installs the hard-coded + // fallback. Treat as a successful pick. + return TryPickResult{Ok: true, Choice: r.lease(slot, tier, nowMs, nil, false)} + } + + anyAvailable := false + for _, c := range candidates { + if r.statsFor(slot, c).CooldownUntilMs <= nowMs { + anyAvailable = true + break + } + } + if !anyAvailable { + earliestMs := math.Inf(1) + for _, c := range candidates { + st := r.statsFor(slot, c) + if st.CooldownUntilMs > nowMs && st.CooldownUntilMs < earliestMs { + earliestMs = st.CooldownUntilMs + } + } + retryAfterMs := 1000.0 + if !math.IsInf(earliestMs, 0) && !math.IsNaN(earliestMs) { + retryAfterMs = math.Max(100, earliestMs-nowMs) + } + return TryPickResult{ + Ok: false, + Reason: "all-busy", + RetryAfterMs: retryAfterMs, + } + } + + return TryPickResult{Ok: true, Choice: r.lease(slot, tier, nowMs, candidates, false)} +} + +// lease chooses a candidate and takes an in-flight slot on it. +func (r *AdaptiveModelRouter) lease( + slot string, tier ModelTier, nowMs float64, candidates []ModelCandidate, relaxed bool, +) RouteChoice { + choice := r.choose(slot, tier, nowMs, candidates, relaxed) + st := r.statsFor(slot, choice.Candidate) + st.Inflight += 1 + return choice +} + +// PickOptions bounds a blocking Pick. +type PickOptions struct { + TimeoutMs *float64 + Signal *AbortSignal +} + +// PickContext binds waiting for a route to the caller's actual lifetime. +// A cancellation before a lease is returned must not leak an in-flight slot. +func (r *AdaptiveModelRouter) PickContext( + ctx context.Context, slot string, tier ModelTier, +) (RouteChoice, error) { + signal := NewAbortSignal() + stop := context.AfterFunc(ctx, signal.Abort) + defer stop() + if ctx.Err() != nil { + signal.Abort() + } + choice, err := r.Pick(slot, tier, PickOptions{Signal: signal}) + if ctx.Err() != nil { + if err == nil { + r.RegisterCanceled(choice) + } else { + // The effective tier, so every emitted tier means the tier + // routed on and never the one asked for. + r.emitCancellation(AdaptiveRouteEvent{ + Slot: slot, Tier: r.EffectiveTier(tier), + Reason: "caller-canceled-pick", + }) + } + return RouteChoice{}, context.Cause(ctx) + } + return choice, err +} + +// RegisterCanceled releases a caller-canceled lease without attributing a +// success, failure, latency sample or cooldown to the provider. The owner must +// serialize this with Register: exactly one terminal accounting action per pick. +func (r *AdaptiveModelRouter) RegisterCanceled(choice RouteChoice) { + r.mu.Lock() + st := r.statsFor(choice.Slot, choice.Candidate) + if st.Inflight > 0 { + st.Inflight-- + } + event := AdaptiveRouteEvent{ + Slot: choice.Slot, Tier: choice.Tier, Model: choice.Candidate.ID, + Reason: "caller-canceled-request", Score: choice.Score, + Attempts: st.Attempts, Successes: st.Successes, + Failures: st.Failures, RateLimits: st.RateLimits, + LatencyEwma: st.LatencyEwma, ToksecEwma: st.ToksecEwma, + } + r.mu.Unlock() + r.emitCancellation(event) +} + +func (r *AdaptiveModelRouter) emitCancellation(event AdaptiveRouteEvent) { + defer func() { _ = recover() }() + r.cfg.OnEvent(event) +} + +// Pick blocks (polling TryPick with bounded exponential backoff) until a +// candidate is available or `timeoutMs` (default 5 minutes) elapses. +// +// The caller must settle the returned lease exactly once, with Register or +// RegisterCanceled. +func (r *AdaptiveModelRouter) Pick( + slot string, tier ModelTier, opts ...PickOptions, +) (RouteChoice, error) { + var opt PickOptions + if len(opts) > 0 { + opt = opts[0] + } + timeoutMs := 5 * 60 * 1000.0 + if opt.TimeoutMs != nil { + timeoutMs = *opt.TimeoutMs + } + deadline := nowMillis() + timeoutMs + waitMs := 100.0 + maxWaitMs := 2000.0 + + for { + if opt.Signal.Aborted() { + return RouteChoice{}, fmt.Errorf("router.pick aborted for slot=%s tier=%s", slot, tier) + } + r.mu.Lock() + result := r.tryPickLocked(slot, tier) + r.mu.Unlock() + if result.Ok { + return result.Choice, nil + } + now := nowMillis() + if now >= deadline { + return RouteChoice{}, fmt.Errorf( + "router.pick timeout after %sms (%s) — slot=%s tier=%s", + strconv.FormatFloat(timeoutMs, 'f', -1, 64), result.Reason, slot, tier) + } + sleepMs := math.Min(math.Min(waitMs, math.Max(50, deadline-now)), result.RetryAfterMs) + sleepMillis(sleepMs, opt.Signal) + waitMs = math.Min(waitMs*2, maxWaitMs) + } +} + +// PickSync always returns a choice: when every candidate is cooling it takes +// the least bad one from the pool and says so. +func (r *AdaptiveModelRouter) PickSync(slot string, tier ModelTier) RouteChoice { + r.mu.Lock() + defer r.mu.Unlock() + tier = r.effectiveTierLocked(tier) + result := r.tryPickLocked(slot, tier) + if result.Ok { + return result.Choice + } + return r.lease(slot, tier, nowMillis(), r.candidatesForTier(tier), true) +} + +// Register records the outcome of a call started by Pick. Pass err == nil on +// success. +func (r *AdaptiveModelRouter) Register(choice RouteChoice, elapsedSeconds float64, completionTokens float64, err error) AdaptiveRouteEvent { + event, onEvent := r.registerLocked(choice, elapsedSeconds, completionTokens, err) + // The listener runs after the lock is dropped, so one that calls back + // into the router re-enters instead of deadlocking. + func() { + defer func() { _ = recover() }() + onEvent(event) + }() + return event +} + +func (r *AdaptiveModelRouter) registerLocked(choice RouteChoice, elapsedSeconds float64, completionTokens float64, err error) (AdaptiveRouteEvent, func(AdaptiveRouteEvent)) { + r.mu.Lock() + defer r.mu.Unlock() + + st := r.statsFor(choice.Slot, choice.Candidate) + if st.Inflight > 0 { + st.Inflight -= 1 + } + st.Attempts += 1 + if err == nil { + st.Successes += 1 + } else { + st.Failures += 1 + st.LastError = truncateChars(errorText(err), 400) + now := nowMillis() + switch { + case IsLikelyRateLimit(err): + st.RateLimits += 1 + st.CooldownUntilMs = now + rateLimitCooldownSeconds(st.RateLimits)*1000 + case IsLikelyProviderIncompatible(err): + st.CooldownUntilMs = now + 3600*1000 + case IsLikelyTimeout(err): + st.CooldownUntilMs = now + 120*1000 + case IsLikelyTransientProviderError(err): + // Short cooldown: encourage a different model next call, but let + // this one back quickly since 5xx is usually genuinely transient. + st.CooldownUntilMs = now + 60*1000 + } + if reward, ok := immediateFailureReward(err); ok { + st.QualityEwma = updateRewardEwma(st.QualityEwma, reward, st.RewardCount, 0.08) + st.RewardCount += 1 + } + } + st.LatencyEwma = updateEwma(st.LatencyEwma, elapsedSeconds, 0.35) + if completionTokens > 0 && elapsedSeconds > 0 { + st.ToksecEwma = updateEwma(st.ToksecEwma, completionTokens/elapsedSeconds, 0.35) + } + + errText := "" + if err != nil { + errText = truncateChars(errorText(err), 300) + } + event := AdaptiveRouteEvent{ + Slot: choice.Slot, + Tier: choice.Tier, + Model: choice.Candidate.ID, + PreviousModel: choice.PreviousModel, + Switched: choice.Switched, + Reason: choice.Reason, + Score: choice.Score, + ElapsedS: elapsedSeconds, + Attempts: st.Attempts, + Successes: st.Successes, + Failures: st.Failures, + RateLimits: st.RateLimits, + LatencyEwma: st.LatencyEwma, + ToksecEwma: st.ToksecEwma, + Error: errText, + } + return event, r.cfg.OnEvent +} + +// truncateChars keeps the first n characters of s without splitting a +// multi-byte character. +func truncateChars(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + runes := []rune(s) + return string(runes[:n]) +} + +// ── internals ──────────────────────────────────────────────────────────── + +// choose scores and selects from a pre-filtered candidate list. `relaxed` +// prefixes the reason with `constraint-relaxed:` so telemetry can tell a clean +// pick from a relaxed one. +func (r *AdaptiveModelRouter) choose( + slot string, + tier ModelTier, + nowMs float64, + preFilteredCandidates []ModelCandidate, + relaxed bool, +) RouteChoice { + candidates := preFilteredCandidates + if len(candidates) == 0 { + candidates = r.candidatesForTier(tier) + } + if len(candidates) == 0 { + candidates = []ModelCandidate{{ID: "openrouter/openai/gpt-oss-120b", Tier: tier}} + } + previous := r.last[slot] + previousScore := math.Inf(-1) + previousCooling := false + previousInflight := 0.0 + previousRateLimits := 0.0 + best := RouteChoice{ + Slot: slot, + Tier: tier, + Candidate: candidates[0], + Score: math.Inf(-1), + PreviousModel: previous, + } + for _, cand := range candidates { + st := r.statsFor(slot, cand) + if st.CooldownUntilMs > nowMs { + if cand.ID == previous { + previousCooling = true + previousInflight = st.Inflight + previousRateLimits = st.RateLimits + } + continue + } + score := r.score(tier, cand, st) + if cand.ID == previous { + previousScore = score + previousInflight = st.Inflight + previousRateLimits = st.RateLimits + } + if score > best.Score { + best = RouteChoice{ + Slot: slot, Tier: tier, Candidate: cand, + Score: score, PreviousModel: previous, + } + } + } + // Forced fallback: every candidate is cooling. Pick the least bad with a + // -2.0 penalty. + if math.IsInf(best.Score, -1) { + for _, cand := range candidates { + st := r.statsFor(slot, cand) + score := r.score(tier, cand, st) - 2.0 + if score > best.Score { + best = RouteChoice{Slot: slot, Tier: tier, Candidate: cand, Score: score, + PreviousModel: previous, Reason: "forced"} + } + } + } + if math.IsInf(best.Score, -1) { + // Nothing produced a finite score; keep the first candidate rather + // than reporting an unrepresentable score. + best.Score = -2.0 + best.Reason = "forced" + } + best.PreviousModel = previous + best.Switched = previous != "" && previous != best.Candidate.ID + best.Reason = routeReason(best, previousScore, previousCooling, previousInflight, previousRateLimits) + if relaxed { + best.Reason = "constraint-relaxed:" + best.Reason + } + r.last[slot] = best.Candidate.ID + return best +} + +func (r *AdaptiveModelRouter) score( + tier ModelTier, cand ModelCandidate, st *modelStats, +) float64 { + // Cold-model optimism: an unattempted model gets a uniform draw in + // [0.72, 0.90) instead of a Beta posterior. + var reliability float64 + if st.Attempts == 0 { + reliability = 0.72 + r.rng()*0.18 + } else { + reliability = sampleBeta(r.rng, st.Successes+2, st.Failures+1) + } + latency := 15.0 + cand.Priority + if st.LatencyEwma > 0 { + latency = st.LatencyEwma + } + toksec := 35.0 + if st.ToksecEwma > 0 { + toksec = st.ToksecEwma + } + price := cand.PromptUSDPerMtok + cand.CompletionUSDPerMtok + if !(price > 0) { + price = 1.0 + } + cost := 1.0 / (1.0 + price) + speed := toksec / (toksec + 80.0) + latencyPen := latency / (latency + 45.0) + pressurePen := st.Inflight * 0.12 + rlRisk := 0.0 + if st.Attempts > 0 { + rlRisk = st.RateLimits / st.Attempts + } + quality := 0.0 + if st.RewardCount > 0 { + quality = math.Max(-1.0, math.Min(1.0, st.QualityEwma)) + } + var score float64 + if tier == ModelTierLow { + // The low tier buys throughput and price: speed and cost carry more + // than twice the weight they do on the other tiers. + score = 0.38*reliability + 0.3*speed + 0.16*cost + + 0.06*quality - 0.1*latencyPen - pressurePen - 0.45*rlRisk + } else { + // HIGH and FRONTIER: reliability dominates, cost and speed matter + // little. + score = 0.68*reliability + 0.14*speed + 0.08*cost + + 0.08*quality - 0.08*latencyPen - pressurePen - 0.5*rlRisk + } + if math.IsNaN(score) { + return math.Inf(-1) + } + return score +} + +func (r *AdaptiveModelRouter) statsFor(slot string, cand ModelCandidate) *modelStats { + key := statsKey(slot, cand) + st, ok := r.stats[key] + if !ok { + st = &modelStats{} + r.stats[key] = st + } + st.ModelID = cand.ID + st.Slot = slot + st.Tier = cand.Tier + return st +} + +func routeReason( + best RouteChoice, + previousScore float64, + previousCooling bool, + previousInflight float64, + previousRateLimits float64, +) string { + if best.Reason == "forced" { + return "all-cooling" + } + if best.PreviousModel == "" { + return "initial" + } + if best.PreviousModel == best.Candidate.ID { + return "stay" + } + if previousCooling { + return "previous-cooling" + } + if previousRateLimits > 0 { + return "previous-rate-limited" + } + if previousInflight > 0 { + return "previous-busy" + } + if !math.IsInf(previousScore, 0) && !math.IsNaN(previousScore) && best.Score > previousScore { + return "better-score" + } + return "switch" +} + +// ── CLI parsing ────────────────────────────────────────────────────────── + +// ParseModelList parses a comma-separated `--high`, `--low` or `--frontier` +// flag value into the named tier's pool. Each entry is either +// "openrouter/qwen/qwen3.6-plus" or +// "openrouter/qwen/qwen3.6-plus@0.325/1.95" (id + prompt$/completion$ per Mtok). +// Cost defaults to zero, which degenerates the cost term to a neutral 0.5. +func ParseModelList(raw *string, tier ModelTier) []ModelCandidate { + if raw == nil || *raw == "" { + return []ModelCandidate{} + } + var entries []string + for _, s := range strings.Split(*raw, ",") { + if trimmed := strings.TrimSpace(s); trimmed != "" { + entries = append(entries, trimmed) + } + } + out := make([]ModelCandidate, 0, len(entries)) + for i, entry := range entries { + id := entry + prompt := 0.0 + completion := 0.0 + if at := strings.LastIndex(entry, "@"); at > 0 { + id = entry[:at] + if p, c, ok := strings.Cut(entry[at+1:], "/"); ok { + prompt = parsePrice(p) + completion = parsePrice(c) + } + } + out = append(out, ModelCandidate{ + ID: NormalizeCandidateModel(id), + Tier: tier, + PromptUSDPerMtok: prompt, + CompletionUSDPerMtok: completion, + Priority: float64(i), + }) + } + return out +} + +// parsePrice reads a $/Mtok figure; anything unparsable or non-finite is 0. +func parsePrice(s string) float64 { + f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return 0 + } + return finiteOrZero(f) +} + +// finiteOrZero maps a non-finite price to 0 so every candidate stays +// encodable and the cost term degenerates to neutral. +func finiteOrZero(f float64) float64 { + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0 + } + return f +} diff --git a/internal/seniordev/router/adaptive/adaptive_contract_test.go b/internal/seniordev/router/adaptive/adaptive_contract_test.go new file mode 100644 index 000000000..37f533876 --- /dev/null +++ b/internal/seniordev/router/adaptive/adaptive_contract_test.go @@ -0,0 +1,181 @@ +//go:build !windows + +package adaptive + +import ( + "encoding/json" + "errors" + "math" + "strings" + "testing" +) + +type statusErr struct { + msg string + status float64 +} + +func (e statusErr) Error() string { return e.msg } +func (e statusErr) ErrorStatusCode() (float64, bool) { return e.status, true } + +func TestRegisterOutcomesDriveCooldownsAndStats(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/only")}, + RandomSeed: seed(5), + }) + first := router.TryPick("s", ModelTierHigh) + if !first.Ok { + t.Fatal("first pick must succeed") + } + event := router.Register(first.Choice, 2, 100, nil) + if event.Attempts != 1 || event.Successes != 1 || event.Failures != 0 || event.Error != "" { + t.Fatalf("success event = %+v", event) + } + if event.LatencyEwma != 2 || event.ToksecEwma != 50 { + t.Fatalf("ewma after one sample = %+v", event) + } + + second := router.TryPick("s", ModelTierHigh) + event = router.Register(second.Choice, 1, 0, statusErr{msg: "Too Many Requests", status: 429}) + if event.Failures != 1 || event.RateLimits != 1 || !strings.Contains(event.Error, "Too Many Requests") { + t.Fatalf("rate-limit event = %+v", event) + } + // The only candidate is now cooling, so a non-blocking pick reports busy. + third := router.TryPick("s", ModelTierHigh) + if third.Ok || third.Reason != "all-busy" || third.RetryAfterMs <= 0 { + t.Fatalf("pick during cooldown = %+v", third) + } + // PickSync forces a choice anyway and says so. + forced := router.PickSync("s", ModelTierHigh) + if !strings.Contains(forced.Reason, "all-cooling") { + t.Fatalf("forced reason = %q", forced.Reason) + } + router.Register(forced, 1, 1, nil) + if st := router.statsFor("s", forced.Candidate); st.Inflight != 0 { + t.Fatalf("inflight after settling every lease = %v", st.Inflight) + } +} + +func TestClassifiersReadStatusNameAndDetail(t *testing.T) { + if !IsLikelyRateLimit(statusErr{msg: "nope", status: 429}) { + t.Error("a 429 status is a rate limit") + } + if !IsLikelyTransientProviderError(statusErr{msg: "nope", status: 503}) { + t.Error("a 5xx status is transient") + } + if !IsLikelyTimeout(errors.New("SSE read timed out")) { + t.Error("timed out is a timeout") + } + if !IsLikelyProviderIncompatible(errors.New("No endpoints found for this model")) { + t.Error("no endpoints is an incompatibility") + } + if IsRetryableRouteError(errors.New("the model refused")) { + t.Error("an ordinary failure is not retryable") + } + if IsRetryableRouteError(nil) { + t.Error("nil is not an error") + } +} + +func TestBlankCandidateIDsAreDropped(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand(" "), cand("openrouter/qwen/real"), cand("")}, + }) + pool := router.CandidatesForTier(ModelTierHigh) + if len(pool) != 1 || pool[0].ID != "openrouter/qwen/real" || pool[0].Priority != 0 { + t.Fatalf("pool = %+v", pool) + } +} + +func TestNaNPriceStillYieldsAFiniteScore(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{{ID: "openrouter/qwen/nanprice", PromptUSDPerMtok: math.NaN()}}, + RandomSeed: seed(37), + }) + result := router.TryPick("s", ModelTierHigh) + if !result.Ok || math.IsNaN(result.Choice.Score) || math.IsInf(result.Choice.Score, 0) { + t.Fatalf("pick = %+v", result) + } + if _, err := json.Marshal(result); err != nil { + t.Fatalf("a pick result must always be encodable: %v", err) + } +} + +func TestEmptyPoolInstallsTheFallbackCandidate(t *testing.T) { + pinRuntime(t) + // A pool whose every entry is blank normalizes to nothing, which is the + // only way to reach the router with no candidates at all: an unset pool + // takes the defaults instead. + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{HighModels: []ModelCandidate{cand(" ")}}) + result := router.TryPick("coder", ModelTierHigh) + if !result.Ok || result.Choice.Candidate.ID != "openrouter/openai/gpt-oss-120b" { + t.Fatalf("fallback pick = %+v", result) + } +} + +func TestSeededRoutersAreReproducible(t *testing.T) { + pinRuntime(t) + build := func() *AdaptiveModelRouter { + return NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/a"), cand("openrouter/deepseek/b"), cand("openrouter/moonshotai/c")}, + RandomSeed: seed(99), + }) + } + a, b := build(), build() + for i := 0; i < 5; i++ { + ca := a.PickSync("s", ModelTierHigh) + cb := b.PickSync("s", ModelTierHigh) + if ca.Candidate.ID != cb.Candidate.ID || ca.Score != cb.Score { + t.Fatalf("pick %d diverged: %+v vs %+v", i, ca, cb) + } + a.Register(ca, 1, 10, nil) + b.Register(cb, 1, 10, nil) + } +} + +func TestRouteChoiceRoundTripsThroughJSON(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/a@x")}, + }) + choice := router.PickSync("s", ModelTierHigh) + encoded, err := json.Marshal(choice) + if err != nil { + t.Fatal(err) + } + var decoded RouteChoice + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded != choice { + t.Fatalf("round trip changed the choice:\n %+v\n %+v", choice, decoded) + } + busy := TryPickResult{Reason: "all-busy", RetryAfterMs: 100} + encoded, err = json.Marshal(busy) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), `"choice"`) { + t.Fatalf("a busy result must not carry an empty choice: %s", encoded) + } +} + +func TestParseModelListPricesAreOptional(t *testing.T) { + raw := "openrouter/qwen/a@0.325/1.95,openrouter/deepseek/b@bogus/2,openrouter/moonshotai/c" + got := ParseModelList(&raw, ModelTierHigh) + if len(got) != 3 { + t.Fatalf("len = %d", len(got)) + } + if got[0].PromptUSDPerMtok != 0.325 || got[0].CompletionUSDPerMtok != 1.95 { + t.Fatalf("priced entry = %+v", got[0]) + } + if got[1].PromptUSDPerMtok != 0 || got[1].CompletionUSDPerMtok != 2 || got[1].ID != "openrouter/deepseek/b" { + t.Fatalf("partially priced entry = %+v", got[1]) + } + if got[2].Priority != 2 || got[2].ID != "openrouter/moonshotai/c" { + t.Fatalf("unpriced entry = %+v", got[2]) + } +} diff --git a/internal/seniordev/router/adaptive/adaptive_test.go b/internal/seniordev/router/adaptive/adaptive_test.go new file mode 100644 index 000000000..53dcc2eeb --- /dev/null +++ b/internal/seniordev/router/adaptive/adaptive_test.go @@ -0,0 +1,107 @@ +//go:build !windows + +package adaptive + +// These tests install a fake sleeper that advances a fake clock, so no test +// can wall-clock sleep: a regression that made Pick block fails fast instead +// of hanging the suite. + +import ( + "errors" + "testing" +) + +func cand(id string) ModelCandidate { + return ModelCandidate{ + ID: id, + PromptUSDPerMtok: 1, + CompletionUSDPerMtok: 1, + Priority: 0, + } +} + +func seed(v float64) *float64 { return &v } + +// pinRuntime freezes the clock and turns the Pick backoff into a clock advance. +func pinRuntime(t *testing.T) { + t.Helper() + now := 1_700_000_000_000.0 + t.Cleanup(SetClockForTesting(func() float64 { return now })) + t.Cleanup(SetSleeperForTesting(func(ms float64, _ *AbortSignal) { now += ms })) +} + +func mustPick( + t *testing.T, r *AdaptiveModelRouter, slot string, tier ModelTier, + opts ...PickOptions, +) RouteChoice { + t.Helper() + choice, err := r.Pick(slot, tier, opts...) + if err != nil { + t.Fatalf("pick(%s): %v", slot, err) + } + return choice +} + +func TestPickTimeoutAndAbort(t *testing.T) { + t.Run("timeout names the reason and the slot", func(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/qwen-a"), cand("openrouter/deepseek/deepseek-a")}, + RandomSeed: seed(22), + }) + // Cool BOTH models for the slot for an hour. + for i := 0; i < 2; i++ { + r := router.TryPick("coder", ModelTierHigh) + router.Register(r.Choice, 1, 1, errors.New("No endpoints found for this model")) + } + + timeout := 5000.0 + _, err := router.Pick("coder", ModelTierHigh, PickOptions{TimeoutMs: &timeout}) + if err == nil { + t.Fatalf("expected a timeout error") + } + want := "router.pick timeout after 5000ms (all-busy) — slot=coder tier=high" + if err.Error() != want { + t.Errorf("error = %q,\n want %q", err.Error(), want) + } + }) + + t.Run("abort before the first tryPick throws immediately", func(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/qwen-a")}, + RandomSeed: seed(23), + }) + signal := NewAbortSignal() + signal.Abort() + _, err := router.Pick("s", ModelTierHigh, PickOptions{Signal: signal}) + if err == nil || err.Error() != "router.pick aborted for slot=s tier=high" { + t.Errorf("error = %v", err) + } + }) +} + +func TestParseModelListShapes(t *testing.T) { + raw := "openrouter/qwen/a@0.325/1.95, ,openrouter/deepseek/b" + got := ParseModelList(&raw, ModelTierHigh) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].PromptUSDPerMtok != 0.325 || got[0].CompletionUSDPerMtok != 1.95 || got[0].Priority != 0 { + t.Errorf("first = %+v", got[0]) + } + if got[1].ID != "openrouter/deepseek/b" || got[1].Priority != 1 { + t.Errorf("second = %+v", got[1]) + } + if got[1].PromptUSDPerMtok != 0 || got[1].CompletionUSDPerMtok != 0 { + t.Errorf("unpriced entry should default to 0/0, got %+v", got[1]) + } + // An empty string yields no candidates, the same as nil. + empty := "" + if n := len(ParseModelList(&empty, ModelTierHigh)); n != 0 { + t.Errorf("empty string produced %d candidates", n) + } + if n := len(ParseModelList(nil, ModelTierHigh)); n != 0 { + t.Errorf("nil produced %d candidates", n) + } +} diff --git a/internal/seniordev/router/adaptive/cancellation_test.go b/internal/seniordev/router/adaptive/cancellation_test.go new file mode 100644 index 000000000..bb1280143 --- /dev/null +++ b/internal/seniordev/router/adaptive/cancellation_test.go @@ -0,0 +1,53 @@ +//go:build !windows + +package adaptive + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestCanceledRouteReleasesWithoutHealthCredit(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{HighModels: []ModelCandidate{cand("p/model")}}) + choice := mustPick(t, router, "coder", ModelTierHigh) + st := router.statsFor("coder", choice.Candidate) + before := *st + router.RegisterCanceled(choice) + before.Inflight-- + if *st != before { + t.Fatalf("cancellation changed health: before=%+v after=%+v", before, *st) + } + result := router.TryPick("coder", ModelTierHigh) + if !result.Ok { + t.Fatalf("canceled call created cooldown: %+v", result) + } + router.RegisterCanceled(result.Choice) +} + +func TestPickContextStopsDuringRealProviderCooldown(t *testing.T) { + var events []AdaptiveRouteEvent + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("p/model")}, + OnEvent: func(e AdaptiveRouteEvent) { events = append(events, e) }, + }) + choice := mustPick(t, router, "coder", ModelTierHigh) + router.Register(choice, 1, 0, errors.New("SSE read timed out")) + before := *router.statsFor("coder", choice.Candidate) + cause := errors.New("wall-clock budget exhausted") + ctx, cancel := context.WithTimeoutCause(context.Background(), 20*time.Millisecond, cause) + defer cancel() + started := time.Now() + _, err := router.PickContext(ctx, "coder", ModelTierHigh) + if !errors.Is(err, cause) || time.Since(started) > time.Second { + t.Fatalf("pick cancellation err=%v elapsed=%s", err, time.Since(started)) + } + if got := *router.statsFor("coder", choice.Candidate); got != before { + t.Fatalf("canceled wait modified prior provider health: %+v -> %+v", before, got) + } + if len(events) != 2 || events[1].Reason != "caller-canceled-pick" { + t.Fatalf("events=%+v", events) + } +} diff --git a/internal/seniordev/router/adaptive/tier_test.go b/internal/seniordev/router/adaptive/tier_test.go new file mode 100644 index 000000000..3b794c54c --- /dev/null +++ b/internal/seniordev/router/adaptive/tier_test.go @@ -0,0 +1,130 @@ +//go:build !windows + +package adaptive + +import "testing" + +// tierRouter builds a router whose pools are exactly what is passed: the +// helper never falls back to the shipped defaults, so an empty pool here +// really is empty. +func tierRouter(t *testing.T, high, low, frontier []string) *AdaptiveModelRouter { + t.Helper() + pool := func(ids []string) []ModelCandidate { + out := make([]ModelCandidate, 0, len(ids)) + for _, id := range ids { + out = append(out, cand(id)) + } + return out + } + return NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: pool(high), + LowModels: pool(low), + FrontierModels: pool(frontier), + RandomSeed: seed(5), + }) +} + +func TestEmptyTierPoolsResolveToHigh(t *testing.T) { + pinRuntime(t) + router := tierRouter(t, []string{"openrouter/qwen/high-a"}, nil, nil) + for _, tier := range []ModelTier{ModelTierLow, ModelTierFrontier, ModelTierHigh} { + if got := router.EffectiveTier(tier); got != ModelTierHigh { + t.Errorf("EffectiveTier(%q) = %q, want %q", tier, got, ModelTierHigh) + } + pool := router.CandidatesForTier(tier) + if len(pool) != 1 || pool[0].ID != "openrouter/qwen/high-a" { + t.Errorf("CandidatesForTier(%q) = %+v, want the high pool", tier, pool) + } + } + if got := router.EffectiveTier("platinum"); got != ModelTierHigh { + t.Errorf("EffectiveTier(unknown) = %q, want %q", got, ModelTierHigh) + } +} + +func TestAConfiguredTierPoolStandsOnItsOwn(t *testing.T) { + pinRuntime(t) + router := tierRouter(t, + []string{"openrouter/qwen/high-a"}, + []string{"openrouter/qwen/low-a"}, + []string{"openrouter/anthropic/frontier-a"}, + ) + for tier, want := range map[ModelTier]string{ + ModelTierHigh: "openrouter/qwen/high-a", + ModelTierLow: "openrouter/qwen/low-a", + ModelTierFrontier: "openrouter/anthropic/frontier-a", + } { + if got := router.EffectiveTier(tier); got != tier { + t.Errorf("EffectiveTier(%q) = %q, want it unchanged", tier, got) + } + choice := router.PickSync("slot", tier) + if choice.Candidate.ID != want { + t.Errorf("PickSync(%q) = %q, want %q", tier, choice.Candidate.ID, want) + } + if choice.Tier != tier { + t.Errorf("choice tier = %q, want %q", choice.Tier, tier) + } + router.Register(choice, 1, 10, nil) + } +} + +func TestOneModelHighPoolServesEveryTier(t *testing.T) { + pinRuntime(t) + // The trivial configuration: `--high m` and nothing else. + router := tierRouter(t, []string{"openrouter/qwen/only"}, nil, nil) + for _, tier := range []ModelTier{ModelTierHigh, ModelTierLow, ModelTierFrontier} { + choice := router.PickSync("slot", tier) + if choice.Candidate.ID != "openrouter/qwen/only" { + t.Fatalf("PickSync(%q) = %q", tier, choice.Candidate.ID) + } + // The degradation happens before anything reads the tier, so the + // choice and its event report the tier actually routed on. + if choice.Tier != ModelTierHigh { + t.Fatalf("choice tier = %q, want %q", choice.Tier, ModelTierHigh) + } + event := router.Register(choice, 1, 10, nil) + if event.Tier != ModelTierHigh || event.Model != "openrouter/qwen/only" { + t.Fatalf("event = %+v", event) + } + } +} + +func TestDegradedTierIsScoredAsHigh(t *testing.T) { + pinRuntime(t) + // Two models that the two weightings rank differently: the cheap, fast + // one wins on LOW, the expensive, reliable one wins on HIGH. With no low + // pool configured, a LOW request must score exactly as a HIGH request + // does, down to the value. + slow := ModelCandidate{ID: "openrouter/qwen/slow", PromptUSDPerMtok: 9, CompletionUSDPerMtok: 9} + fast := ModelCandidate{ID: "openrouter/qwen/fast", PromptUSDPerMtok: 0.01, CompletionUSDPerMtok: 0.01} + build := func(low []ModelCandidate) *AdaptiveModelRouter { + return NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{slow, fast}, + LowModels: low, + RandomSeed: seed(11), + }) + } + degraded := build(nil).PickSync("slot", ModelTierLow) + asHigh := build(nil).PickSync("slot", ModelTierHigh) + if degraded.Candidate.ID != asHigh.Candidate.ID || degraded.Score != asHigh.Score { + t.Fatalf("degraded low pick %+v differs from the high pick %+v", degraded, asHigh) + } + // With a pool of its own, LOW scores on its own weighting. + configured := build([]ModelCandidate{slow, fast}).PickSync("slot", ModelTierLow) + if configured.Tier != ModelTierLow { + t.Fatalf("choice tier = %q, want %q", configured.Tier, ModelTierLow) + } + if configured.Score == asHigh.Score { + t.Fatalf("the low weighting produced the high score %v", configured.Score) + } +} + +func TestParseModelListStampsTheTier(t *testing.T) { + raw := "openrouter/qwen/a,openrouter/qwen/b" + for _, tier := range []ModelTier{ModelTierHigh, ModelTierLow, ModelTierFrontier} { + for _, candidate := range ParseModelList(&raw, tier) { + if candidate.Tier != tier { + t.Errorf("%q parsed with tier %q, want %q", candidate.ID, candidate.Tier, tier) + } + } + } +} diff --git a/internal/seniordev/router/state/adaptivewire.go b/internal/seniordev/router/state/adaptivewire.go new file mode 100644 index 000000000..e4b737e80 --- /dev/null +++ b/internal/seniordev/router/state/adaptivewire.go @@ -0,0 +1,82 @@ +//go:build !windows + +package state + +// The adaptive router wiring: GetRouter hands back a real +// *adaptive.AdaptiveModelRouter whose event hook is bridged onto +// EmitRouteEvent, so every Register lands on the `[router]` NDJSON line. + +import ( + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// ToRouteEvent converts an adaptive event into the emitted record. +func ToRouteEvent(ev adaptive.AdaptiveRouteEvent) RouteEvent { + return RouteEvent{ + Slot: ev.Slot, + Tier: string(ev.Tier), + Model: ev.Model, + PreviousModel: ev.PreviousModel, + Switched: ev.Switched, + Reason: ev.Reason, + Score: ev.Score, + ElapsedS: ev.ElapsedS, + Attempts: ev.Attempts, + Successes: ev.Successes, + Failures: ev.Failures, + RateLimits: ev.RateLimits, + LatencyEwma: ev.LatencyEwma, + ToksecEwma: ev.ToksecEwma, + Error: ev.Error, + } +} + +// AdaptiveConfig coerces the opaque RouterConfig into adaptive's struct. Both +// a typed struct and a decoded JSON map work; anything else yields the zero +// config, which the router fills with its own defaults. +func AdaptiveConfig(cfg RouterConfig) adaptive.AdaptiveRouterConfig { + switch typed := cfg.(type) { + case adaptive.AdaptiveRouterConfig: + return typed + case *adaptive.AdaptiveRouterConfig: + if typed != nil { + return *typed + } + return adaptive.AdaptiveRouterConfig{} + case nil: + return adaptive.AdaptiveRouterConfig{} + } + encoded, err := json.Marshal(cfg) + if err != nil { + return adaptive.AdaptiveRouterConfig{} + } + var out adaptive.AdaptiveRouterConfig + if err := json.Unmarshal(encoded, &out); err != nil { + return adaptive.AdaptiveRouterConfig{} + } + return out +} + +// NewAdaptiveRouter builds the real router and wires its event hook to +// EmitRouteEvent unless the config already carries one. +func NewAdaptiveRouter(cfg RouterConfig) Router { + resolved := AdaptiveConfig(cfg) + if resolved.OnEvent == nil { + resolved.OnEvent = func(ev adaptive.AdaptiveRouteEvent) { EmitRouteEvent(ToRouteEvent(ev)) } + } + return adaptive.NewAdaptiveModelRouter(resolved) +} + +// AdaptiveRouter narrows GetRouter()'s opaque handle. It returns false when a +// test has installed a different factory through SetRouterFactory, which is the +// only way the singleton can be anything else. +func AdaptiveRouter(r Router) (*adaptive.AdaptiveModelRouter, bool) { + router, ok := r.(*adaptive.AdaptiveModelRouter) + return router, ok +} + +func init() { + newRouter = NewAdaptiveRouter +} diff --git a/internal/seniordev/router/state/adaptivewire_test.go b/internal/seniordev/router/state/adaptivewire_test.go new file mode 100644 index 000000000..c7e67d3e5 --- /dev/null +++ b/internal/seniordev/router/state/adaptivewire_test.go @@ -0,0 +1,141 @@ +//go:build !windows + +package state + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestGetRouterReturnsAnAdaptiveRouter(t *testing.T) { + ResetRouterForTesting() + defer ResetRouterForTesting() + + router, ok := AdaptiveRouter(GetRouter()) + if !ok { + t.Fatalf("GetRouter must hand back a *adaptive.AdaptiveModelRouter, got %T", GetRouter()) + } + // The `{}` fallback yields adaptive's own default pool. + if len(router.CandidatesForTier(adaptive.ModelTierHigh)) == 0 { + t.Error("the default config must still populate the pool") + } +} + +func TestInitRouterDecodesAJSONConfig(t *testing.T) { + ResetRouterForTesting() + defer ResetRouterForTesting() + + built := InitRouter(map[string]any{"max_attempts": 5}) + router, ok := AdaptiveRouter(built) + if !ok { + t.Fatalf("InitRouter must build an adaptive router, got %T", built) + } + if router.MaxAttempts() != 5 { + t.Errorf("max_attempts must survive the JSON round trip, got %v", router.MaxAttempts()) + } +} + +func TestAdaptiveConfigAcceptsATypedStruct(t *testing.T) { + attempts := float64(9) + cfg := adaptive.AdaptiveRouterConfig{MaxAttempts: &attempts} + got := AdaptiveConfig(cfg) + if got.MaxAttempts == nil || *got.MaxAttempts != 9 { + t.Fatalf("a typed config must pass through untouched, got %+v", got.MaxAttempts) + } + if got := AdaptiveConfig(nil); got.MaxAttempts != nil { + t.Error("a nil config must yield the zero config") + } + if got := AdaptiveConfig("not a config"); got.MaxAttempts != nil { + t.Error("an undecodable config must yield the zero config, not panic") + } +} + +// Register invokes the configured OnEvent hook and nothing else emits, so the +// bridge must be installed as cfg.OnEvent and must reach EmitRouteEvent's +// stderr NDJSON line and its listeners. +func TestRegisterBridgesOntoEmitRouteEvent(t *testing.T) { + ResetRouterForTesting() + ResetListenersForTesting() + defer ResetRouterForTesting() + defer ResetListenersForTesting() + + var buf strings.Builder + prevStderr := Stderr + Stderr = &buf + defer func() { Stderr = prevStderr }() + + var seen []RouteEvent + unsubscribe := OnRouteEvent(func(ev RouteEvent) { seen = append(seen, ev) }) + defer unsubscribe() + + router, ok := AdaptiveRouter(GetRouter()) + if !ok { + t.Fatal("expected an adaptive router") + } + choice := router.PickSync("coder", adaptive.ModelTierHigh) + router.Register(choice, 1.5, 42, nil) + + if len(seen) != 1 { + t.Fatalf("register must fan out exactly one RouteEvent, got %d", len(seen)) + } + if seen[0].Slot != "coder" { + t.Errorf("slot: got %q", seen[0].Slot) + } + if seen[0].ElapsedS != float64(1.5) { + t.Errorf("elapsed_s: got %v", seen[0].ElapsedS) + } + line := buf.String() + if !strings.HasPrefix(line, "[router] ") || !strings.HasSuffix(line, "\n") { + t.Fatalf("stderr line must be `[router] \\n`, got %q", line) + } + var decoded RouteEvent + if err := json.Unmarshal([]byte(strings.TrimSuffix(strings.TrimPrefix(line, "[router] "), "\n")), &decoded); err != nil { + t.Fatalf("the NDJSON line must be valid JSON: %v", err) + } +} + +// An explicit OnEvent in the config wins — the bridge only fills a nil hook. +func TestExplicitOnEventIsNotOverwritten(t *testing.T) { + ResetRouterForTesting() + ResetListenersForTesting() + defer ResetRouterForTesting() + defer ResetListenersForTesting() + + var buf strings.Builder + prevStderr := Stderr + Stderr = &buf + defer func() { Stderr = prevStderr }() + + custom := 0 + cfg := adaptive.AdaptiveRouterConfig{OnEvent: func(adaptive.AdaptiveRouteEvent) { custom++ }} + router, ok := AdaptiveRouter(InitRouter(cfg)) + if !ok { + t.Fatal("expected an adaptive router") + } + choice := router.PickSync("coder", adaptive.ModelTierHigh) + router.Register(choice, 1, 1, nil) + + if custom != 1 { + t.Errorf("the caller's own OnEvent must be preserved, fired %d times", custom) + } + if buf.Len() != 0 { + t.Errorf("the bridge must not also emit, wrote %q", buf.String()) + } +} + +// A test may still install the inert factory. +func TestSetRouterFactoryStillOverridesTheDefault(t *testing.T) { + ResetRouterForTesting() + defer ResetRouterForTesting() + + type fake struct{ Router } + restore := SetRouterFactory(func(cfg RouterConfig) Router { return &fake{} }) + defer restore() + + if _, ok := AdaptiveRouter(GetRouter()); ok { + t.Error("an injected factory must win over the adaptive default") + } +} diff --git a/internal/seniordev/router/state/state.go b/internal/seniordev/router/state/state.go new file mode 100644 index 000000000..ce2a2616d --- /dev/null +++ b/internal/seniordev/router/state/state.go @@ -0,0 +1,198 @@ +//go:build !windows + +// Package state holds the process-wide adaptive router singleton and the +// route-event fan-out: every route decision is written to stderr as one +// `[router] ` NDJSON line and delivered to the process-local +// subscribers. A panicking subscriber never breaks telemetry. +package state + +import ( + "io" + "os" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// ── the process singleton ───────────────────────────────────────────────── + +// Router is the opaque router handle the singleton stores; adaptivewire.go +// narrows it to *adaptive.AdaptiveModelRouter. +type Router any + +// RouterConfig is the router configuration, opaque at this boundary. +type RouterConfig any + +// DefaultRouterConfig is the empty config GetRouter falls back to; the router +// fills in its default pools. +var DefaultRouterConfig RouterConfig = map[string]any{} + +// placeholderRouter keeps GetRouter()'s never-nil contract until +// adaptivewire.go's init installs the adaptive constructor. It is inert on +// purpose — anything that tries to route through it should fail loudly at the +// call site rather than silently no-op. +type placeholderRouter struct{ Cfg RouterConfig } + +var newRouter = func(cfg RouterConfig) Router { return &placeholderRouter{Cfg: cfg} } + +// SetRouterFactory replaces the router constructor. Returns a restore func. +func SetRouterFactory(f func(cfg RouterConfig) Router) func() { + routerMu.Lock() + prev := newRouter + newRouter = f + routerMu.Unlock() + return func() { + routerMu.Lock() + newRouter = prev + routerMu.Unlock() + } +} + +var ( + routerMu sync.Mutex + router Router +) + +// InitRouter unconditionally replaces the singleton. +func InitRouter(cfg RouterConfig) Router { + routerMu.Lock() + defer routerMu.Unlock() + router = newRouter(cfg) + return router +} + +// GetRouter returns the singleton, building a default-config one for code +// paths that run before the CLI bootstraps the router (tests, eager imports). +func GetRouter() Router { + routerMu.Lock() + defer routerMu.Unlock() + if router == nil { + router = newRouter(DefaultRouterConfig) + } + return router +} + +// ResetRouterForTesting drops the singleton so the next GetRouter() rebuilds +// it. +func ResetRouterForTesting() { + routerMu.Lock() + router = nil + routerMu.Unlock() +} + +// ── route events ────────────────────────────────────────────────────────── + +// RouteEvent is the route decision record written to the `[router] ` NDJSON +// line and handed to subscribers. +type RouteEvent struct { + Slot string `json:"slot"` + Tier string `json:"tier"` + Model string `json:"model"` + PreviousModel string `json:"previous_model"` + Switched bool `json:"switched"` + Reason string `json:"reason"` + Score float64 `json:"score"` + ElapsedS float64 `json:"elapsed_s"` + Attempts float64 `json:"attempts"` + Successes float64 `json:"successes"` + Failures float64 `json:"failures"` + RateLimits float64 `json:"rate_limits"` + LatencyEwma float64 `json:"latency_ewma"` + ToksecEwma float64 `json:"toksec_ewma"` + Error string `json:"error"` +} + +// RouteEventListener receives every emitted route event. +type RouteEventListener func(event RouteEvent) + +// Stderr receives the NDJSON lines; it is a variable so tests can capture +// them. They go to stderr so they never mix with the event stream on stdout. +var Stderr io.Writer = os.Stderr + +// routerTag prefixes every NDJSON record. +const routerTag = "[router] " + +var stderrMu sync.Mutex + +var ( + listenerMu sync.Mutex + listenerSeq uint64 + listenerKeys []uint64 + listenerFns map[uint64]RouteEventListener +) + +// OnRouteEvent registers a process-local subscriber and returns its +// unsubscribe. +func OnRouteEvent(listener RouteEventListener) func() { + listenerMu.Lock() + if listenerFns == nil { + listenerFns = map[uint64]RouteEventListener{} + } + listenerSeq++ + key := listenerSeq + listenerKeys = append(listenerKeys, key) + listenerFns[key] = listener + listenerMu.Unlock() + return func() { + listenerMu.Lock() + if _, ok := listenerFns[key]; ok { + delete(listenerFns, key) + for i, k := range listenerKeys { + if k == key { + listenerKeys = append(listenerKeys[:i], listenerKeys[i+1:]...) + break + } + } + } + listenerMu.Unlock() + } +} + +// ResetListenersForTesting drops every subscriber. +func ResetListenersForTesting() { + listenerMu.Lock() + listenerKeys = nil + listenerFns = nil + listenerMu.Unlock() +} + +// EmitRouteEvent writes one NDJSON line on stderr, then fans the event out. +// Listener panics are swallowed so they cannot break router telemetry. +func EmitRouteEvent(event RouteEvent) { + encoded, err := jsonutil.Marshal(event) + if err != nil { + // Keep the stream line-oriented even if encoding somehow fails. + encoded = []byte("null") + } + // One Write of the whole record, serialized, so two concurrent emits + // cannot tear a line in half. + line := make([]byte, 0, len(routerTag)+len(encoded)+1) + line = append(line, routerTag...) + line = append(line, encoded...) + line = append(line, '\n') + stderrMu.Lock() + _, _ = Stderr.Write(line) + stderrMu.Unlock() + + listenerMu.Lock() + keys := make([]uint64, len(listenerKeys)) + copy(keys, listenerKeys) + listenerMu.Unlock() + + for _, key := range keys { + listenerMu.Lock() + listener, live := listenerFns[key] + listenerMu.Unlock() + if !live { + // Unsubscribed by an earlier listener in this same emit. + continue + } + callListener(listener, event) + } +} + +// callListener invokes one listener, swallowing any panic. +func callListener(listener RouteEventListener, event RouteEvent) { + defer func() { _ = recover() }() + listener(event) +} diff --git a/internal/seniordev/router/state/state_test.go b/internal/seniordev/router/state/state_test.go new file mode 100644 index 000000000..ecb1ae3f4 --- /dev/null +++ b/internal/seniordev/router/state/state_test.go @@ -0,0 +1,245 @@ +//go:build !windows + +package state + +import ( + "bytes" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// These cover the process singleton and the listener registry. + +func TestGetRouterBuildsADefaultSingleton(t *testing.T) { + ResetRouterForTesting() + t.Cleanup(ResetRouterForTesting) + + first := GetRouter() + if first == nil { + t.Fatalf("GetRouter must never return nil — it is the safety net for code paths that run before the CLI bootstraps the router") + } + if second := GetRouter(); second != first { + t.Errorf("GetRouter must return the same process singleton on every call") + } +} + +func TestInitRouterReplacesTheSingleton(t *testing.T) { + ResetRouterForTesting() + t.Cleanup(ResetRouterForTesting) + + before := GetRouter() + built := InitRouter(map[string]any{"max_attempts": 5}) + if built == before { + t.Fatalf("InitRouter must construct a fresh router, not reuse the default") + } + if got := GetRouter(); got != built { + t.Errorf("GetRouter must hand back what InitRouter stored") + } + // Unconditional: every InitRouter call builds a fresh router. + if again := InitRouter(map[string]any{}); again == built { + t.Errorf("a second InitRouter must replace the singleton again") + } +} + +func TestSetRouterFactoryReplacesTheConstructor(t *testing.T) { + ResetRouterForTesting() + t.Cleanup(ResetRouterForTesting) + + type stub struct{ cfg RouterConfig } + var seen []RouterConfig + restore := SetRouterFactory(func(cfg RouterConfig) Router { + seen = append(seen, cfg) + return &stub{cfg: cfg} + }) + defer restore() + + built := GetRouter() + if _, ok := built.(*stub); !ok { + t.Fatalf("GetRouter must build through the injected factory, got %T", built) + } + if len(seen) != 1 { + t.Fatalf("factory called %d times, want 1", len(seen)) + } + // GetRouter's fallback is the empty config; the router fills in its own + // defaults. + if cfg, ok := seen[0].(map[string]any); !ok || len(cfg) != 0 { + t.Errorf("default config = %#v, want the empty object literal", seen[0]) + } + + InitRouter(map[string]any{"random_seed": 7}) + if len(seen) != 2 { + t.Fatalf("InitRouter must also go through the factory") + } +} + +func TestOnRouteEventReturnsAWorkingUnsubscribe(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + count := 0 + off := OnRouteEvent(func(RouteEvent) { count++ }) + EmitRouteEvent(RouteEvent{Model: "a"}) + off() + EmitRouteEvent(RouteEvent{Model: "b"}) + + if count != 1 { + t.Errorf("listener called %d times, want 1", count) + } + // Telemetry keeps flowing regardless of subscribers. + if got := strings.Count(sink.String(), "[router] "); got != 2 { + t.Errorf("stderr lines = %d, want 2", got) + } +} + +func TestEmitRouteEventLineIsExactlyOneNDJSONRecord(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + event := RouteEvent{ + Slot: "coder", + Model: "openrouter/z-ai/glm-5.1", + PreviousModel: "", + Switched: false, + Reason: "sticky", + Score: float64(0.5), + ElapsedS: float64(1), + Attempts: float64(1), + Successes: float64(1), + Failures: float64(0), + RateLimits: float64(0), + LatencyEwma: float64(1), + ToksecEwma: float64(0), + Error: "", + } + EmitRouteEvent(event) + + line := sink.String() + if !strings.HasPrefix(line, "[router] ") { + t.Fatalf("line %q must start with the [router] tag", line) + } + if !strings.HasSuffix(line, "\n") || strings.Count(line, "\n") != 1 { + t.Fatalf("line %q must be exactly one newline-terminated record", line) + } + // The payload must be the event verbatim — the wire format downstream + // tooling parses. + want, err := jsonutil.Marshal(event) + if err != nil { + t.Fatalf("stringify: %v", err) + } + if got := strings.TrimSuffix(strings.TrimPrefix(line, "[router] "), "\n"); got != string(want) { + t.Errorf("payload = %s, want %s", got, want) + } +} + +func TestPanickingListenerDoesNotBreakTelemetry(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + reached := false + OnRouteEvent(func(RouteEvent) { panic("listener exploded") }) + OnRouteEvent(func(RouteEvent) { reached = true }) + + EmitRouteEvent(RouteEvent{Model: "m"}) + + if !reached { + t.Errorf("a panicking listener must not stop the ones registered after it") + } + if !strings.Contains(sink.String(), "[router] ") { + t.Errorf("the NDJSON line must still be written") + } +} + +// TestListenerRegistrySemantics pins how the listener registry treats +// duplicate registrations and registrations made during an emit. +func TestListenerRegistrySemantics(t *testing.T) { + t.Run("registering the same func twice yields two entries", func(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + count := 0 + listener := func(RouteEvent) { count++ } + OnRouteEvent(listener) + OnRouteEvent(listener) + EmitRouteEvent(RouteEvent{}) + + if count != 2 { + t.Errorf("listener calls = %d, want 2", count) + } + }) + + t.Run("a listener added during an emit is not visited by that emit", func(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + lateCalls := 0 + OnRouteEvent(func(RouteEvent) { + OnRouteEvent(func(RouteEvent) { lateCalls++ }) + }) + EmitRouteEvent(RouteEvent{}) + + if lateCalls != 0 { + t.Errorf("late listener calls = %d, want 0", lateCalls) + } + // It is registered for the next emit, though. + EmitRouteEvent(RouteEvent{}) + if lateCalls == 0 { + t.Errorf("the listener added mid-emit must fire on the following emit") + } + }) +} + +func TestConcurrentEmitIsSafe(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + var mu sync.Mutex + seen := 0 + OnRouteEvent(func(RouteEvent) { + mu.Lock() + seen++ + mu.Unlock() + }) + + var wait sync.WaitGroup + for i := 0; i < 50; i++ { + wait.Add(1) + go func() { + defer wait.Done() + EmitRouteEvent(RouteEvent{Model: "m"}) + }() + } + wait.Wait() + + mu.Lock() + defer mu.Unlock() + if seen != 50 { + t.Errorf("listener calls = %d, want 50", seen) + } +} diff --git a/internal/seniordev/session/compaction/additive_test.go b/internal/seniordev/session/compaction/additive_test.go new file mode 100644 index 000000000..88946e01e --- /dev/null +++ b/internal/seniordev/session/compaction/additive_test.go @@ -0,0 +1,376 @@ +//go:build !windows + +package compaction + +import ( + "context" + "strings" + "testing" + + "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/session/overflow" +) + +// scriptedProcessor drives the fake summary processor with one outcome. +type scriptedProcessor struct { + text string // written as the summary text when non-empty + result steploop.Result + failWith string // sets an assistant error when non-empty + calls int + requests []SummaryRequest +} + +func (sc *scriptedProcessor) factory(store *memoryStore) ProcessorFactory { + return ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, request SummaryRequest) (steploop.Result, error) { + sc.calls++ + sc.requests = append(sc.requests, request) + if sc.failWith != "" { + converted := msgmodel.NewUnknownError(sc.failWith) + assistant.Error = &converted + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultStop, nil + } + finish := "stop" + assistant.Finish = &finish + assistant.Tokens = msgmodel.Tokens{Input: 1234, Output: 56} + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + if sc.text != "" { + if err := store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: "sp_" + assistant.ID, SessionID: "ses_1", MessageID: assistant.ID, + }, + Text: sc.text, + }); err != nil { + return steploop.ResultStop, err + } + } + result := sc.result + if result == "" { + result = steploop.ResultContinue + } + return result, nil + }, + }, nil + }) +} + +func additiveDeps(store *memoryStore, script *scriptedProcessor) (Dependencies, *[]CompactionDecision) { + deps := baseDeps(store) + deps.Provider = &fakeProvider{model: serviceModel(), provider: ProviderInfo{Source: "env"}} + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + decisions := &[]CompactionDecision{} + deps.Decisions = DecisionSinkFunc(func(d CompactionDecision) { + *decisions = append(*decisions, d) + }) + deps.Processors = script.factory(store) + deps.ChangedFiles = func(context.Context) []string { + return []string{" src/a.ts | 4 ++--"} + } + return deps, decisions +} + +// sessionWithPriorBoundary is a session that already compacted once: the +// goal, a completed boundary (uc0 + its valid summary), the tail of that +// boundary, more work, and the new compaction parent uc. +func sessionWithPriorBoundary() []msgmodel.WithParts { + finish := "stop" + flag := true + previous := testAssistant("as0", "uc0", textPart("as0", testValidSummary("Implemented parse() in src/a.ts"))) + info := previous.Info.(msgmodel.Assistant) + info.Summary = &flag + info.Finish = &finish + previous.Info = info + big := strings.Repeat("test output line\n", 600) + return []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), + testUser("uc0", msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc0", SessionID: "ses_1", MessageID: "uc0"}, + Auto: true, + }), + previous, + // Long enough (~1,700 tokens) that a 1,500-token tail budget cannot + // hold it once the truncated a2 is in. + testAssistant("a1", "u0", textPart("a1", strings.Repeat("Continuing with the tests. ", 250))), + testAssistant("a2", "u0", toolPartCompleted("a2", "c2", "bash", `{"cmd":"npm test"}`, big)), + testAssistant("a3", "u0", textPart("a3", "Two tests still fail.")), + testUser("uc", msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + }), + } +} + +func selectableBoundaries(t *testing.T, store *memoryStore, parent string) int { + t.Helper() + msgs, _ := store.Messages(context.Background(), "ses_1") + count := 0 + for _, m := range msgs { + a, ok := m.Info.(msgmodel.Assistant) + if !ok || !boolPointer(a.Summary) || a.ParentID != parent { + continue + } + if a.Finish != nil && *a.Finish != "" && a.Error == nil { + count++ + } + } + return count +} + +func TestRejectedSummaryInstallsDeterministicRecordCarryingThePreviousSummary(t *testing.T) { + messages := sessionWithPriorBoundary() + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{text: "Let me keep going and read src/b.ts next."} + deps, decisions := additiveDeps(store, script) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if script.calls != 1 { + t.Fatalf("summary calls = %d, want exactly 1 (no retries)", script.calls) + } + d := (*decisions)[0] + if d.SummaryStatus != "fallback" || d.SummaryClass != SummaryClassFormat || + !d.PreviousSummaryCarried || d.SummaryPromptTokens != 1234 || d.SummaryOutputTokens != 56 { + t.Fatalf("decision = %#v", d) + } + if selectableBoundaries(t, store, "uc") != 1 { + t.Fatal("the rejected attempt did not become the boundary") + } + fresh, _ := store.Messages(context.Background(), "ses_1") + prior := completedCompactions(fresh) + record := *prior[len(prior)-1].Summary + if !stringsContainsAll(record, + "carried forward verbatim", + "> - Implemented parse() in src/a.ts", // the previous summary, quoted as data + "> \\### Completed", // its headings escaped + "See the CHANGED FILES record", + ) { + t.Fatalf("deterministic record:\n%s", record) + } + if strings.Contains(record, "read src/b.ts next") { + t.Fatalf("the rejected continuation leaked into the record:\n%s", record) + } + // The prompt sent was the flattened head only: the newest message is the + // verbatim tail and must not have been summarized. + prompt := promptOf(t, script.requests[0]) + if !strings.Contains(prompt, "Continuing with the tests.") || + strings.Contains(prompt, "Two tests still fail.") { + t.Fatalf("head/tail split is wrong in prompt:\n%s", prompt) + } + if !strings.Contains(prompt, "") { + t.Fatalf("previous summary was not offered to the summarizer:\n%s", prompt) + } +} + +func TestTailIsKeptAndOlderToolOutputsAreTruncatedInTheStore(t *testing.T) { + messages := sessionWithPriorBoundary() + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{text: testValidSummary("tests running")} + deps, decisions := additiveDeps(store, script) + // Enough budget for the truncated a2 but not for an untruncated one: + // a2's output is ~10,200 chars (~2,550 tokens); truncated it is ~4,100. + budget := float64(1_500) + deps.Config = ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{ + PreserveRecentTokens: &budget, + }}, nil + }) + + if _, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }); err != nil { + t.Fatal(err) + } + d := (*decisions)[0] + if d.SummaryStatus != "valid" || d.TailMessages != 2 || d.TailTruncatedOutputs != 1 { + t.Fatalf("decision = %#v", d) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + var tailStart *string + var truncatedOutput string + for _, m := range fresh { + if m.Info.MessageID() == "uc" { + tailStart = m.Parts[0].(msgmodel.CompactionPart).TailStartID + } + if m.Info.MessageID() == "a2" { + truncatedOutput = m.Parts[0].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted).Output + } + } + if tailStart == nil || *tailStart != "a2" { + t.Fatalf("tail start = %v, want a2", tailStart) + } + if !strings.Contains(truncatedOutput, "truncated at a context compaction") || + len(truncatedOutput) > 4_400 { + t.Fatalf("old tool output was not truncated in the store: %d chars", len(truncatedOutput)) + } + // And the projection places the tail after the summary: compaction user, + // summary, then a2 and a3 verbatim, then the auto-continue user. + projected := msgmodel.FilterCompacted(newestFirstMessages(fresh)) + order := []string{} + for _, m := range projected { + order = append(order, m.Info.MessageID()) + } + summaryIndex := indexOfPrefix(order, "message_") + if indexOf(order, "uc") != 0 || summaryIndex != 1 || + indexOf(order, "a2") != 2 || indexOf(order, "a3") != 3 || len(order) != 5 { + t.Fatalf("projection order = %v", order) + } +} + +func TestSummaryCallErrorInstallsDeterministicRecordAndContinues(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{failWith: "unexpected EOF"} + deps, decisions := additiveDeps(store, script) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + d := (*decisions)[0] + if d.SummaryStatus != "summary-error" || !strings.Contains(d.SummaryError, "unexpected EOF") { + t.Fatalf("decision = %#v", d) + } + if selectableBoundaries(t, store, "uc") != 1 { + t.Fatal("an errored summary call left no usable boundary") + } + fresh, _ := store.Messages(context.Background(), "ses_1") + if prior := completedCompactions(fresh); len(prior) != 1 || + !strings.Contains(*prior[0].Summary, "unexpected EOF") { + t.Fatalf("record does not name the cause: %#v", prior) + } +} + +func TestNothingToSummarizeSkipsTheModelCall(t *testing.T) { + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), + testUser("uc", msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + }), + } + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{text: testValidSummary("never used")} + deps, decisions := additiveDeps(store, script) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if script.calls != 0 { + t.Fatalf("summary calls = %d, want 0", script.calls) + } + if d := (*decisions)[0]; d.SummaryStatus != "no-head" || d.TranscriptMessages != 0 || d.TailMessages != 1 { + t.Fatalf("decision = %#v", d) + } + if selectableBoundaries(t, store, "uc") != 1 { + t.Fatal("no boundary was installed") + } + // The whole history is the tail, and the projection must still carry it: + // compaction user, record, then u0 verbatim, then the auto-continue. + fresh, _ := store.Messages(context.Background(), "ses_1") + tailStart := fresh[1].Parts[0].(msgmodel.CompactionPart).TailStartID + if tailStart == nil || *tailStart != "u0" { + t.Fatalf("no-head boundary did not name the tail: %v", tailStart) + } + order := []string{} + for _, m := range msgmodel.FilterCompacted(newestFirstMessages(fresh)) { + order = append(order, m.Info.MessageID()) + } + if len(order) != 4 || order[0] != "uc" || order[2] != "u0" { + t.Fatalf("projection after a no-head boundary = %v", order) + } +} + +func TestChangedFilesPinIsInstalledBesideTheSummary(t *testing.T) { + for name, script := range map[string]*scriptedProcessor{ + "valid": {text: testValidSummary("x")}, + "rejected": {text: "not a record"}, + } { + t.Run(name, func(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + deps, _ := additiveDeps(store, script) + if _, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: false, + }); err != nil { + t.Fatal(err) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + prior := completedCompactions(fresh) + if len(prior) != 1 { + t.Fatalf("completed compactions = %#v", prior) + } + pinned := false + for _, raw := range fresh[prior[0].AssistantIndex].Parts { + part, ok := raw.(msgmodel.TextPart) + if ok && strings.Contains(string(part.Metadata), `"changed_files"`) && + strings.Contains(part.Text, "src/a.ts | 4 ++--") && boolPointer(part.Synthetic) { + pinned = true + } + } + if !pinned { + t.Fatal("changed-files pin missing") + } + }) + } +} + +func TestFallbackRecordAlwaysValidates(t *testing.T) { + previous := "## Working State\n### Completed\n- did things\n### Current\n- x\n### Verification\n- y\n### Next\n- z\n### Files\n- f" + for name, record := range map[string]string{ + "with previous": fallbackRecord(&previous, "task", errString("boom"), true), + "bare": fallbackRecord(nil, "", nil, false), + } { + if err := ValidateSummaryText(record); err != nil { + t.Fatalf("%s: %v\n%s", name, err, record) + } + } +} + +type errString string + +func (e errString) Error() string { return string(e) } + +func newestFirstMessages(messages []msgmodel.WithParts) []msgmodel.WithParts { + out := make([]msgmodel.WithParts, len(messages)) + for i := range messages { + out[len(messages)-1-i] = messages[i] + } + return out +} + +func indexOf(values []string, want string) int { + for i, value := range values { + if value == want { + return i + } + } + return -1 +} + +func indexOfPrefix(values []string, prefix string) int { + for i, value := range values { + if strings.HasPrefix(value, prefix) { + return i + } + } + return -1 +} diff --git a/internal/seniordev/session/compaction/controller.go b/internal/seniordev/session/compaction/controller.go new file mode 100644 index 000000000..490670e82 --- /dev/null +++ b/internal/seniordev/session/compaction/controller.go @@ -0,0 +1,70 @@ +//go:build !windows + +// Controller adapts the compaction service to the step-loop task seam. +package compaction + +import ( + "context" + "errors" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +type Controller struct { + Compaction *Service +} + +func (c Controller) ProcessCompaction( + ctx context.Context, + input steploop.TaskInput, + task msgmodel.CompactionPart, +) (steploop.Result, error) { + if c.Compaction == nil { + return steploop.ResultStop, errors.New("compaction: nil service") + } + return c.Compaction.Process(ctx, ProcessInput{ + ParentID: task.MessageID, Messages: input.Messages, + SessionID: input.SessionID, Auto: task.Auto, Overflow: task.Overflow, + }) +} + +func (c Controller) IsOverflow( + ctx context.Context, + assistant msgmodel.Assistant, + model steploop.Model, +) (bool, error) { + if c.Compaction == nil { + return false, errors.New("compaction: nil service") + } + return c.Compaction.IsOverflow( + ctx, assistant.Tokens, Model{Message: model.Message, Overflow: model.Calc}, + ) +} + +func (c Controller) CreateCompaction( + ctx context.Context, + sessionID string, + user msgmodel.User, + overflowed bool, +) error { + if c.Compaction == nil { + return errors.New("compaction: nil service") + } + return c.Compaction.Create(ctx, CreateInput{ + SessionID: sessionID, Agent: user.Agent, + Model: ModelRef{ + ProviderID: user.Model.ProviderID, ModelID: user.Model.ModelID, + }, + Auto: true, Overflow: &overflowed, + }) +} + +func (c Controller) Prune(ctx context.Context, sessionID string) error { + if c.Compaction == nil { + return errors.New("compaction: nil service") + } + return c.Compaction.Prune(ctx, sessionID) +} + +var _ steploop.TaskController = Controller{} diff --git a/internal/seniordev/session/compaction/controller_test.go b/internal/seniordev/session/compaction/controller_test.go new file mode 100644 index 000000000..d5d2f5501 --- /dev/null +++ b/internal/seniordev/session/compaction/controller_test.go @@ -0,0 +1,54 @@ +//go:build !windows + +package compaction + +import ( + "context" + "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/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +// The controller hands the assistant's token count and the session's config +// to the overflow arithmetic; the trigger is the high watermark of the +// capacity, and nothing else fires it. +func TestControllerIsOverflowTriggersAtTheHighWatermark(t *testing.T) { + capacity := 100_000.0 + service := NewService(Dependencies{ + Config: ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{ + CapacityTokens: &capacity, + }}, nil + }), + }) + controller := Controller{Compaction: service} + model := steploop.Model{Calc: calc.Model{ + Limit: calc.ModelLimit{Context: 200_000, Output: 32_768}, + }} + + tests := []struct { + name string + tokens uint64 + want bool + }{ + {name: "below the high watermark", tokens: 59_999}, + {name: "at the high watermark", tokens: 60_000, want: true}, + {name: "far above", tokens: 150_000, want: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := controller.IsOverflow(context.Background(), msgmodel.Assistant{ + Tokens: msgmodel.Tokens{Input: test.tokens, Cache: msgmodel.TokenCache{}}, + }, model) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("IsOverflow() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/internal/seniordev/session/compaction/core.go b/internal/seniordev/session/compaction/core.go new file mode 100644 index 000000000..94c42e597 --- /dev/null +++ b/internal/seniordev/session/compaction/core.go @@ -0,0 +1,424 @@ +//go:build !windows + +// Package compaction summarizes a session's older history into a state record +// when the context window fills, keeping the newest messages verbatim. +package compaction + +import ( + "fmt" + "math" + "strconv" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +const ( + PruneMinimum = 20_000 + PruneProtect = 40_000 + // ToolOutputMaxChars caps each tool output inside the flattened transcript + // handed to the summarizer. + ToolOutputMaxChars = 2_000 + // SummaryTranscriptMaxChars bounds the whole flattened transcript. A head + // that outgrows it is cut in the middle (a quarter from the start, the rest + // from the end) so the summarizer sees how the work began and, mostly, its + // latest state. ~60K tokens, well inside any current model's window. + SummaryTranscriptMaxChars = 240_000 + EvidenceToolOutputMaxChars = 2_000 + EventCompacted = "session.compacted" +) + +var PruneProtectedTools = []string{"skill"} + +const SummarySystemPrompt = `You are a context serializer. The text inside tags is a +finished transcript given to you as DATA, not a conversation to continue. +Do NOT continue the task, call tools, or emit tool-call markup as text. Do NOT +write code or address the user. Read the transcript and return ONLY the +Markdown state record in the exact format requested after it.` + +// The task itself is pinned verbatim beside every summary, so the record does +// not repeat it. The contract is deliberately small: current work, exact +// verification evidence, and the next concrete action. +const SummaryTemplate = `Return exactly this Markdown structure, with terse bullets and no text outside it: +## Working State +### Completed +- completed work, or (none) +### Current +- work in progress, blockers, and decisions, or (none) +### Verification +- exact commands, exit codes, and error strings, or (none) +### Next +- the next concrete action, or (none) +### Files +- relevant file paths and why, or (none) + +Preserve exact paths, commands, errors, and identifiers. Do not restate the task; it is pinned verbatim beside this record.` + +var summaryHeadings = []string{ + "## Working State", + "### Completed", + "### Current", + "### Verification", + "### Next", + "### Files", +} + +type Turn struct { + Start int `json:"start"` + End int `json:"end"` + ID string `json:"id"` +} + +type CompletedCompaction struct { + UserIndex int + AssistantIndex int + Summary *string +} + +func summaryText(message msgmodel.WithParts) *string { + parts := []string{} + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) { + continue + } + text := strings.TrimSpace(part.Text) + if text != "" { + parts = append(parts, text) + } + } + text := strings.TrimSpace(strings.Join(parts, "\n\n")) + if text == "" { + return nil + } + return &text +} + +func generatedSummaryText(message msgmodel.WithParts) *string { + parts := []string{} + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) || boolPointer(part.Synthetic) { + continue + } + text := strings.TrimSpace(part.Text) + if text != "" { + parts = append(parts, text) + } + } + text := strings.TrimSpace(strings.Join(parts, "\n\n")) + if text == "" { + return nil + } + return &text +} + +// Summary failure classes, emitted on the compaction decision event so every +// rejected summary can be attributed from the event stream. +const ( + SummaryClassToolCall = "tool-call" + SummaryClassDSMLText = "dsml-text" + SummaryClassEmpty = "empty" + SummaryClassFormat = "contract-format" +) + +// ClassifySummaryFailure labels a rejected summary attempt. tool-call is a +// structural ToolPart; dsml-text is provider tool-call markup leaked as TEXT; +// empty is no usable text; contract-format is any other heading/structure +// violation. +func ClassifySummaryFailure(message msgmodel.WithParts) string { + for _, part := range message.Parts { + if _, ok := part.(msgmodel.ToolPart); ok { + return SummaryClassToolCall + } + } + text := generatedSummaryText(message) + if text == nil { + return SummaryClassEmpty + } + if containsToolMarkup(*text) { + return SummaryClassDSMLText + } + return SummaryClassFormat +} + +// containsToolMarkup detects tool-call markup emitted as plain text — DeepSeek +// DSML (the "|" full-width bar and the DSML token) plus the common +// XML-ish tool-call shapes other providers leak. +func containsToolMarkup(text string) bool { + if strings.Contains(text, "|") || strings.Contains(text, "DSML") { + return true + } + lower := strings.ToLower(text) + for _, marker := range []string{ + "", + *previousSummary, + "", + }, "\n") + } + parts := []string{anchor, SummaryTemplate} + parts = append(parts, context...) + return strings.Join(parts, "\n\n") +} + +// tailBudget is the token budget for the truncated older messages of the +// verbatim tail. An explicit preserve_recent_tokens always wins. Otherwise +// the tail is preserve_recent_fraction (default DefaultPreserveRecentFraction) +// of the high watermark, so a 300K trigger keeps a 60K tail rather than a +// fixed one. +func TailBudget(cfg overflow.Config, marks overflow.CompactionWatermarks) float64 { + return tailBudget(cfg, marks) +} + +func tailBudget(cfg overflow.Config, marks overflow.CompactionWatermarks) float64 { + fraction := DefaultPreserveRecentFraction + if cfg.Compaction != nil { + if cfg.Compaction.PreserveRecentTokens != nil { + return *cfg.Compaction.PreserveRecentTokens + } + if f := cfg.Compaction.PreserveRecentFraction; f != nil && *f > 0 && *f < 1 { + fraction = *f + } + } + return math.Floor(marks.High * fraction) +} + +func IsSyntheticUser(message msgmodel.WithParts) bool { + if _, ok := message.Info.(msgmodel.User); !ok { + return false + } + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || part.Synthetic == nil || !*part.Synthetic { + return false + } + } + return true +} + +func Turns(messages []msgmodel.WithParts) []Turn { + result := []Turn{} + for i, message := range messages { + user, ok := message.Info.(msgmodel.User) + if !ok || hasCompaction(message.Parts) || IsSyntheticUser(message) { + continue + } + result = append(result, Turn{ + Start: i, End: len(messages), ID: user.ID, + }) + } + for i := 0; i < len(result)-1; i++ { + result[i].End = result[i+1].Start + } + return result +} + +func HeadTailTruncate(text string, maxChars float64) string { + length := charCount(text) + if maxChars <= 0 || float64(length) <= maxChars { + return text + } + headChars := int(maxChars / 4) + tailChars := int(maxChars) - headChars + head := sliceChars(text, 0, headChars) + tail := sliceChars(text, length-tailChars, length) + omitted := length - headChars - tailChars + return head + "\n[Tool output truncated for evidence: omitted " + + strconv.Itoa(omitted) + " chars]\n" + tail +} + +func EvidenceBlocksFromMessages( + messages []msgmodel.WithParts, maxToolChars ...float64, +) []string { + maxChars := float64(EvidenceToolOutputMaxChars) + if len(maxToolChars) > 0 { + maxChars = maxToolChars[0] + } + blocks := []string{} + for _, message := range messages { + parts := []string{} + for _, raw := range message.Parts { + switch part := raw.(type) { + case msgmodel.TextPart: + if strings.TrimSpace(part.Text) != "" { + parts = append(parts, part.Text) + } + case msgmodel.ToolPart: + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if ok && completed.Output != "" && strings.TrimSpace(completed.Output) != "" { + parts = append(parts, HeadTailTruncate(completed.Output, maxChars)) + } + } + } + text := strings.Join(parts, "\n") + if strings.TrimSpace(text) != "" { + blocks = append(blocks, text) + } + } + return blocks +} + +type Model struct { + Message msgmodel.Model + Overflow overflow.Model +} + +type EstimateFunc func(messages []msgmodel.WithParts, model Model) (float64, error) + +// estimateTokens is the rough four-characters-per-token estimate used when a +// message has no recorded usage. +func estimateTokens(input string) float64 { + return math.Round(float64(charCount(input)) / 4) +} + +func hasCompaction(parts msgmodel.Parts) bool { + for _, part := range parts { + if _, ok := part.(msgmodel.CompactionPart); ok { + return true + } + } + return false +} + +func boolPointer(value *bool) bool { return value != nil && *value } + +// charCount is the length of value in characters (runes), the unit every +// character budget in this package is expressed in. +func charCount(value string) int { return utf8.RuneCountInString(value) } + +// sliceChars returns the characters of value in [start, end), clamped to the +// string, so a cut never splits a multi-byte character. +func sliceChars(value string, start, end int) string { + runes := []rune(value) + start = max(0, min(start, len(runes))) + end = max(start, min(end, len(runes))) + return string(runes[start:end]) +} diff --git a/internal/seniordev/session/compaction/core_test.go b/internal/seniordev/session/compaction/core_test.go new file mode 100644 index 000000000..70aca0e53 --- /dev/null +++ b/internal/seniordev/session/compaction/core_test.go @@ -0,0 +1,278 @@ +//go:build !windows + +package compaction + +import ( + "fmt" + "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/session/overflow" +) + +func testUser(id string, parts ...msgmodel.Part) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.TimeCreated{Created: 1}, + Agent: "build", + Model: msgmodel.UserModel{ProviderID: "openrouter", ModelID: "m"}, + }, + Parts: parts, + } +} + +func testAssistant(id, parent string, parts ...msgmodel.Part) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.AssistantTime{Created: 2}, + ParentID: parent, ModelID: "m", ProviderID: "openrouter", + Mode: "build", Agent: "build", + Path: msgmodel.AssistantPath{Cwd: "/work", Root: "/work"}, + Tokens: msgmodel.Tokens{Cache: msgmodel.TokenCache{}}, + }, + Parts: parts, + } +} + +func textPart(messageID, text string) msgmodel.TextPart { + return msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: "p_" + messageID, SessionID: "ses_1", MessageID: messageID, + }, + Text: text, + } +} + +func testValidSummary(goal string) string { + return strings.Join([]string{ + "## Working State", + "### Completed", "- " + goal, + "### Current", "- inspect the implementation", + "### Verification", "- (none)", + "### Next", "- continue", + "### Files", "- (none)", + }, "\n") +} + +func testOverflowModel(context, output float64) overflow.Model { + return overflow.Model{ + Limit: calc.ModelLimit{Context: context, Output: output}, + } +} + +func TestTailBudgetDefaultsAndExplicitValueWins(t *testing.T) { + marks := overflow.CompactionWatermarks{Capacity: 500_000, High: 300_000, Low: 200_000} + if got := tailBudget(overflow.Config{}, marks); got != 60_000 { + t.Fatalf("default budget = %v, want 60000 (0.2 of high)", got) + } + explicit := float64(0) + got := tailBudget(overflow.Config{Compaction: &overflow.CompactionConfig{ + PreserveRecentTokens: &explicit, + }}, marks) + if got != 0 { + t.Fatalf("explicit zero budget = %v", got) + } + _ = testOverflowModel +} + +func TestTailBudgetIsAFractionOfHigh(t *testing.T) { + marks := overflow.CompactionWatermarks{Capacity: 500_000, High: 300_000, Low: 200_000} + window := func(mutate func(*overflow.CompactionConfig)) overflow.Config { + c := &overflow.CompactionConfig{Policy: overflow.PolicyWindow} + if mutate != nil { + mutate(c) + } + return overflow.Config{Compaction: c} + } + if got := tailBudget(window(nil), marks); got != 60_000 { + t.Fatalf("default fraction budget = %v, want 60000 (0.2 of high)", got) + } + tenth := 0.1 + if got := tailBudget(window(func(c *overflow.CompactionConfig) { c.PreserveRecentFraction = &tenth }), marks); got != 30_000 { + t.Fatalf("explicit fraction budget = %v, want 30000", got) + } + // Out-of-range fractions fall back to the default rather than producing + // an empty or whole-context tail. + for _, bad := range []float64{0, 1, 1.5, -0.2} { + f := bad + if got := tailBudget(window(func(c *overflow.CompactionConfig) { c.PreserveRecentFraction = &f }), marks); got != 60_000 { + t.Fatalf("fraction %v budget = %v, want the 60000 default", bad, got) + } + } + // An explicit token budget still wins over the fraction. + tokens := 45_000.0 + if got := tailBudget(window(func(c *overflow.CompactionConfig) { + c.PreserveRecentTokens = &tokens + c.PreserveRecentFraction = &tenth + }), marks); got != 45_000 { + t.Fatalf("tokens should win over fraction: %v", got) + } +} + +func TestEstimateTokensCountsCharacters(t *testing.T) { + cases := map[string]float64{ + "": 0, "a": 0, "ab": 1, "abcde": 1, + "abcdef": 2, "😀": 0, "😀a": 1, "😀😀": 1, + } + for input, want := range cases { + if got := estimateTokens(input); got != want { + t.Errorf("estimateTokens(%q) = %v, want %v", input, got, want) + } + } +} + +func TestCompletedCompactionsRequireSuccessfulFinishedSummary(t *testing.T) { + finish := "stop" + summary := true + compaction := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + } + user := testUser("uc", compaction) + valid := testValidSummary("Fix src/a.ts") + ok := testAssistant("ac", "uc", textPart("ac", " "+valid+" ")) + assistant := ok.Info.(msgmodel.Assistant) + assistant.Summary = &summary + assistant.Finish = &finish + ok.Info = assistant + failed := ok + failedAssistant := failed.Info.(msgmodel.Assistant) + failedAssistant.ID = "af" + converted := msgmodel.NewUnknownError("boom") + failedAssistant.Error = &converted + failed.Info = failedAssistant + got := completedCompactions([]msgmodel.WithParts{user, ok, failed}) + if len(got) != 1 || got[0].Summary == nil || *got[0].Summary != valid { + t.Fatalf("completed compactions = %#v", got) + } +} + +func TestValidateSummaryRejectsEmptyMalformedAndToolShapedOutput(t *testing.T) { + validText := testValidSummary("Fix src/a.ts") + valid := testAssistant("valid", "uc", textPart("valid", validText)) + if err := ValidateSummary(valid); err != nil { + t.Fatalf("valid summary rejected: %v", err) + } + + cases := map[string]msgmodel.WithParts{ + "empty": testAssistant("empty", "uc"), + "malformed": testAssistant("malformed", "uc", textPart("malformed", "I will inspect the code next.")), + "missing section": testAssistant( + "partial", "uc", textPart("partial", "## Goal\n- Fix src/a.ts"), + ), + "tool shaped": testAssistant( + "tool", "uc", + textPart("tool", validText), + msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ + ID: "p_tool", SessionID: "ses_1", MessageID: "tool", + }, + CallID: "call_1", Tool: "bash", State: msgmodel.PendingToolState(), + }, + ), + } + for name, message := range cases { + t.Run(name, func(t *testing.T) { + if err := ValidateSummary(message); err == nil { + t.Fatal("invalid summary was accepted") + } + }) + } +} + +func TestNormalizeOffFormatSummaryKeepsStateButRejectsContinuation(t *testing.T) { + report := testAssistant("report", "uc", textPart( + "report", + "## Summary of Changes\n### Current\n- Added SortBy in src/cli.rs\n- cargo build: error: could not compile", + )) + normalized, ok := NormalizeOffFormatSummary(report) + if !ok || ValidateSummaryText(normalized) != nil { + t.Fatalf("off-format state was not normalized:\n%s", normalized) + } + if !strings.Contains(normalized, "src/cli.rs") || + !strings.Contains(normalized, "error: could not compile") { + t.Fatalf("normalization lost working state:\n%s", normalized) + } + if !strings.Contains(normalized, `> \### Current`) { + t.Fatalf("recovered headings can collide with the summary envelope:\n%s", normalized) + } + + continuation := testAssistant( + "continue", "uc", textPart("continue", "Let me verify the edit by reading src/cli.rs."), + ) + if _, ok := NormalizeOffFormatSummary(continuation); ok { + t.Fatal("a coding continuation was mistaken for serialized state") + } +} + +func TestOverflowHistoryAndReplayPartSurgery(t *testing.T) { + imageName := "photo.png" + image := msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "pi", SessionID: "ses_1", MessageID: "u1"}, + Mime: "image/png", Filename: &imageName, URL: "data:image/png;base64,AA", + } + plain := msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "pt", SessionID: "ses_1", MessageID: "u1"}, + Mime: "text/plain", URL: "file:///a.txt", + } + compaction := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + } + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", textPart("a0", "reply")), + testUser("u1", image, plain), + testUser("uc", compaction), + } + selected := selectOverflowHistory(messages, "uc", true) + if selected.Replay == nil || selected.Replay.Info.ID != "u1" || + len(selected.Messages) != 2 { + t.Fatalf("overflow history = %#v", selected) + } + id := 0 + parts := buildReplayParts(*selected.Replay, "ses_new", "msg_new", func(prefix string) string { + id++ + return fmt.Sprintf("%s_%d", prefix, id) + }) + if len(parts) != 2 { + t.Fatalf("replay parts = %#v", parts) + } + placeholder, ok := parts[0].(msgmodel.TextPart) + if !ok || placeholder.Text != "[Attached image/png: photo.png]" || + placeholder.SessionID != "ses_new" || placeholder.MessageID != "msg_new" { + t.Fatalf("placeholder = %#v", parts[0]) + } + replayedFile, ok := parts[1].(msgmodel.FilePart) + if !ok || replayedFile.ID != "part_2" || replayedFile.Mime != "text/plain" { + t.Fatalf("plain replay = %#v", parts[1]) + } + + noHead := selectOverflowHistory(messages[2:], "uc", true) + if noHead.Replay != nil || len(noHead.Messages) != 2 { + t.Fatalf("fallback history = %#v", noHead) + } +} + +func TestAutoContinueStrings(t *testing.T) { + if got := autoContinueText(false); got != + "The conversation was compacted: the state record above replaces the older transcript, and the most recent messages are retained verbatim. Continue from the current state." { + t.Fatalf("normal continue = %q", got) + } + if got := autoContinueText(true); !stringsContainsAll( + got, "exceeded the provider's size limit", "\n\nThe conversation was compacted", + ) { + t.Fatalf("overflow continue = %q", got) + } +} + +func stringsContainsAll(value string, needles ...string) bool { + for _, needle := range needles { + if !strings.Contains(value, needle) { + return false + } + } + return true +} diff --git a/internal/seniordev/session/compaction/evidence.go b/internal/seniordev/session/compaction/evidence.go new file mode 100644 index 000000000..da2563b89 --- /dev/null +++ b/internal/seniordev/session/compaction/evidence.go @@ -0,0 +1,24 @@ +//go:build !windows + +package compaction + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/evidenceharvest" +) + +// FallbackEvidenceSelector exposes the deterministic evidence harvest through +// the compaction service's evidence seam. +type FallbackEvidenceSelector struct { + MaxChars float64 +} + +func (selector FallbackEvidenceSelector) SelectEvidence( + _ context.Context, blocks []string, +) (*string, error) { + if selector.MaxChars > 0 { + return evidenceharvest.HarvestEvidence(blocks, selector.MaxChars), nil + } + return evidenceharvest.HarvestEvidence(blocks), nil +} diff --git a/internal/seniordev/session/compaction/service.go b/internal/seniordev/session/compaction/service.go new file mode 100644 index 000000000..0238ce1bb --- /dev/null +++ b/internal/seniordev/session/compaction/service.go @@ -0,0 +1,1356 @@ +//go:build !windows + +// The compaction service runs one compaction boundary end to end. Concrete +// config/provider/plugin/session/processor services are represented by narrow +// interfaces; the state transitions and model-visible strings live here. +package compaction + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "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/jsonutil" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +type ConfigProvider interface { + GetConfig(ctx context.Context) (overflow.Config, error) +} + +type ConfigProviderFunc func(ctx context.Context) (overflow.Config, error) + +func (f ConfigProviderFunc) GetConfig(ctx context.Context) (overflow.Config, error) { + return f(ctx) +} + +type ModelRef struct { + ProviderID string + ModelID string +} + +type Agent struct { + Name string + Model *ModelRef +} + +type AgentProvider interface { + GetAgent(ctx context.Context, name string) (Agent, error) +} + +type AgentProviderFunc func(ctx context.Context, name string) (Agent, error) + +func (f AgentProviderFunc) GetAgent(ctx context.Context, name string) (Agent, error) { + return f(ctx, name) +} + +type ProviderInfo struct { + Source string + Options any +} + +type ModelProvider interface { + GetModel(ctx context.Context, providerID, modelID string) (Model, error) + GetProvider(ctx context.Context, providerID string) (ProviderInfo, error) +} + +type CompactingResult struct { + Context []string + Prompt *string +} + +type AutoContinueInput struct { + SessionID string + Agent string + Model Model + Provider ProviderInfo + Message msgmodel.User + Overflow bool +} + +type Plugin interface { + Compacting(ctx context.Context, sessionID string) (CompactingResult, error) + TransformMessages(ctx context.Context, messages []msgmodel.WithParts) error + AutoContinue(ctx context.Context, input AutoContinueInput) (bool, error) +} + +type SummaryRequest struct { + User msgmodel.User + Agent Agent + SessionID string + Messages []msgmodel.ModelMessage + Model Model +} + +type SummaryProcessor interface { + Process(ctx context.Context, request SummaryRequest) (steploop.Result, error) + Message() msgmodel.Assistant +} + +type ProcessorFactory interface { + Create( + ctx context.Context, + assistant *msgmodel.Assistant, + sessionID string, + model Model, + ) (SummaryProcessor, error) +} + +type ProcessorFactoryFunc func( + ctx context.Context, + assistant *msgmodel.Assistant, + sessionID string, + model Model, +) (SummaryProcessor, error) + +func (f ProcessorFactoryFunc) Create( + ctx context.Context, + assistant *msgmodel.Assistant, + sessionID string, + model Model, +) (SummaryProcessor, error) { + return f(ctx, assistant, sessionID, model) +} + +type EvidenceSelector interface { + SelectEvidence(ctx context.Context, blocks []string) (*string, error) +} + +type EvidenceSelectorFunc func(ctx context.Context, blocks []string) (*string, error) + +func (f EvidenceSelectorFunc) SelectEvidence(ctx context.Context, blocks []string) (*string, error) { + return f(ctx, blocks) +} + +// ContextSizer measures the complete model-visible request represented by a +// projected message list. The senior-dev adapter includes its system prompt and +// tool schemas; the default service implementation measures messages alone. +type ContextSizer interface { + EstimateContext(ctx context.Context, messages []msgmodel.WithParts, model Model) (float64, error) +} + +type ContextSizerFunc func( + ctx context.Context, messages []msgmodel.WithParts, model Model, +) (float64, error) + +func (f ContextSizerFunc) EstimateContext( + ctx context.Context, messages []msgmodel.WithParts, model Model, +) (float64, error) { + return f(ctx, messages, model) +} + +// CompactionDecision is the one record a boundary leaves behind: what the +// summarizer was handed (transcript and prompt sizes), what the provider +// reported back (prompt and output tokens), how the summary was judged, and +// how much verbatim tail was kept. +type CompactionDecision struct { + SessionID string `json:"sessionID"` + Status string `json:"status"` + Before float64 `json:"beforeTokens"` + After float64 `json:"afterTokens"` + Capacity float64 `json:"capacityTokens"` + Low float64 `json:"lowTokens"` + High float64 `json:"highTokens"` + // DroppedTail: the watermark rebuild removed the verbatim tail. + // StubbedSummary: it also had to replace the summary with the capacity + // stub because the summary block alone did not fit. + DroppedTail bool `json:"droppedTail"` + StubbedSummary bool `json:"stubbedSummary,omitempty"` + // SummaryStatus is valid, normalized, fallback, no-head (nothing to + // summarize, no call made), overflow (the summary request itself exceeded + // the model), summary-error (the call failed), summary-stopped, or + // capacity-fallback (the watermark rebuild replaced the record). Every + // status except valid and normalized installs the deterministic record. + SummaryStatus string `json:"summaryStatus"` + SummaryClass string `json:"summaryClass,omitempty"` + SummaryError string `json:"summaryError,omitempty"` + + TranscriptMessages int `json:"transcriptMessages"` + TranscriptChars int `json:"transcriptChars"` + TranscriptCutChars float64 `json:"transcriptCutChars,omitempty"` + PromptChars int `json:"promptChars"` + SummaryPromptTokens uint64 `json:"summaryPromptTokens"` + SummaryOutputTokens uint64 `json:"summaryOutputTokens"` + SummaryWallMs int64 `json:"summaryWallMs"` + + TailBudget float64 `json:"tailBudget"` + TailMessages int `json:"tailMessages"` + TailTokens float64 `json:"tailTokens"` + TailTruncatedOutputs int `json:"tailTruncatedOutputs"` + TailTruncatedReasoning int `json:"tailTruncatedReasoning,omitempty"` + // PreviousSummaryCarried is set when a deterministic record carried the + // previous boundary's summary forward verbatim. + PreviousSummaryCarried bool `json:"previousSummaryCarried,omitempty"` +} + +type DecisionSink interface { + CompactionDecision(decision CompactionDecision) +} + +type DecisionSinkFunc func(decision CompactionDecision) + +func (f DecisionSinkFunc) CompactionDecision(decision CompactionDecision) { f(decision) } + +type InstanceContext struct { + Directory string + Worktree string +} + +type EventSink interface { + CompactionStarted(sessionID string, timestamp uint64, reason string) + CompactionEnded(sessionID string, timestamp uint64, text string, include *string) + PublishCompacted(ctx context.Context, sessionID string) error +} + +type Dependencies struct { + Store steploop.Store + Config ConfigProvider + Agents AgentProvider + Provider ModelProvider + Plugin Plugin + Processors ProcessorFactory + Evidence EvidenceSelector + Sizer ContextSizer + Decisions DecisionSink + Events EventSink + Instance InstanceContext + // ChangedFiles reports the workspace's changed files as preformatted + // lines, computed by code (a diffstat against the starting tree plus the + // status). It is pinned beside every summary as a record the model cannot + // misremember; nil disables the pin. + ChangedFiles func(ctx context.Context) []string + + NewID func(prefix string) string + Now func() uint64 +} + +type Service struct { + deps Dependencies +} + +var ErrContextCapacityExhausted = errors.New("compaction: context capacity exhausted") + +type ContextCapacityError struct { + After float64 + High float64 +} + +func (err ContextCapacityError) Error() string { + return fmt.Sprintf( + "%s: deterministic rebuild is %.0f tokens; high watermark is %.0f", + ErrContextCapacityExhausted, err.After, err.High, + ) +} + +func (ContextCapacityError) Unwrap() error { return ErrContextCapacityExhausted } + +func NewService(deps Dependencies) *Service { + if deps.NewID == nil { + deps.NewID = defaultID + } + if deps.Now == nil { + deps.Now = func() uint64 { return uint64(time.Now().UnixMilli()) } + } + return &Service{deps: deps} +} + +func (s *Service) IsOverflow( + ctx context.Context, tokens msgmodel.Tokens, model Model, +) (bool, error) { + cfg, err := s.config(ctx) + if err != nil { + return false, err + } + return overflow.IsOverflow(overflow.OverflowInput{ + Cfg: cfg, Tokens: overflowTokens(tokens), Model: model.Overflow, + }), nil +} + +func (s *Service) Estimate( + messages []msgmodel.WithParts, model Model, +) (float64, error) { + modelMessages, err := msgmodel.ToModelMessages(messages, model.Message, nil) + if err != nil { + return 0, err + } + raw, err := jsonutil.Marshal(modelMessages) + if err != nil { + return 0, err + } + return estimateTokens(string(raw)), nil +} + +func (s *Service) estimateContext( + ctx context.Context, messages []msgmodel.WithParts, model Model, +) (float64, error) { + if s.deps.Sizer != nil { + return s.deps.Sizer.EstimateContext(ctx, messages, model) + } + return s.Estimate(messages, model) +} + +func (s *Service) projectedContextTokens( + ctx context.Context, sessionID string, model Model, +) (float64, error) { + messages, err := s.deps.Store.Messages(ctx, sessionID) + if err != nil { + return 0, err + } + newest := make([]msgmodel.WithParts, len(messages)) + for index := range messages { + newest[len(messages)-1-index] = messages[index] + } + return s.estimateContext(ctx, msgmodel.FilterCompacted(newest), model) +} + +func (s *Service) Prune(ctx context.Context, sessionID string) error { + if s.deps.Store == nil { + return errors.New("compaction: nil store") + } + cfg, err := s.config(ctx) + if err != nil { + return err + } + if cfg.Compaction != nil && cfg.Compaction.Prune != nil && !*cfg.Compaction.Prune { + return nil + } + messages, err := s.deps.Store.Messages(ctx, sessionID) + if errors.Is(err, msgmodel.ErrNotFound) { + return nil + } + if err != nil { + return err + } + total := float64(0) + pruned := float64(0) + toPrune := []msgmodel.ToolPart{} + turnCount := 0 + stop := false + for messageIndex := len(messages) - 1; messageIndex >= 0 && !stop; messageIndex-- { + message := messages[messageIndex] + if _, ok := message.Info.(msgmodel.User); ok { + turnCount++ + } + if turnCount < 2 { + continue + } + if assistant, ok := message.Info.(msgmodel.Assistant); ok && boolPointer(assistant.Summary) { + break + } + for partIndex := len(message.Parts) - 1; partIndex >= 0; partIndex-- { + part, ok := message.Parts[partIndex].(msgmodel.ToolPart) + if !ok { + continue + } + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if !ok || protectedTool(part.Tool) { + continue + } + if completed.Time.Compacted != nil && *completed.Time.Compacted != 0 { + stop = true + break + } + estimate := estimateTokens(completed.Output) + total += estimate + if total <= PruneProtect { + continue + } + pruned += estimate + toPrune = append(toPrune, part) + } + } + if pruned > PruneMinimum { + for _, part := range toPrune { + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if !ok { + continue + } + now := s.deps.Now() + completed.Time.Compacted = &now + part.State = completed + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + } + } + return nil +} + +type ProcessInput struct { + ParentID string + Messages []msgmodel.WithParts + SessionID string + Auto bool + Overflow *bool +} + +func (s *Service) Process(ctx context.Context, input ProcessInput) (steploop.Result, error) { + if s.deps.Store == nil || s.deps.Agents == nil || + s.deps.Provider == nil || s.deps.Processors == nil { + return steploop.ResultStop, errors.New("compaction: incomplete dependencies") + } + var parent *msgmodel.WithParts + for i := len(input.Messages) - 1; i >= 0; i-- { + if input.Messages[i].Info.MessageID() == input.ParentID { + value := input.Messages[i] + parent = &value + break + } + } + if parent == nil { + return steploop.ResultStop, fmt.Errorf( + "Compaction parent must be a user message: %s", input.ParentID, + ) + } + userMessage, ok := parent.Info.(msgmodel.User) + if !ok { + return steploop.ResultStop, fmt.Errorf( + "Compaction parent must be a user message: %s", input.ParentID, + ) + } + compactionPart := findCompaction(parent.Parts) + overflowed := input.Overflow != nil && *input.Overflow + historyChoice := selectOverflowHistory(input.Messages, input.ParentID, overflowed) + messages := historyChoice.Messages + replay := historyChoice.Replay + + agent, err := s.deps.Agents.GetAgent(ctx, "compaction") + if err != nil { + return steploop.ResultStop, err + } + ref := ModelRef{ + ProviderID: userMessage.Model.ProviderID, ModelID: userMessage.Model.ModelID, + } + if agent.Model != nil { + ref = *agent.Model + } + model, err := s.deps.Provider.GetModel(ctx, ref.ProviderID, ref.ModelID) + if err != nil { + return steploop.ResultStop, err + } + originalModel := model + if ref.ProviderID != userMessage.Model.ProviderID || ref.ModelID != userMessage.Model.ModelID { + originalModel, err = s.deps.Provider.GetModel( + ctx, userMessage.Model.ProviderID, userMessage.Model.ModelID, + ) + if err != nil { + return steploop.ResultStop, err + } + } + cfg, err := s.config(ctx) + if err != nil { + return steploop.ResultStop, err + } + history := messages + if compactionPart != nil && len(messages) > 0 && + messages[len(messages)-1].Info.MessageID() == input.ParentID { + history = messages[:len(messages)-1] + } + beforeTokens, err := s.estimateContext(ctx, history, originalModel) + if err != nil { + return steploop.ResultStop, fmt.Errorf("compaction: measure input context: %w", err) + } + beforeTokens = math.Max(beforeTokens, observedContextTokens(history)) + prior := completedCompactions(history) + hidden := map[int]bool{} + for _, item := range prior { + hidden[item.UserIndex] = true + hidden[item.AssistantIndex] = true + } + var previousSummary *string + if len(prior) > 0 { + previousSummary = prior[len(prior)-1].Summary + } + visible := make([]msgmodel.WithParts, 0, len(history)-len(hidden)) + for i, message := range history { + if !hidden[i] { + visible = append(visible, message) + } + } + // The tail first: the newest messages are kept verbatim (older tool + // outputs truncated in the store so the tail always fits), and only what + // precedes them is summarized. + // The tail budget follows the same watermarks the trigger used: it is a + // fraction of the high watermark, and it is recorded on the decision + // beside the tail actually kept. + budget := tailBudget(cfg, overflow.Watermarks( + overflow.UsableInput{Cfg: cfg, Model: originalModel.Overflow}, + )) + selected, err := selectTail(visible, budget, model, s.Estimate, TailToolOutputMaxChars) + if err != nil { + return steploop.ResultStop, err + } + compacting := CompactingResult{Context: []string{}} + if s.deps.Plugin != nil { + compacting, err = s.deps.Plugin.Compacting(ctx, input.SessionID) + if err != nil { + return steploop.ResultStop, err + } + } + nextPrompt := BuildPrompt(previousSummary, compacting.Context) + if compacting.Prompt != nil { + nextPrompt = *compacting.Prompt + } + cloned, err := cloneMessages(selected.Head) + if err != nil { + return steploop.ResultStop, err + } + if s.deps.Plugin != nil { + if err := s.deps.Plugin.TransformMessages(ctx, cloned); err != nil { + return steploop.ResultStop, err + } + } + pinnedPrompt := nextPrompt + // The head is flattened into ONE user text block (SerializeTranscript): + // data to read, with no open turn for the model to continue. + transcript, transcriptCut := CapTranscript( + SerializeTranscript(cloned, ToolOutputMaxChars), SummaryTranscriptMaxChars, + ) + summaryPrompt := "\n" + transcript + "\n\n\n" + pinnedPrompt + authoritativeTask, taskSource := s.authoritativeTask(messages) + changedFiles := s.changedFiles(ctx) + + decision := CompactionDecision{ + SessionID: input.SessionID, + TranscriptMessages: len(selected.Head), + TranscriptChars: charCount(transcript), + TranscriptCutChars: transcriptCut, + PromptChars: charCount(summaryPrompt), + TailBudget: budget, + TailMessages: selected.Messages, + TailTokens: selected.Tokens, + TailTruncatedOutputs: selected.TruncatedOutputs, TailTruncatedReasoning: selected.TruncatedReasoning, + } + + // ONE summary call. Whatever comes back, this boundary completes and the + // tail is kept: a valid record is installed as generated, a summary-shaped + // one is normalized, and anything else -- rejected, errored, overflowed, + // or nothing to summarize -- gets the deterministic record, which carries + // the previous summary forward verbatim. There are no retries and no + // second attempt message, so there is nothing for FilterCompacted to + // pick wrongly and no path on which the boundary destroys progress. + assistant := msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: input.SessionID, + }, + Time: msgmodel.AssistantTime{Created: s.deps.Now()}, + ParentID: input.ParentID, + ModelID: model.Message.ID, + ProviderID: model.Message.ProviderID, + Mode: "compaction", + Agent: "compaction", + Path: msgmodel.AssistantPath{ + Cwd: s.deps.Instance.Directory, Root: s.deps.Instance.Worktree, + }, + Summary: boolAddress(true), + Cost: 0, + Tokens: msgmodel.Tokens{ + Cache: msgmodel.TokenCache{}, + }, + Variant: userMessage.Model.Variant, + } + if err := s.deps.Store.UpdateMessage(ctx, assistant); err != nil { + return steploop.ResultStop, err + } + var ( + accepted msgmodel.WithParts + cause error + ) + decision.SummaryStatus = "valid" + if strings.TrimSpace(transcript) == "" { + decision.SummaryStatus = "no-head" + } else { + processor, err := s.deps.Processors.Create(ctx, &assistant, input.SessionID, model) + if err != nil { + return steploop.ResultStop, err + } + started := time.Now() + attemptResult, err := processor.Process(ctx, SummaryRequest{ + User: userMessage, Agent: agent, SessionID: input.SessionID, + Messages: []msgmodel.ModelMessage{msgmodel.UserText(summaryPrompt)}, + Model: model, + }) + if err != nil { + return steploop.ResultStop, err + } + decision.SummaryWallMs = time.Since(started).Milliseconds() + processorMessage := processor.Message() + decision.SummaryPromptTokens = processorMessage.Tokens.Input + + processorMessage.Tokens.Cache.Read + processorMessage.Tokens.Cache.Write + decision.SummaryOutputTokens = processorMessage.Tokens.Output + candidate, err := s.summaryMessage(ctx, input.SessionID, assistant.ID) + if err != nil { + return steploop.ResultStop, err + } + switch { + case attemptResult == steploop.ResultCompact: + decision.SummaryStatus = "overflow" + cause = errors.New("the summary request itself exceeded the model context") + case processorMessage.Error != nil: + decision.SummaryStatus = "summary-error" + cause = errors.New(assistantErrorText(processorMessage.Error)) + case attemptResult != steploop.ResultContinue: + decision.SummaryStatus = "summary-stopped" + cause = fmt.Errorf("summary call ended with %s", attemptResult) + default: + validationErr := ValidateSummary(candidate) + if validationErr == nil { + accepted = candidate + } else if normalized, ok := NormalizeOffFormatSummary(candidate); ok { + decision.SummaryStatus = "normalized" + decision.SummaryError = validationErr.Error() + accepted, err = s.installSummaryText(ctx, input.SessionID, candidate, normalized) + if err != nil { + return steploop.ResultStop, err + } + } else { + decision.SummaryStatus = "fallback" + decision.SummaryClass = ClassifySummaryFailure(candidate) + cause = validationErr + } + } + } + if accepted.Info == nil { + // The deterministic record. The message becomes the boundary: no + // error, a finish reason, and a record that validates. + if cause != nil { + decision.SummaryError = cause.Error() + } + candidate, err := s.summaryMessage(ctx, input.SessionID, assistant.ID) + if err != nil { + return steploop.ResultStop, err + } + info, _ := candidate.Info.(msgmodel.Assistant) + info.Error = nil + finish := "stop" + info.Finish = &finish + if err := s.deps.Store.UpdateMessage(ctx, info); err != nil { + return steploop.ResultStop, err + } + candidate.Info = info + decision.PreviousSummaryCarried = previousSummary != nil && *previousSummary != "" + accepted, err = s.installSummaryText(ctx, input.SessionID, candidate, + fallbackRecord(previousSummary, authoritativeTask, cause, len(changedFiles) > 0)) + if err != nil { + return steploop.ResultStop, err + } + } + result := steploop.ResultContinue + if authoritativeTask != "" { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: accepted.Info.MessageID(), + SessionID: input.SessionID, + }, + Text: BuildAuthoritativeTaskPin(authoritativeTask, taskSource), + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"authoritative_task"}`), + }); err != nil { + return steploop.ResultStop, err + } + } + + if pin := BuildChangedFilesPin(changedFiles); pin != "" { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: accepted.Info.MessageID(), + SessionID: input.SessionID, + }, + Text: pin, + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"changed_files"}`), + }); err != nil { + return steploop.ResultStop, err + } + } + + if s.deps.Evidence != nil { + evidence, evidenceErr := s.deps.Evidence.SelectEvidence( + ctx, EvidenceBlocksFromMessages(selected.Head), + ) + if evidenceErr == nil && evidence != nil && *evidence != "" { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: accepted.Info.MessageID(), + SessionID: input.SessionID, + }, + Text: *evidence, Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"evidence"}`), + }); err != nil { + return steploop.ResultStop, err + } + } + } + + // The kept tail was measured at its truncated size; make the store agree + // before the tail is projected, so what the model sees costs what was + // counted. + for _, part := range selected.Truncated { + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return steploop.ResultStop, err + } + } + if compactionPart != nil && selected.StartID != nil && + (compactionPart.TailStartID == nil || *compactionPart.TailStartID != *selected.StartID) { + part := *compactionPart + part.TailStartID = selected.StartID + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return steploop.ResultStop, err + } + } + + if result == steploop.ResultContinue && input.Auto { + if replay != nil { + if err := s.persistReplay(ctx, input.SessionID, *replay); err != nil { + return steploop.ResultStop, err + } + } else { + enabled := true + if s.deps.Plugin != nil { + info, err := s.deps.Provider.GetProvider(ctx, userMessage.Model.ProviderID) + if err != nil { + return steploop.ResultStop, err + } + enabled, err = s.deps.Plugin.AutoContinue(ctx, AutoContinueInput{ + SessionID: input.SessionID, Agent: userMessage.Agent, + Model: originalModel, Provider: info, + Message: userMessage, Overflow: overflowed, + }) + if err != nil { + return steploop.ResultStop, err + } + } + if enabled { + if err := s.persistAutoContinue( + ctx, input.SessionID, userMessage, overflowed, + ); err != nil { + return steploop.ResultStop, err + } + } + } + } + + capacity, capacityErr := s.enforceWatermarks( + ctx, input.SessionID, beforeTokens, originalModel, cfg, + compactionPart, taskSource, + ) + decision.Status = capacity.Status + decision.Before, decision.After = capacity.Before, capacity.After + decision.Capacity, decision.Low, decision.High = capacity.Capacity, capacity.Low, capacity.High + decision.DroppedTail = capacity.DroppedTail + decision.StubbedSummary = capacity.StubbedSummary + if capacity.StubbedSummary { + decision.SummaryStatus = "capacity-fallback" + } + if s.deps.Decisions != nil { + s.deps.Decisions.CompactionDecision(decision) + } + if capacityErr != nil { + return steploop.ResultStop, capacityErr + } + + if result == steploop.ResultContinue { + var summary *string + fresh, err := s.deps.Store.Messages(ctx, input.SessionID) + if err != nil { + return steploop.ResultStop, err + } + for _, item := range fresh { + if item.Info.MessageID() == accepted.Info.MessageID() { + summary = summaryText(item) + break + } + } + if s.deps.Events != nil { + text := "" + if summary != nil { + text = *summary + } + s.deps.Events.CompactionEnded( + input.SessionID, s.deps.Now(), text, selected.StartID, + ) + if err := s.deps.Events.PublishCompacted(ctx, input.SessionID); err != nil { + return steploop.ResultStop, err + } + } + } + return result, nil +} + +func (s *Service) summaryMessage( + ctx context.Context, sessionID, messageID string, +) (msgmodel.WithParts, error) { + messages, err := s.deps.Store.Messages(ctx, sessionID) + if err != nil { + return msgmodel.WithParts{}, err + } + for _, message := range messages { + if message.Info.MessageID() == messageID { + return message, nil + } + } + return msgmodel.WithParts{}, fmt.Errorf("compaction summary message not found: %s", messageID) +} + +const ( + compactionStatusTarget = "target" + compactionStatusDegraded = "degraded" + compactionStatusRebuilt = "rebuilt" + compactionStatusExhausted = "context_capacity_exhausted" + compactionStatusUnbounded = "unbounded" +) + +func minimumContinuationHeadroom(marks overflow.CompactionWatermarks) float64 { + gap := math.Max(0, marks.High-marks.Low) + wanted := math.Max(2_048, math.Floor(marks.High*0.10)) + return math.Max(1, math.Min(gap, wanted)) +} + +func (s *Service) enforceWatermarks( + ctx context.Context, + sessionID string, + before float64, + model Model, + cfg overflow.Config, + compactionPart *msgmodel.CompactionPart, + source string, +) (CompactionDecision, error) { + marks := overflow.Watermarks( + overflow.UsableInput{Cfg: cfg, Model: model.Overflow}, + ) + decision := CompactionDecision{ + SessionID: sessionID, Before: before, + Capacity: marks.Capacity, Low: marks.Low, High: marks.High, + } + if marks.High <= 0 || math.IsInf(marks.High, 1) { + decision.Status = compactionStatusUnbounded + return decision, nil + } + after, err := s.projectedContextTokens(ctx, sessionID, model) + if err != nil { + return decision, fmt.Errorf("compaction: measure reconstructed context: %w", err) + } + decision.After = after + headroom := minimumContinuationHeadroom(marks) + if after <= marks.Low { + decision.Status = compactionStatusTarget + return decision, nil + } + if after < marks.High && before-after >= headroom && marks.High-after >= headroom { + decision.Status = compactionStatusDegraded + return decision, nil + } + + // Stage one: drop the verbatim tail and keep the summary. The tail is + // almost always what does not fit (typically one giant newest message), + // and the summary is the progress record, so it is kept as long as it + // fits on its own. + if compactionPart != nil && compactionPart.TailStartID != nil { + part := *compactionPart + part.TailStartID = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return decision, err + } + decision.DroppedTail = true + after, err = s.projectedContextTokens(ctx, sessionID, model) + if err != nil { + return decision, fmt.Errorf("compaction: measure tail-dropped rebuild: %w", err) + } + decision.After = after + if after < marks.High && marks.High-after >= headroom { + decision.Status = compactionStatusRebuilt + return decision, nil + } + } + + // Stage two: the summary block itself does not fit. Replace it with the + // deterministic capacity stub. + if err := s.installCapacityFallback( + ctx, sessionID, compactionPart, source, after, marks, + ); err != nil { + return decision, err + } + decision.DroppedTail = true + decision.StubbedSummary = true + after, err = s.projectedContextTokens(ctx, sessionID, model) + if err != nil { + return decision, fmt.Errorf("compaction: measure deterministic rebuild: %w", err) + } + decision.After = after + if after < marks.High && marks.High-after >= headroom { + decision.Status = compactionStatusRebuilt + return decision, nil + } + decision.Status = compactionStatusExhausted + return decision, ContextCapacityError{After: after, High: marks.High} +} + +func compactFallbackSummary(source string, observed float64, marks overflow.CompactionWatermarks) string { + current := "- Continue the authoritative task pinned verbatim beside this state record." + files := "- (none)" + if source == ".senior-dev/spec.md" { + files = "- .senior-dev/spec.md: authoritative task specification" + } else if source == "" { + current = "- Recover the original request from durable session state before editing." + } + context := fmt.Sprintf( + "- Prior projection was %.0f tokens (target %.0f; high watermark %.0f); retained history did not fit.", + observed, marks.Low, marks.High, + ) + return strings.Join([]string{ + "## Working State", + "### Completed", "- No generated completion claim survived the capacity rebuild.", + "### Current", current, + "### Verification", context, + "### Next", "- Inspect the current diff and latest exact failure before editing.", + "### Files", files, + }, "\n") +} + +func compactionRole(part msgmodel.TextPart) string { + if len(part.Metadata) == 0 { + return "" + } + var value struct { + Role string `json:"compaction_role"` + } + if json.Unmarshal(part.Metadata, &value) != nil { + return "" + } + return value.Role +} + +func (s *Service) installCapacityFallback( + ctx context.Context, + sessionID string, + compactionPart *msgmodel.CompactionPart, + source string, + observed float64, + marks overflow.CompactionWatermarks, +) error { + if compactionPart != nil { + part := *compactionPart + part.TailStartID = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + } + + messages, err := s.deps.Store.Messages(ctx, sessionID) + if err != nil { + return err + } + prior := completedCompactions(messages) + if len(prior) == 0 { + return errors.New("compaction: completed summary missing during deterministic rebuild") + } + message := messages[prior[len(prior)-1].AssistantIndex] + fallback := compactFallbackSummary(source, observed, marks) + written := false + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if ok && !boolPointer(part.Synthetic) && !written { + part.Text = fallback + part.Ignored = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + written = true + continue + } + if ok && compactionRole(part) == "authoritative_task" { + continue + } + switch raw.(type) { + case msgmodel.StepStartPart, msgmodel.StepFinishPart: + continue + } + ignored := true + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: raw.Base(), Text: "", Ignored: &ignored, + }); err != nil { + return err + } + } + if !written { + return errors.New("compaction: generated summary text missing during deterministic rebuild") + } + accepted, err := s.summaryMessage(ctx, sessionID, message.Info.MessageID()) + if err != nil { + return err + } + if err := ValidateSummary(accepted); err != nil { + return fmt.Errorf("compaction deterministic rebuild validation failed: %w", err) + } + return nil +} + +func (s *Service) authoritativeTask(messages []msgmodel.WithParts) (string, string) { + if s.deps.Instance.Directory != "" { + path := filepath.Join(s.deps.Instance.Directory, ".senior-dev", "spec.md") + if raw, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(raw)) != "" { + return string(raw), ".senior-dev/spec.md" + } + } + for _, message := range messages { + if IsSyntheticUser(message) || hasCompaction(message.Parts) { + continue + } + if _, ok := message.Info.(msgmodel.User); !ok { + continue + } + parts := []string{} + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) || boolPointer(part.Synthetic) || + strings.TrimSpace(part.Text) == "" { + continue + } + parts = append(parts, part.Text) + } + if task := strings.TrimSpace(strings.Join(parts, "\n\n")); task != "" { + return task, "original user request" + } + } + return "", "" +} + +func BuildAuthoritativeTaskPin(task, source string) string { + return strings.Join([]string{ + "# AUTHORITATIVE TASK (verbatim — durable, not generated)", + "Source: " + source, + "The text below is the task. It overrides any conflicting claim in the generated summary.", + "", + task, + "", + }, "\n") +} + +// fallbackRecord is the deterministic state record installed when no generated +// summary is available. It never claims progress it cannot know, and it never +// loses progress either: the previous boundary's summary is carried forward +// verbatim (quoted as data), the task is pinned beside it by the caller, the +// changed files are computed by code, and the verbatim tail follows. +func fallbackRecord(previous *string, task string, cause error, filesPinned bool) string { + completed := "- (none: no state record could be generated at this boundary)" + if previous != nil && strings.TrimSpace(*previous) != "" { + completed = "- No summary was generated at this boundary; the previous state record is carried forward verbatim:\n" + + quoteAsData(HeadTailTruncate(strings.TrimSpace(*previous), 8_000)) + } + current := "- Continue the authoritative task from the current repository state; the most recent messages are retained verbatim after this record." + if strings.TrimSpace(task) == "" { + current = "- Recover the original request from durable session state before editing; the most recent messages are retained verbatim after this record." + } + verification := "- No verification claim was retained at this boundary." + if cause != nil { + verification += " Cause: " + cause.Error() + } + files := "- (none recorded)" + if filesPinned { + files = "- See the CHANGED FILES record pinned beside this state." + } + return strings.Join([]string{ + "## Working State", + "### Completed", completed, + "### Current", current, + "### Verification", verification, + "### Next", "- Inspect the current diff and the retained recent messages before editing.", + "### Files", files, + }, "\n") +} + +// quoteAsData prefixes every line with "> " and escapes leading heading +// markers, so quoted text can never collide with the record's own contract +// headings. +func quoteAsData(text string) string { + quoted := make([]string, 0, strings.Count(text, "\n")+1) + for _, line := range strings.Split(text, "\n") { + if trimmed := strings.TrimLeft(line, " \t"); strings.HasPrefix(trimmed, "#") { + line = strings.Replace(line, "#", `\#`, 1) + } + quoted = append(quoted, "> "+line) + } + return strings.Join(quoted, "\n") +} + +// BuildChangedFilesPin renders the code-computed changed-files record that is +// pinned beside every summary: a generated summary can forget a file, a +// diffstat cannot. +func BuildChangedFilesPin(lines []string) string { + lines = nonEmptyStrings(lines) + if len(lines) == 0 { + return "" + } + return strings.Join(append([]string{ + "# CHANGED FILES (computed by senior-dev at this compaction, not generated)", + }, lines...), "\n") +} + +func (s *Service) changedFiles(ctx context.Context) []string { + if s.deps.ChangedFiles == nil { + return nil + } + return s.deps.ChangedFiles(ctx) +} + +// assistantErrorText renders a stored assistant error for the decision event, +// bounded so a provider's HTML error page cannot flood the record. +func assistantErrorText(err *msgmodel.AssistantError) string { + if err == nil { + return "" + } + text := err.Name + if len(err.Data) > 0 { + text += ": " + string(err.Data) + } + return HeadTailTruncate(text, 600) +} + +func (s *Service) installSummaryText( + ctx context.Context, + sessionID string, + message msgmodel.WithParts, + text string, +) (msgmodel.WithParts, error) { + written := false + for _, raw := range message.Parts { + if part, ok := raw.(msgmodel.TextPart); ok && !written && + !boolPointer(part.Synthetic) { + part.Text = text + part.Ignored = nil + part.Synthetic = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return msgmodel.WithParts{}, err + } + written = true + continue + } + switch raw.(type) { + case msgmodel.StepStartPart, msgmodel.StepFinishPart: + continue + } + ignored := true + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: raw.Base(), Text: "", Ignored: &ignored, + }); err != nil { + return msgmodel.WithParts{}, err + } + } + if !written { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: message.Info.MessageID(), + SessionID: sessionID, + }, + Text: text, + }); err != nil { + return msgmodel.WithParts{}, err + } + } + accepted, err := s.summaryMessage(ctx, sessionID, message.Info.MessageID()) + if err != nil { + return msgmodel.WithParts{}, err + } + if err := ValidateSummary(accepted); err != nil { + return msgmodel.WithParts{}, fmt.Errorf("compaction installed summary validation failed: %w", err) + } + return accepted, nil +} + +func nonEmptyStrings(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} + +type CreateInput struct { + SessionID string + Agent string + Model ModelRef + Auto bool + Overflow *bool +} + +func (s *Service) Create(ctx context.Context, input CreateInput) error { + if s.deps.Store == nil { + return errors.New("compaction: nil store") + } + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: input.SessionID, + }, + Time: msgmodel.TimeCreated{Created: s.deps.Now()}, + Agent: input.Agent, + Model: msgmodel.UserModel{ + ProviderID: input.Model.ProviderID, ModelID: input.Model.ModelID, + }, + } + if err := s.deps.Store.UpdateMessage(ctx, message); err != nil { + return err + } + part := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: message.ID, + SessionID: input.SessionID, + }, + Auto: input.Auto, Overflow: input.Overflow, + } + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + if s.deps.Events != nil { + reason := "manual" + if input.Auto { + reason = "auto" + } + s.deps.Events.CompactionStarted(input.SessionID, s.deps.Now(), reason) + } + return nil +} + +func (s *Service) persistReplay( + ctx context.Context, sessionID string, replay Replay, +) error { + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: sessionID, + }, + Time: msgmodel.TimeCreated{Created: s.deps.Now()}, + Format: replay.Info.Format, + Agent: replay.Info.Agent, + Model: replay.Info.Model, + System: replay.Info.System, + Tools: replay.Info.Tools, + } + if err := s.deps.Store.UpdateMessage(ctx, message); err != nil { + return err + } + for _, part := range buildReplayParts( + replay, sessionID, message.ID, s.deps.NewID, + ) { + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + } + return nil +} + +func (s *Service) persistAutoContinue( + ctx context.Context, + sessionID string, + user msgmodel.User, + overflowed bool, +) error { + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: sessionID, + }, + Time: msgmodel.TimeCreated{Created: s.deps.Now()}, + Agent: user.Agent, Model: user.Model, + } + if err := s.deps.Store.UpdateMessage(ctx, message); err != nil { + return err + } + partID := s.deps.NewID("part") + start := s.deps.Now() + end := s.deps.Now() + return s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: partID, MessageID: message.ID, SessionID: sessionID, + }, + Text: autoContinueText(overflowed), + Metadata: msgmodel.RawObject(`{"compaction_continue":true}`), + Synthetic: boolAddress(true), + Time: &msgmodel.TimeStartEnd{Start: start, End: &end}, + }) +} + +func (s *Service) config(ctx context.Context) (overflow.Config, error) { + if s.deps.Config == nil { + return overflow.Config{}, errors.New("compaction: nil config provider") + } + return s.deps.Config.GetConfig(ctx) +} + +func cloneMessages(input []msgmodel.WithParts) ([]msgmodel.WithParts, error) { + raw, err := jsonutil.Marshal(input) + if err != nil { + return nil, err + } + var out []msgmodel.WithParts + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} + +func findCompaction(parts msgmodel.Parts) *msgmodel.CompactionPart { + for _, raw := range parts { + if part, ok := raw.(msgmodel.CompactionPart); ok { + return &part + } + } + return nil +} + +func overflowTokens(tokens msgmodel.Tokens) overflow.Tokens { + var total *float64 + if tokens.Total != nil { + value := float64(*tokens.Total) + total = &value + } + return overflow.Tokens{ + Total: total, Input: float64(tokens.Input), Output: float64(tokens.Output), + Reasoning: float64(tokens.Reasoning), + Cache: overflow.TokenCache{ + Read: float64(tokens.Cache.Read), Write: float64(tokens.Cache.Write), + }, + } +} + +func observedContextTokens(messages []msgmodel.WithParts) float64 { + observed := float64(0) + for _, message := range messages { + assistant, ok := message.Info.(msgmodel.Assistant) + if !ok { + continue + } + tokens := float64(assistant.Tokens.Input + assistant.Tokens.Output + + assistant.Tokens.Cache.Read + assistant.Tokens.Cache.Write) + if assistant.Tokens.Total != nil && *assistant.Tokens.Total != 0 { + tokens = float64(*assistant.Tokens.Total) + } + observed = math.Max(observed, tokens) + } + return observed +} + +func protectedTool(name string) bool { + for _, protected := range PruneProtectedTools { + if name == protected { + return true + } + } + return false +} + +func boolAddress(value bool) *bool { return &value } + +var serviceID atomic.Uint64 + +func defaultID(prefix string) string { + return fmt.Sprintf("%s_%016x", prefix, serviceID.Add(1)) +} diff --git a/internal/seniordev/session/compaction/service_test.go b/internal/seniordev/session/compaction/service_test.go new file mode 100644 index 000000000..453a41285 --- /dev/null +++ b/internal/seniordev/session/compaction/service_test.go @@ -0,0 +1,957 @@ +//go:build !windows + +package compaction + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "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/overflow" +) + +type memoryStore struct { + mu sync.Mutex + messages []msgmodel.WithParts + updates []string + err error +} + +func (s *memoryStore) Messages(_ context.Context, _ string) ([]msgmodel.WithParts, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return nil, s.err + } + return append([]msgmodel.WithParts(nil), s.messages...), nil +} + +func (s *memoryStore) UpdateMessage(_ context.Context, info msgmodel.Info) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.messages { + if s.messages[i].Info.MessageID() == info.MessageID() { + s.messages[i].Info = info + s.updates = append(s.updates, "message:"+info.MessageID()) + return nil + } + } + s.messages = append(s.messages, msgmodel.WithParts{Info: info, Parts: msgmodel.Parts{}}) + s.updates = append(s.updates, "message:"+info.MessageID()) + return nil +} + +func (s *memoryStore) UpdatePart(_ context.Context, part msgmodel.Part) error { + s.mu.Lock() + defer s.mu.Unlock() + base := part.Base() + for messageIndex := range s.messages { + if s.messages[messageIndex].Info.MessageID() != base.MessageID { + continue + } + for partIndex, existing := range s.messages[messageIndex].Parts { + if existing.Base().ID == base.ID { + s.messages[messageIndex].Parts[partIndex] = part + s.updates = append(s.updates, "part:"+base.ID) + return nil + } + } + s.messages[messageIndex].Parts = append(s.messages[messageIndex].Parts, part) + s.updates = append(s.updates, "part:"+base.ID) + return nil + } + return fmt.Errorf("message not found for part %s", base.ID) +} + +func (s *memoryStore) find(id string) *msgmodel.WithParts { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.messages { + if s.messages[i].Info.MessageID() == id { + value := s.messages[i] + return &value + } + } + return nil +} + +type fakeProvider struct { + model Model + provider ProviderInfo + providerCalls int +} + +func (p *fakeProvider) GetModel(_ context.Context, _, _ string) (Model, error) { + return p.model, nil +} + +func (p *fakeProvider) GetProvider(_ context.Context, _ string) (ProviderInfo, error) { + p.providerCalls++ + return p.provider, nil +} + +type fakePlugin struct { + compacting CompactingResult + transformed bool + auto bool + autoCalls int + autoInput AutoContinueInput +} + +func (p *fakePlugin) Compacting(context.Context, string) (CompactingResult, error) { + return p.compacting, nil +} + +func (p *fakePlugin) TransformMessages(_ context.Context, _ []msgmodel.WithParts) error { + p.transformed = true + return nil +} + +func (p *fakePlugin) AutoContinue(_ context.Context, input AutoContinueInput) (bool, error) { + p.autoCalls++ + p.autoInput = input + return p.auto, nil +} + +type fakeProcessor struct { + message *msgmodel.Assistant + process func(context.Context, SummaryRequest) (steploop.Result, error) +} + +func (p *fakeProcessor) Process( + ctx context.Context, request SummaryRequest, +) (steploop.Result, error) { + return p.process(ctx, request) +} + +func (p *fakeProcessor) Message() msgmodel.Assistant { return *p.message } + +type fakeEvents struct { + started []string + ended []string + published []string +} + +func (e *fakeEvents) CompactionStarted(sessionID string, _ uint64, reason string) { + e.started = append(e.started, sessionID+":"+reason) +} + +func (e *fakeEvents) CompactionEnded( + sessionID string, _ uint64, text string, include *string, +) { + tail := "" + if include != nil { + tail = *include + } + e.ended = append(e.ended, sessionID+":"+tail+":"+text) +} + +func (e *fakeEvents) PublishCompacted(_ context.Context, sessionID string) error { + e.published = append(e.published, sessionID) + return nil +} + +func serviceModel() Model { + return 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}, + }, + } +} + +func deterministicRuntime() (func(string) string, func() uint64) { + id := 0 + now := uint64(1000) + return func(prefix string) string { + id++ + return fmt.Sprintf("%s_%d", prefix, id) + }, func() uint64 { + now++ + return now + } +} + +func baseDeps(store *memoryStore) Dependencies { + newID, now := deterministicRuntime() + // A zero tail budget keeps only the newest message verbatim, so every + // older message lands in the summarized head and the tests can see the + // summary path with a one-message tail. + tail := float64(0) + return Dependencies{ + Store: store, + Config: ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{ + PreserveRecentTokens: &tail, + }}, nil + }), + Agents: AgentProviderFunc(func(_ context.Context, name string) (Agent, error) { + return Agent{Name: name}, nil + }), + NewID: newID, Now: now, + } +} + +func compactionConversation(agent string) []msgmodel.WithParts { + parentPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ + ID: "pc", SessionID: "ses_1", MessageID: "uc", + }, + Auto: true, + } + goal := testUser("u0", textPart("u0", "Fix src/a.ts")) + // Two assistant messages: the newest one is always the verbatim tail, so + // the goal and the first reply form the head that gets summarized. + first := testAssistant("a0", "u0", textPart("a0", "Reading src/a.ts first.")) + newest := testAssistant("a1", "u0", textPart("a1", "The edit is in place.")) + parent := testUser("uc", parentPart) + parentUser := parent.Info.(msgmodel.User) + parentUser.Agent = agent + parent.Info = parentUser + return []msgmodel.WithParts{goal, first, newest, parent} +} + +// promptOf returns the single user text block of a summary request, after +// proving it is the one shape the wire converter accepts. +func promptOf(t *testing.T, request SummaryRequest) string { + t.Helper() + if len(request.Messages) != 1 || request.Messages[0].Role != "user" { + t.Fatalf("summary request shape = %#v", request.Messages) + } + parts, ok := request.Messages[0].Content.([]any) + if !ok || len(parts) != 1 { + t.Fatalf("summary content is not a canonical part list: %#v", request.Messages[0].Content) + } + text, ok := parts[0].(msgmodel.TextContent) + if !ok { + t.Fatalf("summary content part = %#v", parts[0]) + } + body, err := orclient.BuildRequestBody(orclient.RequestParams{ + ModelID: "vendor/model", Prompt: request.Messages, + }) + if err != nil { + t.Fatalf("summary request does not build a request body: %v", err) + } + if !strings.Contains(string(body), `"content":"\n`) { + t.Fatalf("wire body lost the transcript: %s", body) + } + return text.Text +} + +func TestProcessContinueInjectsSummaryEvidenceAutoContinueAndEvents(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + provider := &fakeProvider{ + model: serviceModel(), provider: ProviderInfo{Source: "env", Options: "opts"}, + } + plugin := &fakePlugin{ + compacting: CompactingResult{Context: []string{"PLUGIN CONTEXT"}}, + auto: true, + } + events := &fakeEvents{} + decisions := []CompactionDecision{} + deps := baseDeps(store) + deps.Provider = provider + deps.Plugin = plugin + deps.Events = events + deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) { + decisions = append(decisions, decision) + }) + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + deps.Evidence = EvidenceSelectorFunc(func(_ context.Context, blocks []string) (*string, error) { + // Evidence is harvested from the summarized head only: the goal and + // the first reply, never the verbatim tail. + if len(blocks) != 2 || blocks[0] != "Fix src/a.ts" || blocks[1] != "Reading src/a.ts first." { + t.Fatalf("evidence blocks = %#v", blocks) + } + value := "EVIDENCE" + return &value, nil + }) + deps.ChangedFiles = func(context.Context) []string { + return []string{" src/a.ts | 2 +-", "?? notes.txt"} + } + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, request SummaryRequest) (steploop.Result, error) { + if !plugin.transformed { + t.Fatal("message transform did not run before processor") + } + prompt := promptOf(t, request) + if !stringsContainsAll( + prompt, SummaryTemplate, "PLUGIN CONTEXT", + "[User]: Fix src/a.ts", "[Assistant]: Reading src/a.ts first.", + ) { + t.Fatalf("summary prompt = %q", prompt) + } + if strings.Contains(prompt, "The edit is in place.") { + t.Fatalf("the verbatim tail was summarized too: %q", prompt) + } + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + if err := store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: "summary_part", SessionID: "ses_1", MessageID: assistant.ID, + }, + Text: testValidSummary("Fix src/a.ts"), + }); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultContinue, nil + }, + }, nil + }) + service := NewService(deps) + result, err := service.Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if plugin.autoCalls != 1 || provider.providerCalls != 1 || + plugin.autoInput.Agent != "coder" { + t.Fatalf("auto plugin/provider calls = %d/%d input=%#v", plugin.autoCalls, provider.providerCalls, plugin.autoInput) + } + if len(events.ended) != 1 || + !stringsContainsAll( + events.ended[0], + "ses_1:a1:", // the tail starts at the newest message + testValidSummary("Fix src/a.ts"), + "# AUTHORITATIVE TASK (verbatim — durable, not generated)", + "Fix src/a.ts", "EVIDENCE", + "# CHANGED FILES (computed by senior-dev at this compaction, not generated)", + "src/a.ts | 2 +-", "?? notes.txt", + ) || len(events.published) != 1 { + t.Fatalf("events = %#v %#v", events.ended, events.published) + } + if len(decisions) != 1 || decisions[0].Status != compactionStatusTarget || + decisions[0].After > decisions[0].Low || decisions[0].DroppedTail || + decisions[0].SummaryStatus != "valid" || decisions[0].SummaryError != "" { + t.Fatalf("compaction decisions = %#v", decisions) + } + if d := decisions[0]; d.TranscriptMessages != 2 || d.TailMessages != 1 || + d.TranscriptChars == 0 || d.PromptChars <= d.TranscriptChars || d.TailTokens == 0 { + t.Fatalf("decision sizes = %#v", d) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + tailPart := fresh[3].Parts[0].(msgmodel.CompactionPart) + if tailPart.TailStartID == nil || *tailPart.TailStartID != "a1" { + t.Fatalf("tail start = %#v", tailPart) + } + last := fresh[len(fresh)-1] + autoUser, ok := last.Info.(msgmodel.User) + if !ok || autoUser.Agent != "coder" || len(last.Parts) != 1 { + t.Fatalf("auto continuation = %#v", last) + } + autoPart := last.Parts[0].(msgmodel.TextPart) + if autoPart.Text != autoContinueText(false) || + string(autoPart.Metadata) != `{"compaction_continue":true}` || + autoPart.Synthetic == nil || !*autoPart.Synthetic { + t.Fatalf("auto part = %#v", autoPart) + } +} + +func TestEnforceWatermarksRebuildsWhenFirstProjectionLacksHeadroom(t *testing.T) { + tail := "u0" + compactionPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, TailStartID: &tail, + } + parent := testUser("uc", compactionPart) + finish := "stop" + summaryFlag := true + summary := testAssistant( + "as", "uc", + textPart("as", testValidSummary("Fix src/a.ts")), + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "pin", SessionID: "ses_1", MessageID: "as"}, + Text: BuildAuthoritativeTaskPin("Fix src/a.ts", "original user request"), + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"authoritative_task"}`), + }, + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "evidence", SessionID: "ses_1", MessageID: "as"}, + Text: "large non-authoritative evidence", Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"evidence"}`), + }, + ) + assistant := summary.Info.(msgmodel.Assistant) + assistant.Summary = &summaryFlag + assistant.Finish = &finish + summary.Info = assistant + store := &memoryStore{messages: []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), parent, summary, + }} + // Stage one only: dropping the tail brings the projection under the + // watermark, so the VALID summary and its evidence are kept. + sizes := []float64{58_000, 20_000} + deps := baseDeps(store) + deps.Sizer = ContextSizerFunc(func( + context.Context, []msgmodel.WithParts, Model, + ) (float64, error) { + value := sizes[0] + sizes = sizes[1:] + return value, nil + }) + decision, err := NewService(deps).enforceWatermarks( + context.Background(), "ses_1", 70_000, serviceModel(), watermarkTestConfig(), + &compactionPart, "original user request", + ) + if err != nil { + t.Fatal(err) + } + if decision.Status != compactionStatusRebuilt || !decision.DroppedTail || decision.StubbedSummary || + decision.After != 20_000 || len(sizes) != 0 { + t.Fatalf("decision = %#v; remaining sizes = %#v", decision, sizes) + } + fresh, err := store.Messages(context.Background(), "ses_1") + if err != nil { + t.Fatal(err) + } + updatedParent := fresh[1].Parts[0].(msgmodel.CompactionPart) + if updatedParent.TailStartID != nil { + t.Fatalf("retained tail survived the rebuild: %#v", updatedParent) + } + generated := generatedSummaryText(fresh[2]) + if generated == nil || !strings.Contains(*generated, "Fix src/a.ts") || + strings.Contains(*generated, "retained history did not fit") { + t.Fatalf("the valid summary did not survive a tail-only rebuild: %v", generated) + } +} + +// watermarkTestConfig caps the capacity at 100K so the watermarks the +// enforcement tests reason about are high 60,000 / low 40,000. +func watermarkTestConfig() overflow.Config { + capacity := 100_000.0 + return overflow.Config{Compaction: &overflow.CompactionConfig{CapacityTokens: &capacity}} +} + +// Stage two: when the summary block alone still does not fit, it is replaced +// by the capacity stub and the evidence is tombstoned. +func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T) { + tail := "u0" + compactionPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, TailStartID: &tail, + } + parent := testUser("uc", compactionPart) + finish := "stop" + summaryFlag := true + summary := testAssistant( + "as", "uc", + textPart("as", testValidSummary("Fix src/a.ts")), + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "pin", SessionID: "ses_1", MessageID: "as"}, + Text: BuildAuthoritativeTaskPin("Fix src/a.ts", "original user request"), + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"authoritative_task"}`), + }, + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "evidence", SessionID: "ses_1", MessageID: "as"}, + Text: "large non-authoritative evidence", Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"evidence"}`), + }, + ) + assistant := summary.Info.(msgmodel.Assistant) + assistant.Summary = &summaryFlag + assistant.Finish = &finish + summary.Info = assistant + store := &memoryStore{messages: []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), parent, summary, + }} + sizes := []float64{90_000, 70_000, 20_000} + deps := baseDeps(store) + deps.Sizer = ContextSizerFunc(func( + context.Context, []msgmodel.WithParts, Model, + ) (float64, error) { + value := sizes[0] + sizes = sizes[1:] + return value, nil + }) + decision, err := NewService(deps).enforceWatermarks( + context.Background(), "ses_1", 95_000, serviceModel(), watermarkTestConfig(), + &compactionPart, "original user request", + ) + if err != nil { + t.Fatal(err) + } + if decision.Status != compactionStatusRebuilt || !decision.DroppedTail || !decision.StubbedSummary || + decision.After != 20_000 || len(sizes) != 0 { + t.Fatalf("decision = %#v; remaining sizes = %#v", decision, sizes) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + generated := generatedSummaryText(fresh[2]) + if generated == nil || !strings.Contains(*generated, "retained history did not fit") { + t.Fatalf("generated fallback = %v", generated) + } + evidenceRemoved := false + for _, raw := range fresh[2].Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || part.ID != "evidence" { + continue + } + if part.Ignored == nil || !*part.Ignored || part.Text != "" { + t.Fatalf("evidence survived deterministic rebuild: %#v", part) + } + evidenceRemoved = true + } + if !evidenceRemoved { + t.Fatal("deterministic rebuild did not retain an ignored evidence tombstone") + } +} + +func TestProcessReplacesInvalidSummaryBeforeActivatingBoundary(t *testing.T) { + cases := []struct { + name string + wantStatus string + wantText string + parts func(assistant *msgmodel.Assistant) msgmodel.Parts + }{ + { + name: "empty", + wantStatus: "fallback", + parts: func(*msgmodel.Assistant) msgmodel.Parts { return nil }, + }, + { + name: "malformed text", + wantStatus: "fallback", + parts: func(assistant *msgmodel.Assistant) msgmodel.Parts { + return msgmodel.Parts{textPart(assistant.ID, "I will inspect src/a.ts next.")} + }, + }, + { + name: "tool shaped", + wantStatus: "fallback", + parts: func(assistant *msgmodel.Assistant) msgmodel.Parts { + return msgmodel.Parts{msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ + ID: "bad_tool", SessionID: "ses_1", MessageID: assistant.ID, + }, + CallID: "call_1", Tool: "bash", State: msgmodel.PendingToolState(), + }} + }, + }, + { + name: "off-format state", + wantStatus: "normalized", + wantText: "cargo build: error: could not compile", + parts: func(assistant *msgmodel.Assistant) msgmodel.Parts { + return msgmodel.Parts{textPart( + assistant.ID, "## Summary of Changes\n- cargo build: error: could not compile", + )} + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + workspace := t.TempDir() + if err := os.MkdirAll(filepath.Join(workspace, ".senior-dev"), 0o755); err != nil { + t.Fatal(err) + } + const spec = "Fix src/a.ts exactly as requested.\nPreserve public behavior." + if err := os.WriteFile( + filepath.Join(workspace, ".senior-dev", "spec.md"), []byte(spec), 0o600, + ); err != nil { + t.Fatal(err) + } + deps := baseDeps(store) + var decisions []CompactionDecision + deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) { + decisions = append(decisions, decision) + }) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Instance = InstanceContext{Directory: workspace, Worktree: workspace} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, _ SummaryRequest) (steploop.Result, error) { + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + for _, part := range tc.parts(assistant) { + if err := store.UpdatePart(ctx, part); err != nil { + return steploop.ResultStop, err + } + } + return steploop.ResultContinue, nil + }, + }, nil + }) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: false, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + fresh, err := store.Messages(context.Background(), "ses_1") + if err != nil { + t.Fatal(err) + } + prior := completedCompactions(fresh) + if len(prior) != 1 || prior[0].Summary == nil { + t.Fatalf("completed compactions = %#v", prior) + } + if err := ValidateSummaryText(*prior[0].Summary); err != nil { + t.Fatalf("fallback summary is invalid: %v\n%s", err, *prior[0].Summary) + } + accepted := fresh[prior[0].AssistantIndex] + for _, part := range accepted.Parts { + if _, ok := part.(msgmodel.ToolPart); ok { + t.Fatalf("tool-shaped summary part survived fallback: %#v", accepted.Parts) + } + } + full := summaryText(accepted) + if len(decisions) != 1 || decisions[0].SummaryStatus != tc.wantStatus { + t.Fatalf("compaction decisions = %#v", decisions) + } + if full == nil || !stringsContainsAll(*full, spec, "# AUTHORITATIVE TASK") { + t.Fatalf("authoritative fallback = %v", full) + } + if tc.wantText != "" { + if !strings.Contains(*full, tc.wantText) { + t.Fatalf("normalized state was lost: %s", *full) + } + } else if !strings.Contains(*full, "no state record could be generated at this boundary") { + t.Fatalf("deterministic record missing: %s", *full) + } + }) + } +} + +// A summary request that itself overflows the model is one more way of having +// no generated summary: the deterministic record is installed and the run goes +// on with its tail intact. +func TestProcessSummaryOverflowInstallsDeterministicRecordAndContinues(t *testing.T) { + imageName := "large.png" + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", textPart("a0", "reply")), + testUser("u1", msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "img", SessionID: "ses_1", MessageID: "u1"}, + Mime: "image/png", Filename: &imageName, URL: "data:image/png;base64,AA", + }), + } + parentPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + } + parent := testUser("uc", parentPart) + messages = append(messages, parent) + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + deps := baseDeps(store) + var decisions []CompactionDecision + deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) { + decisions = append(decisions, decision) + }) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(context.Context, SummaryRequest) (steploop.Result, error) { + return steploop.ResultCompact, nil + }, + }, nil + }) + overflowed := true + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", + Auto: true, Overflow: &overflowed, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + prior := completedCompactions(fresh) + if len(prior) != 1 || prior[0].Summary == nil || + !strings.Contains(*prior[0].Summary, "no state record could be generated") { + t.Fatalf("completed compactions = %#v", prior) + } + if len(decisions) != 1 || decisions[0].SummaryStatus != "overflow" || + !strings.Contains(decisions[0].SummaryError, "exceeded the model context") { + t.Fatalf("decisions = %#v", decisions) + } +} + +func TestProcessOverflowReplayReplacesMediaAndSkipsAutoContinue(t *testing.T) { + name := "big.pdf" + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", textPart("a0", "reply")), + testUser("u1", + textPart("u1", "inspect"), + msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "pdf", SessionID: "ses_1", MessageID: "u1"}, + Mime: "application/pdf", Filename: &name, URL: "data:application/pdf;base64,AA", + }, + ), + } + parentPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + } + messages = append(messages, testUser("uc", parentPart)) + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + plugin := &fakePlugin{auto: true} + deps := baseDeps(store) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Plugin = plugin + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, _ SummaryRequest) (steploop.Result, error) { + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultContinue, nil + }, + }, nil + }) + overflowed := true + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", + Auto: true, Overflow: &overflowed, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if plugin.autoCalls != 0 { + t.Fatalf("autocontinue plugin called for replay: %d", plugin.autoCalls) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + last := fresh[len(fresh)-1] + if _, ok := last.Info.(msgmodel.User); !ok || len(last.Parts) != 2 { + t.Fatalf("replay = %#v", last) + } + if got := last.Parts[1].(msgmodel.TextPart).Text; got != "[Attached application/pdf: big.pdf]" { + t.Fatalf("media placeholder = %q", got) + } +} + +func TestPruneThresholdProtectedToolAndConfigDisable(t *testing.T) { + big := strings.Repeat("x", 260_000) // 65k estimated tokens + bash := completedToolPart("bash_part", "a0", "bash", big) + skill := completedToolPart("skill_part", "a0", "skill", big) + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", bash, skill), + testUser("u1", textPart("u1", "next")), + testAssistant("a1", "u1", textPart("a1", "reply")), + testUser("u2", textPart("u2", "latest")), + } + store := &memoryStore{messages: messages} + deps := baseDeps(store) + service := NewService(deps) + if err := service.Prune(context.Background(), "ses_1"); err != nil { + t.Fatal(err) + } + old := store.find("a0") + bashState := old.Parts[0].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted) + skillState := old.Parts[1].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted) + if bashState.Time.Compacted == nil || skillState.Time.Compacted != nil { + t.Fatalf("prune states bash=%#v skill=%#v", bashState.Time, skillState.Time) + } + + disabled := false + deps.Config = ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{Prune: &disabled}}, nil + }) + store.updates = nil + if err := NewService(deps).Prune(context.Background(), "ses_1"); err != nil { + t.Fatal(err) + } + if len(store.updates) != 0 { + t.Fatalf("disabled prune updates = %#v", store.updates) + } +} + +func TestPruneNotFoundDegradesOnlyThatError(t *testing.T) { + store := &memoryStore{err: fmt.Errorf("%w: missing", msgmodel.ErrNotFound)} + deps := baseDeps(store) + if err := NewService(deps).Prune(context.Background(), "missing"); err != nil { + t.Fatalf("not-found prune = %v", err) + } + store.err = errors.New("database failed") + if err := NewService(deps).Prune(context.Background(), "ses"); err == nil || + err.Error() != "database failed" { + t.Fatalf("other error = %v", err) + } +} + +func TestCreatePersistsCompactionAndStartedEvent(t *testing.T) { + store := &memoryStore{} + events := &fakeEvents{} + deps := baseDeps(store) + deps.Events = events + overflowed := true + err := NewService(deps).Create(context.Background(), CreateInput{ + SessionID: "ses_1", Agent: "coder", + Model: ModelRef{ProviderID: "openrouter", ModelID: "m"}, + Auto: true, Overflow: &overflowed, + }) + if err != nil { + t.Fatal(err) + } + if len(store.messages) != 1 || len(store.messages[0].Parts) != 1 { + t.Fatalf("created state = %#v", store.messages) + } + part := store.messages[0].Parts[0].(msgmodel.CompactionPart) + if !part.Auto || part.Overflow == nil || !*part.Overflow { + t.Fatalf("part = %#v", part) + } + if len(events.started) != 1 || events.started[0] != "ses_1:auto" { + t.Fatalf("started events = %#v", events.started) + } +} + +func completedToolPart(id, messageID, tool, output string) msgmodel.ToolPart { + return msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: id, SessionID: "ses_1", MessageID: messageID}, + CallID: "call_" + id, Tool: tool, + State: msgmodel.ToolStateCompleted{ + Input: msgmodel.RawObject("{}"), Output: output, Title: tool, + Metadata: msgmodel.RawObject("{}"), + Time: msgmodel.ToolTimeCompleted{Start: 1, End: 2}, + }, + } +} + +// A run that loses the specification at a compaction boundary will later +// report that no task was given. The spec must therefore be pinned beside +// every summary, not only beside a rejected one. +// +// TestProcessContinueInjectsSummaryEvidenceAutoContinueAndEvents already covers +// the pin on a valid summary, and TestProcessReplacesInvalidSummaryBefore- +// ActivatingBoundary covers .senior-dev/spec.md as its source. Neither covers the +// combination that actually occurs in a long run: a summary that VALIDATES, a +// spec on disk, and an original request that is no longer recoverable from +// the message list. Without this, moving the pin into the invalid-summary +// branch would leave every test green and silently restore the defect. +func TestProcessPinsSpecOnValidSummaryWhenTheRequestIsUnrecoverable(t *testing.T) { + messages := compactionConversation("coder") + // The surviving user message says something the spec does not, standing in + // for a first message compaction has already rewritten past recognition. + // If the pin ever sources from here instead of the file, the assertions + // below say so by name rather than by a missing substring. + stale := "Fix src/a.ts" + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + + workspace := t.TempDir() + if err := os.MkdirAll(filepath.Join(workspace, ".senior-dev"), 0o755); err != nil { + t.Fatal(err) + } + const spec = "Emit a sorted manifest of every record, ordered by source path.\nInclude derived records in the output." + if err := os.WriteFile( + filepath.Join(workspace, ".senior-dev", "spec.md"), []byte(spec), 0o600, + ); err != nil { + t.Fatal(err) + } + + deps := baseDeps(store) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Instance = InstanceContext{Directory: workspace, Worktree: workspace} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, _ SummaryRequest) (steploop.Result, error) { + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + // A summary that passes ValidateSummary, so the fallback path + // is not what installs the pin. + if err := store.UpdatePart( + ctx, textPart(assistant.ID, testValidSummary("continue the refactor")), + ); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultContinue, nil + }, + }, nil + }) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: false, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + + fresh, err := store.Messages(context.Background(), "ses_1") + if err != nil { + t.Fatal(err) + } + prior := completedCompactions(fresh) + if len(prior) != 1 || prior[0].Summary == nil { + t.Fatalf("completed compactions = %#v", prior) + } + // The generated summary was accepted on its own merits: if this run had + // gone down the fallback path the pin would prove nothing about the path + // a real run takes. + if strings.Contains(*prior[0].Summary, "generated summary failed validation") { + t.Fatalf("valid summary was replaced by the fallback:\n%s", *prior[0].Summary) + } + if !strings.Contains(*prior[0].Summary, "continue the refactor") { + t.Fatalf("generated summary was not preserved:\n%s", *prior[0].Summary) + } + + accepted := fresh[prior[0].AssistantIndex] + var pin *msgmodel.TextPart + for _, raw := range accepted.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || !strings.Contains(string(part.Metadata), `"authoritative_task"`) { + continue + } + pin = &part + } + if pin == nil { + t.Fatal("no authoritative-task pin was installed beside a VALID summary") + } + if !stringsContainsAll( + pin.Text, spec, "# AUTHORITATIVE TASK", "Source: .senior-dev/spec.md", + ) { + t.Fatalf("pin did not carry the spec verbatim from disk:\n%s", pin.Text) + } + if strings.Contains(pin.Text, stale) { + t.Fatalf("pin sourced the stale user message instead of the spec:\n%s", pin.Text) + } + if pin.Synthetic == nil || !*pin.Synthetic { + t.Fatalf("pin must be synthetic so it is not mistaken for the summary: %#v", pin) + } +} diff --git a/internal/seniordev/session/compaction/surgery.go b/internal/seniordev/session/compaction/surgery.go new file mode 100644 index 000000000..23101c412 --- /dev/null +++ b/internal/seniordev/session/compaction/surgery.go @@ -0,0 +1,124 @@ +//go:build !windows + +// Post-compaction message surgery: the overflow history selection, the replay +// of the message that overflowed, and the auto-continue text. These helpers +// are pure apart from the injected ID factory. +package compaction + +import ( + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +type Replay struct { + Info msgmodel.User + Parts msgmodel.Parts +} + +type OverflowHistory struct { + Messages []msgmodel.WithParts + Replay *Replay +} + +func selectOverflowHistory( + messages []msgmodel.WithParts, parentID string, overflow bool, +) OverflowHistory { + if !overflow { + return OverflowHistory{Messages: messages} + } + index := -1 + for i, message := range messages { + if message.Info.MessageID() == parentID { + index = i + break + } + } + var replay *Replay + selected := messages + for i := index - 1; i >= 0; i-- { + user, ok := messages[i].Info.(msgmodel.User) + if ok && !hasCompaction(messages[i].Parts) { + replay = &Replay{Info: user, Parts: messages[i].Parts} + selected = messages[:i] + break + } + } + hasContent := false + if replay != nil { + for _, message := range selected { + if _, ok := message.Info.(msgmodel.User); ok && !hasCompaction(message.Parts) { + hasContent = true + break + } + } + } + if !hasContent { + return OverflowHistory{Messages: messages} + } + return OverflowHistory{Messages: selected, Replay: replay} +} + +func buildReplayParts( + replay Replay, sessionID, messageID string, newID func(prefix string) string, +) msgmodel.Parts { + out := msgmodel.Parts{} + for _, raw := range replay.Parts { + if _, ok := raw.(msgmodel.CompactionPart); ok { + continue + } + base := msgmodel.PartBase{ + ID: newID("part"), MessageID: messageID, SessionID: sessionID, + } + if file, ok := raw.(msgmodel.FilePart); ok && msgmodel.IsMedia(file.Mime) { + filename := "file" + if file.Filename != nil { + filename = *file.Filename + } + out = append(out, msgmodel.TextPart{ + PartBase: base, + Text: "[Attached " + file.Mime + ": " + filename + "]", + }) + continue + } + out = append(out, rebasePart(raw, base)) + } + return out +} + +func autoContinueText(overflow bool) string { + prefix := "" + if overflow { + prefix = "The previous request exceeded the provider's size limit due to large media attachments. " + + "The conversation was compacted and media files were removed from context. If the user was asking " + + "about attached images or files, explain that the attachments were too large to process and suggest " + + "they try again with smaller or fewer files.\n\n" + } + return prefix + "The conversation was compacted: the state record above replaces the older transcript, and the most recent messages are retained verbatim. Continue from the current state." +} + +func rebasePart(raw msgmodel.Part, base msgmodel.PartBase) msgmodel.Part { + switch part := raw.(type) { + case msgmodel.TextPart: + part.PartBase = base + return part + case msgmodel.ReasoningPart: + part.PartBase = base + return part + case msgmodel.FilePart: + part.PartBase = base + return part + case msgmodel.ToolPart: + part.PartBase = base + return part + case msgmodel.StepStartPart: + part.PartBase = base + return part + case msgmodel.StepFinishPart: + part.PartBase = base + return part + case msgmodel.CompactionPart: + part.PartBase = base + return part + default: + panic("compaction: unknown part type " + raw.PartType()) + } +} diff --git a/internal/seniordev/session/compaction/tail.go b/internal/seniordev/session/compaction/tail.go new file mode 100644 index 000000000..96747081d --- /dev/null +++ b/internal/seniordev/session/compaction/tail.go @@ -0,0 +1,198 @@ +//go:build !windows + +package compaction + +import ( + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// The verbatim tail a compaction keeps ahead of the summary. +// +// The newest messages are kept verbatim at every boundary and only what lies +// before them is summarized. Old tool outputs INSIDE the tail are truncated in +// the store, so a tail always fits: measured on untruncated multi-kilobyte +// command outputs, one assistant message could cost more than the whole budget +// and no split point would ever be found. +// +// Reasoning is cut too, in every kept message including the newest. The +// same-model projection re-sends every reasoning part with its provider +// metadata, and a single message can carry far more reasoning than tool +// traffic. Reasoning is the model's scratch, not state; signed reasoning +// (Anthropic) is left alone because the provider validates it byte for byte. +const ( + // DefaultPreserveRecentFraction sizes the token budget for the truncated + // older messages of the tail as a fraction of the high watermark, so the + // tail grows with the budget. The newest message is kept on top of it. + DefaultPreserveRecentFraction = 0.2 + // TailToolOutputMaxChars caps each completed tool output of an OLDER tail + // message. Head/tail truncation keeps the start and, mostly, the end, so + // an error at the bottom of a test run survives. + TailToolOutputMaxChars = 4_000 + // TailReasoningMaxChars caps each unsigned reasoning part of EVERY tail + // message to its head. + TailReasoningMaxChars = 1_000 +) + +// tailSelection is the outcome of selectTail: where the tail starts, what it +// costs, and the parts that must be rewritten in the store so the kept +// messages really are the size they were measured at. +type tailSelection struct { + // StartID names the first kept message; nil only when there are no + // messages at all. When everything fits it is the first message, and the + // head is empty. + StartID *string + // Head is everything before the tail: the messages to summarize. + Head []msgmodel.WithParts + // Messages counts kept messages and Tokens is their estimated size after + // truncation. Truncated lists the rewritten parts; TruncatedOutputs and + // TruncatedReasoning count them by kind. + Messages int + Tokens float64 + Truncated []msgmodel.Part + TruncatedOutputs int + TruncatedReasoning int +} + +// selectTail walks backwards from the newest message. The newest message is +// always kept: it is the observation the model has not yet acted on (the +// trigger fires when the turn that produced it finishes), so its tool outputs +// stay whole and only its reasoning is cut. Older messages are kept, oldest +// cut first, while their truncated size fits `budget`. +func selectTail( + messages []msgmodel.WithParts, + budget float64, + model Model, + estimate EstimateFunc, + maxToolChars float64, +) (tailSelection, error) { + if len(messages) == 0 { + return tailSelection{}, nil + } + newest := len(messages) - 1 + newestCopy, cuts := truncateForTail(messages[newest], 0, TailReasoningMaxChars) + total, err := estimate([]msgmodel.WithParts{newestCopy}, model) + if err != nil { + return tailSelection{}, err + } + selection := tailSelection{Messages: 1, Tokens: total} + selection.absorb(cuts) + start := newest + older := float64(0) + var olderCuts []msgmodel.Part + for i := newest - 1; i >= 0; i-- { + copy, cuts := truncateForTail(messages[i], maxToolChars, TailReasoningMaxChars) + size, err := estimate([]msgmodel.WithParts{copy}, model) + if err != nil { + return tailSelection{}, err + } + if older+size > budget { + break + } + older += size + start = i + olderCuts = append(cuts, olderCuts...) + } + selection.absorb(olderCuts) + // Everything fitting (start == 0) means there is no head to summarize, + // but the tail must STILL be named: FilterCompacted keeps only what a + // compaction's tail_start_id points at, and a boundary without one keeps + // nothing before it: a "no-head" boundary would otherwise project only the + // summary and lose the tail it meant to keep. + id := messages[start].Info.MessageID() + selection.StartID = &id + selection.Head = messages[:start] + selection.Messages = len(messages) - start + selection.Tokens = total + older + return selection, nil +} + +func (selection *tailSelection) absorb(parts []msgmodel.Part) { + for _, part := range parts { + switch part.(type) { + case msgmodel.ToolPart: + selection.TruncatedOutputs++ + case msgmodel.ReasoningPart: + selection.TruncatedReasoning++ + } + } + selection.Truncated = append(selection.Truncated, parts...) +} + +// truncateForTail returns a copy of the message whose completed tool outputs +// longer than maxToolChars (0 = leave them) and whose unsigned reasoning parts +// longer than maxReasoningChars are cut, plus the rewritten parts. The copy is +// what the tail will cost; the parts are what the store must be told. +func truncateForTail( + message msgmodel.WithParts, maxToolChars, maxReasoningChars float64, +) (msgmodel.WithParts, []msgmodel.Part) { + out := message + out.Parts = append(msgmodel.Parts(nil), message.Parts...) + var cuts []msgmodel.Part + for index, raw := range out.Parts { + switch part := raw.(type) { + case msgmodel.ToolPart: + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if !ok || maxToolChars <= 0 || float64(charCount(completed.Output)) <= maxToolChars { + continue + } + completed.Output = truncateTailOutput(completed.Output, maxToolChars) + part.State = completed + out.Parts[index] = part + cuts = append(cuts, part) + case msgmodel.ReasoningPart: + if maxReasoningChars <= 0 || signedReasoning(part) || + float64(charCount(part.Text)) <= maxReasoningChars { + continue + } + part.Text = truncateTailReasoning(part.Text, maxReasoningChars) + out.Parts[index] = part + cuts = append(cuts, part) + } + } + return out, cuts +} + +// signedReasoning reports provider-signed reasoning (Anthropic's signature +// field), which must reach the provider unchanged. +func signedReasoning(part msgmodel.ReasoningPart) bool { + anthropic, ok := part.Metadata.Field("anthropic") + if !ok { + return false + } + signature, ok := msgmodel.RawObject(anthropic).Field("signature") + return ok && strings.TrimSpace(string(signature)) != "null" && len(signature) > 0 +} + +// truncateTailOutput keeps a quarter of the cap from the start and the rest +// from the end, and says so in words the model can act on. +func truncateTailOutput(text string, maxChars float64) string { + length := float64(charCount(text)) + if maxChars <= 0 || length <= maxChars { + return text + } + headChars := int(maxChars / 4) + tailChars := int(maxChars) - headChars + head := sliceChars(text, 0, headChars) + tail := sliceChars(text, charCount(text)-tailChars, charCount(text)) + omitted := length - float64(headChars) - float64(tailChars) + return strings.Join([]string{ + head, + "[Tool output truncated at a context compaction: omitted " + + strconv.FormatFloat(omitted, 'f', -1, 64) + " chars. Re-run the command if you need the full output.]", + tail, + }, "\n") +} + +// truncateTailReasoning keeps the head of a reasoning part. +func truncateTailReasoning(text string, maxChars float64) string { + length := float64(charCount(text)) + if maxChars <= 0 || length <= maxChars { + return text + } + head := sliceChars(text, 0, int(maxChars)) + return head + "\n[Reasoning truncated at a context compaction: omitted " + + strconv.FormatFloat(length-maxChars, 'f', -1, 64) + " chars.]" +} diff --git a/internal/seniordev/session/compaction/tail_test.go b/internal/seniordev/session/compaction/tail_test.go new file mode 100644 index 000000000..ed79c0e98 --- /dev/null +++ b/internal/seniordev/session/compaction/tail_test.go @@ -0,0 +1,180 @@ +//go:build !windows + +package compaction + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// charEstimate sizes messages by their text and tool output characters so the +// tests can reason in exact numbers. +func charEstimate(messages []msgmodel.WithParts, _ Model) (float64, error) { + total := 0 + for _, message := range messages { + for _, raw := range message.Parts { + switch part := raw.(type) { + case msgmodel.TextPart: + total += len(part.Text) + case msgmodel.ToolPart: + if completed, ok := part.State.(msgmodel.ToolStateCompleted); ok { + total += len(completed.Output) + } + } + } + } + return float64(total), nil +} + +func TestSelectTailKeepsNewestVerbatimEvenOverBudget(t *testing.T) { + huge := strings.Repeat("x", 50_000) + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "goal")), + testAssistant("a0", "u0", textPart("a0", "older reply")), + testAssistant("a1", "u0", toolPartCompleted("a1", "c1", "bash", `{"cmd":"cat"}`, huge)), + } + // A budget too small for even "older reply": only the newest message is + // kept, and it is kept whole despite being 500x the budget. + selected, err := selectTail(messages, 5, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + if selected.StartID == nil || *selected.StartID != "a1" || len(selected.Head) != 2 { + t.Fatalf("selection = %#v", selected) + } + if selected.Messages != 1 || selected.Tokens != 50_000 || len(selected.Truncated) != 0 { + t.Fatalf("the newest message must be kept whole and uncut: %#v", selected) + } +} + +func TestSelectTailTruncatesOlderToolOutputsAndMeasuresAfterwards(t *testing.T) { + big := strings.Repeat("y", 10_000) + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "goal")), + testAssistant("a0", "u0", textPart("a0", strings.Repeat("f", 2_000))), + testAssistant("a1", "u0", toolPartCompleted("a1", "c1", "bash", `{}`, big)), + testAssistant("a2", "u0", toolPartCompleted("a2", "c2", "bash", `{}`, big)), + testAssistant("a3", "u0", textPart("a3", "newest")), + } + // Each truncated output costs 4,000 chars plus a marker; a budget of + // 9,000 fits both older tool messages after truncation but neither + // before it, and stops short of the 2,000-char reply before them. + selected, err := selectTail(messages, 9_000, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + if selected.StartID == nil || *selected.StartID != "a1" || len(selected.Head) != 2 { + t.Fatalf("selection = %#v", selected) + } + if selected.Messages != 3 || len(selected.Truncated) != 2 || selected.TruncatedOutputs != 2 { + t.Fatalf("tail accounting = %#v", selected) + } + if selected.Tokens >= 20_000 || selected.Tokens < 8_000 { + t.Fatalf("tail measured before truncation: %v", selected.Tokens) + } + for _, raw := range selected.Truncated { + part := raw.(msgmodel.ToolPart) + output := part.State.(msgmodel.ToolStateCompleted).Output + if !strings.Contains(output, "truncated at a context compaction") || + !strings.HasPrefix(output, strings.Repeat("y", 1_000)) || + !strings.HasSuffix(output, strings.Repeat("y", 3_000)) { + t.Fatalf("truncated output = %q", output[:80]) + } + } + // The caller's messages are untouched: truncation is reported, not + // applied in place. + original := messages[2].Parts[0].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted).Output + if original != big { + t.Fatal("selectTail mutated the input messages") + } +} + +func TestSelectTailWithEverythingFittingHasNoHead(t *testing.T) { + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "goal")), + testAssistant("a0", "u0", textPart("a0", "reply")), + } + selected, err := selectTail(messages, 1_000, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + // No head, but the tail is still NAMED from the first message: a boundary + // whose compaction part carries no tail_start_id keeps nothing before it. + if selected.StartID == nil || *selected.StartID != "u0" || + len(selected.Head) != 0 || selected.Messages != 2 { + t.Fatalf("selection = %#v", selected) + } + empty, err := selectTail(nil, 1_000, Model{}, charEstimate, 4_000) + if err != nil || empty.StartID != nil || empty.Messages != 0 { + t.Fatalf("empty selection = %#v %v", empty, err) + } +} + +func reasoningPart(messageID, id, text string, metadata string) msgmodel.ReasoningPart { + part := msgmodel.ReasoningPart{ + PartBase: msgmodel.PartBase{ID: id, SessionID: "ses_1", MessageID: messageID}, + Text: text, + } + if metadata != "" { + part.Metadata = msgmodel.RawObject(metadata) + } + return part +} + +// Reasoning is cut in every kept message, the newest included: a newest +// message dominated by reasoning would otherwise cost the whole tail. Signed +// reasoning is left alone. +func TestSelectTailTruncatesUnsignedReasoningEverywhereIncludingNewest(t *testing.T) { + long := strings.Repeat("thinking ", 2_000) // 18,000 chars + messages := []msgmodel.WithParts{ + // A 5,000-char request that cannot fit the 3,000 budget, so it forms + // the head and a0 is the first kept message. + testUser("u0", textPart("u0", strings.Repeat("g", 5_000))), + testAssistant("a0", "u0", reasoningPart("a0", "r0", long, ""), textPart("a0", "older")), + testAssistant("a1", "u0", + reasoningPart("a1", "r1", long, `{"anthropic":{"signature":"sig"}}`), + reasoningPart("a1", "r2", long, ""), + toolPartCompleted("a1", "c1", "bash", `{}`, strings.Repeat("o", 9_000)), + ), + } + selected, err := selectTail(messages, 3_000, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + if selected.StartID == nil || *selected.StartID != "a0" || selected.TruncatedReasoning != 2 || + selected.TruncatedOutputs != 0 { + t.Fatalf("selection = %#v", selected) + } + // newest: unsigned r2 cut, signed r1 untouched, tool output whole. + // older: r0 cut; its size after the cut fits the 3,000 budget. + ids := map[string]bool{} + for _, raw := range selected.Truncated { + part := raw.(msgmodel.ReasoningPart) + ids[part.ID] = true + if !strings.HasPrefix(part.Text, "thinking ") || !strings.Contains(part.Text, "Reasoning truncated") || + len(part.Text) > 1_200 { + t.Fatalf("reasoning cut = %q", part.Text[:80]) + } + } + if !ids["r0"] || !ids["r2"] || ids["r1"] { + t.Fatalf("truncated reasoning ids = %v", ids) + } + if selected.Tokens > 1_000+18_000+9_000+200 || selected.Tokens < 9_000 { + t.Fatalf("tail tokens = %v", selected.Tokens) + } +} + +func TestTruncateTailOutputKeepsHeadAndMostlyTail(t *testing.T) { + text := strings.Repeat("h", 500) + strings.Repeat("t", 500) + out := truncateTailOutput(text, 200) + if !strings.HasPrefix(out, strings.Repeat("h", 50)+"\n") || + !strings.HasSuffix(out, strings.Repeat("t", 150)) || + !strings.Contains(out, "omitted 800 chars") { + t.Fatalf("truncated = %q", out) + } + if got := truncateTailOutput("short", 200); got != "short" { + t.Fatalf("short output altered: %q", got) + } +} diff --git a/internal/seniordev/session/compaction/transcript.go b/internal/seniordev/session/compaction/transcript.go new file mode 100644 index 000000000..2212702a1 --- /dev/null +++ b/internal/seniordev/session/compaction/transcript.go @@ -0,0 +1,123 @@ +//go:build !windows + +package compaction + +import ( + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// SerializeTranscript flattens the conversation being summarized into ONE +// plain-text block for the summary model call (the caller wraps it in +// tags). Handing the summarizer the session as a live message +// array invites the model to keep coding — leaking tool-call markup as text or +// narrating its next action — instead of serializing state. A flattened +// transcript cannot be "continued": there is no open tool call, no assistant +// turn to extend, only data to read. +// +// Each tool output is head/tail truncated (HeadTailTruncate) so the serialized +// block is bounded and, in practice, smaller than the message array it +// replaces. +func SerializeTranscript(messages []msgmodel.WithParts, maxToolChars float64) string { + lines := make([]string, 0, len(messages)) + for _, message := range messages { + switch info := message.Info.(type) { + case msgmodel.User: + if text := plainText(message.Parts); text != "" { + lines = append(lines, "[User]: "+text) + } + case msgmodel.Assistant: + if text := plainText(message.Parts); text != "" { + lines = append(lines, "[Assistant]: "+text) + } + if calls := toolCallLines(message.Parts); calls != "" { + lines = append(lines, "[Assistant tool calls]: "+calls) + } + for _, result := range toolResultLines(message.Parts, maxToolChars) { + lines = append(lines, result) + } + _ = info + } + } + return strings.Join(lines, "\n") +} + +// plainText joins the non-synthetic, non-ignored text parts of a message. +func plainText(parts msgmodel.Parts) string { + collected := make([]string, 0, len(parts)) + for _, raw := range parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) || boolPointer(part.Synthetic) { + continue + } + if text := strings.TrimSpace(part.Text); text != "" { + collected = append(collected, text) + } + } + return strings.TrimSpace(strings.Join(collected, "\n")) +} + +// toolCallLines renders "name(input); name(input)" for the tool calls in a +// message. The raw input JSON is included but capped, so a large write/edit +// payload cannot dominate the serialized transcript. +func toolCallLines(parts msgmodel.Parts) string { + calls := make([]string, 0) + for _, raw := range parts { + part, ok := raw.(msgmodel.ToolPart) + if !ok { + continue + } + input := "" + if raw := part.State.ToolInput(); len(raw) > 0 { + input = HeadTailTruncate(string(raw), toolCallInputMaxChars) + } + calls = append(calls, part.Tool+"("+input+")") + } + return strings.Join(calls, "; ") +} + +// toolResultLines renders one "[Tool result]" / "[Tool error]" line per tool +// part, truncated to the observation cap. +func toolResultLines(parts msgmodel.Parts, maxToolChars float64) []string { + lines := make([]string, 0) + for _, raw := range parts { + part, ok := raw.(msgmodel.ToolPart) + if !ok { + continue + } + switch state := part.State.(type) { + case msgmodel.ToolStateCompleted: + if output := strings.TrimSpace(state.Output); output != "" { + lines = append(lines, + "[Tool result]: "+HeadTailTruncate(state.Output, maxToolChars)) + } + case msgmodel.ToolStateError: + if errText := strings.TrimSpace(state.Error); errText != "" { + lines = append(lines, + "[Tool error]: "+HeadTailTruncate(state.Error, maxToolChars)) + } + } + } + return lines +} + +const toolCallInputMaxChars = 500 + +// CapTranscript bounds a flattened transcript to maxChars, cutting the middle +// so the beginning and the latest state both survive. It returns the number +// of characters removed so the boundary can report it. +func CapTranscript(transcript string, maxChars float64) (string, float64) { + length := float64(charCount(transcript)) + if maxChars <= 0 || length <= maxChars { + return transcript, 0 + } + headChars := int(maxChars / 4) + tailChars := int(maxChars) - headChars + head := sliceChars(transcript, 0, headChars) + tail := sliceChars(transcript, charCount(transcript)-tailChars, charCount(transcript)) + omitted := length - float64(headChars) - float64(tailChars) + return head + "\n[... transcript cut here: " + strconv.FormatFloat(omitted, 'f', -1, 64) + + " chars of the middle omitted ...]\n" + tail, omitted +} diff --git a/internal/seniordev/session/compaction/transcript_test.go b/internal/seniordev/session/compaction/transcript_test.go new file mode 100644 index 000000000..586565528 --- /dev/null +++ b/internal/seniordev/session/compaction/transcript_test.go @@ -0,0 +1,138 @@ +//go:build !windows + +package compaction + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +func toolPartCompleted(messageID, id, tool, input, output string) msgmodel.ToolPart { + return msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: id, SessionID: "ses_1", MessageID: messageID}, + CallID: id, + Tool: tool, + State: msgmodel.ToolStateCompleted{ + Input: msgmodel.RawObject(input), + Output: output, + }, + } +} + +func TestSerializeTranscriptFlattensToOneBlockWithoutMessageArray(t *testing.T) { + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), + testAssistant("a0", "u0", + textPart("a0", "Reading the file first."), + toolPartCompleted("a0", "c1", "read", `{"file":"src/a.ts"}`, "line one\nline two"), + ), + } + out := SerializeTranscript(messages, 2000) + + for _, want := range []string{ + "[User]: Fix src/a.ts", + "[Assistant]: Reading the file first.", + "[Assistant tool calls]: read(", + "[Tool result]: line one", + } { + if !strings.Contains(out, want) { + t.Fatalf("serialized transcript missing %q:\n%s", want, out) + } + } + // The whole point: a flattened string, not a replayable conversation. + if strings.Contains(out, "\"role\"") { + t.Fatalf("transcript leaked a message-array shape:\n%s", out) + } +} + +func TestSerializeTranscriptCapsToolOutput(t *testing.T) { + big := strings.Repeat("x", 10_000) + messages := []msgmodel.WithParts{ + testAssistant("a0", "u0", toolPartCompleted("a0", "c1", "bash", `{"cmd":"cat big"}`, big)), + } + out := SerializeTranscript(messages, 2000) + if len(out) > 4000 { + t.Fatalf("tool output not capped: serialized length %d", len(out)) + } + if !strings.Contains(out, "truncated") { + t.Fatalf("expected truncation marker in:\n%s", out[:200]) + } +} + +func TestSerializeTranscriptRendersToolErrors(t *testing.T) { + messages := []msgmodel.WithParts{ + testAssistant("a0", "u0", msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "c1", SessionID: "ses_1", MessageID: "a0"}, + CallID: "c1", Tool: "bash", + State: msgmodel.ToolStateError{ + Input: msgmodel.RawObject(`{"cmd":"false"}`), + Error: "exit status 1", + }, + }), + } + out := SerializeTranscript(messages, 2000) + if !strings.Contains(out, "[Tool error]: exit status 1") { + t.Fatalf("tool error not rendered:\n%s", out) + } +} + +func TestClassifySummaryFailure(t *testing.T) { + cases := []struct { + name string + msg msgmodel.WithParts + want string + }{ + { + name: "dsml markup as text", + msg: testAssistant("a", "u", textPart("a", "<|DSML|invoke name=\"bash\">")), + want: SummaryClassDSMLText, + }, + { + name: "xml tool call as text", + msg: testAssistant("a", "u", textPart("a", "read")), + want: SummaryClassDSMLText, + }, + { + name: "empty", + msg: testAssistant("a", "u", textPart("a", " ")), + want: SummaryClassEmpty, + }, + { + name: "off format prose", + msg: testAssistant("a", "u", textPart("a", "Let me continue reading the code.")), + want: SummaryClassFormat, + }, + { + name: "structural tool part", + msg: testAssistant("a", "u", + toolPartCompleted("a", "c1", "read", "{}", "ok")), + want: SummaryClassToolCall, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ClassifySummaryFailure(tc.msg); got != tc.want { + t.Fatalf("class = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCapTranscriptCutsTheMiddleAndReportsIt(t *testing.T) { + transcript := strings.Repeat("a", 1000) + strings.Repeat("z", 1000) + capped, omitted := CapTranscript(transcript, 400) + if omitted != 1600 { + t.Fatalf("omitted = %v, want 1600", omitted) + } + if !strings.HasPrefix(capped, strings.Repeat("a", 100)) || + !strings.HasSuffix(capped, strings.Repeat("z", 300)) || + !strings.Contains(capped, "transcript cut here: 1600 chars") { + t.Fatalf("capped transcript = %q", capped) + } + same, omitted := CapTranscript("short", 400) + if same != "short" || omitted != 0 { + t.Fatalf("short transcript was altered: %q %v", same, omitted) + } +} diff --git a/internal/seniordev/session/evidenceharvest/evidenceharvest.go b/internal/seniordev/session/evidenceharvest/evidenceharvest.go new file mode 100644 index 000000000..d3766500d --- /dev/null +++ b/internal/seniordev/session/evidenceharvest/evidenceharvest.go @@ -0,0 +1,423 @@ +//go:build !windows + +// Package evidenceharvest extracts the lines worth preserving from tool +// output before a compaction: command outcomes, exact error signatures and +// paths referenced across messages. A low-tier judge may pick the lines; the +// deterministic harvester is the fallback. All budgets count characters. +package evidenceharvest + +import ( + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +const ( + maxLineChars = 300 + defaultMaxChars = 4000 + maxCorpusChars = 60000 +) + +var outcomePatterns = []*regexp.Regexp{ + regexp.MustCompile(`exit(ed)?( with)?( code)? [0-9]+`), + regexp.MustCompile(`\b[0-9]+ pass(ed|ing)?\b[^\n\r\x{2028}\x{2029}]*\b[0-9]+ fail`), + regexp.MustCompile(`\b[0-9]+ fail(ed|ing|ures?)\b`), + regexp.MustCompile(`\btests? (passed|failed)\b`), +} + +var errorPatterns = []struct { + re *regexp.Regexp + fold bool +}{ + {regexp.MustCompile(`\b[A-Z][a-zA-Z]*Error\b:?`), false}, + {regexp.MustCompile(`(^|[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}])Error:[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]`), false}, + {regexp.MustCompile(`\bTraceback \(most recent call last\)`), false}, + {regexp.MustCompile(`\bpanic:[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]`), false}, + {regexp.MustCompile(`\bFAILED\b`), false}, + {regexp.MustCompile(`\bassertionerror\b|\bassert(ion)? failed\b`), true}, + {regexp.MustCompile(`\bENOENT\b|\bEACCES\b|\bECONNREFUSED\b|\bETIMEDOUT\b`), false}, + {regexp.MustCompile(`\berror TS[0-9]+\b`), false}, + {regexp.MustCompile(`\bnpm error\b|\bnpm ERR!`), false}, + // Suite-abort signatures that are lower-case or toolchain-specific, so + // the generic Error patterns above miss them. + {regexp.MustCompile(`error: could not compile|\[build failed\]`), true}, + {regexp.MustCompile(`error during collection|ERROR collecting|ImportError while loading`), false}, + {regexp.MustCompile(`Transform failed with [0-9]+ error|Exception during run`), false}, +} + +// Captures are: 1 boundary, 2 path, 3 optional relative prefix, 4 repeated +// directory segment, 5 optional line suffix. Group 2 is PATH_RE's match[1]. +var pathRE = regexp.MustCompile( + `(^|[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}"'` + "`" + `(=])` + + `((\.{0,2}/)?([A-Za-z0-9_.@-]+/)+[A-Za-z0-9_.@-]+\.[a-z]{1,10})(:[0-9]+)?`, +) + +// Message is the minimal model-message surface textBlocksOf reads. +type Message struct { + Content any `json:"content"` +} + +// EvidenceSource reports whether the low-tier judgment or deterministic +// fallback supplied SelectEvidence's content. +type EvidenceSource string + +const ( + SourceLLM EvidenceSource = "llm" + SourceFallback EvidenceSource = "fallback" +) + +// EvidenceJudgment is the narrow result SelectEvidence needs from a judge. +type EvidenceJudgment struct { + Lines []string + Source EvidenceSource +} + +// EvidenceJudge is the seam for a model-backed evidence selector. +type EvidenceJudge interface { + JudgeEvidence(prompt string, language any) EvidenceJudgment +} + +// EvidenceJudgeFunc adapts a function to EvidenceJudge. +type EvidenceJudgeFunc func(prompt string, language any) EvidenceJudgment + +// JudgeEvidence implements EvidenceJudge. +func (f EvidenceJudgeFunc) JudgeEvidence(prompt string, language any) EvidenceJudgment { + return f(prompt, language) +} + +// SelectEvidenceOptions configures SelectEvidence. Nil MaxChars selects 4000. +type SelectEvidenceOptions struct { + MaxChars *float64 + Judge EvidenceJudge +} + +// SelectedEvidence is SelectEvidence's result. Text is nil when nothing was +// selected. +type SelectedEvidence struct { + Text *string `json:"text"` + Source EvidenceSource `json:"source"` +} + +// charCount is the length of s in characters (runes). +func charCount(s string) int { return utf8.RuneCountInString(s) } + +// sliceChars returns the characters of s in [start, end), clamped to s. +func sliceChars(s string, start, end int) string { + runes := []rune(s) + start = max(0, min(start, len(runes))) + end = max(start, min(end, len(runes))) + return string(runes[start:end]) +} + +func truncate(line string) string { + trimmed := strings.TrimSpace(line) + if charCount(trimmed) > maxLineChars { + return sliceChars(trimmed, 0, maxLineChars) + "…" + } + return trimmed +} + +func asciiLower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +func isOutcome(line string) bool { + folded := asciiLower(line) + for _, re := range outcomePatterns { + if re.MatchString(folded) { + return true + } + } + return false +} + +func isError(line string) bool { + for _, pattern := range errorPatterns { + scan := line + if pattern.fold { + scan = asciiLower(scan) + } + if pattern.re.MatchString(scan) { + return true + } + } + return false +} + +type scoredLine struct { + line string + score int + block int + seq int +} + +type pathRefs struct { + path string + blocks map[int]struct{} +} + +// HarvestEvidence extracts deterministic, verbatim evidence. Nil means nothing +// qualified. +func HarvestEvidence(blocks []string, maxChars ...float64) *string { + budget := float64(defaultMaxChars) + if len(maxChars) > 0 { + budget = maxChars[0] + } + if len(blocks) == 0 || budget <= 0 { + return nil + } + + scored := []scoredLine{} + seen := map[string]int{} + pathIndex := map[string]int{} + paths := []pathRefs{} + seq := 0 + + for blockIndex, block := range blocks { + for _, raw := range strings.Split(block, "\n") { + line := truncate(raw) + if charCount(line) < 4 { + continue + } + for _, match := range pathRE.FindAllStringSubmatch(raw, -1) { + path := match[2] + index, ok := pathIndex[path] + if !ok { + index = len(paths) + pathIndex[path] = index + paths = append(paths, pathRefs{path: path, blocks: map[int]struct{}{}}) + } + paths[index].blocks[blockIndex] = struct{}{} + } + score := 0 + if isOutcome(line) { + score += 3 + } + if isError(line) { + score += 2 + } + if score == 0 { + continue + } + if index, exists := seen[line]; exists { + // Attribute repeated evidence to its newest occurrence. + scored[index].block, scored[index].seq = blockIndex, seq + seq++ + continue + } + seen[line] = len(scored) + scored = append(scored, scoredLine{ + line: line, score: score, block: blockIndex, seq: seq, + }) + seq++ + } + } + + crossFiles := []pathRefs{} + for _, item := range paths { + if len(item.blocks) >= 2 { + crossFiles = append(crossFiles, item) + } + } + sort.SliceStable(crossFiles, func(i, j int) bool { + if len(crossFiles[i].blocks) != len(crossFiles[j].blocks) { + return len(crossFiles[i].blocks) > len(crossFiles[j].blocks) + } + return crossFiles[i].path < crossFiles[j].path + }) + if len(crossFiles) > 20 { + crossFiles = crossFiles[:20] + } + + if len(scored) == 0 && len(crossFiles) == 0 { + return nil + } + + sort.SliceStable(scored, func(i, j int) bool { + if scored[i].score != scored[j].score { + return scored[i].score > scored[j].score + } + // Prefer the newest command, but retain line order within that command. + if scored[i].block != scored[j].block { + return scored[i].block > scored[j].block + } + return scored[i].seq < scored[j].seq + }) + + fileBlock := []string{} + if len(crossFiles) > 0 { + fileBlock = append(fileBlock, "### Files referenced across multiple steps") + for _, file := range crossFiles { + fileBlock = append(fileBlock, + "- "+file.path+" ("+strconv.Itoa(len(file.blocks))+" messages)") + } + } + fileChars := 0 + for _, line := range fileBlock { + fileChars += charCount(line) + 1 + } + + lines := []string{} + used := 0 + evidenceBudget := budget - float64(fileChars) + for _, entry := range scored { + if float64(used+charCount(entry.line)+3) > evidenceBudget { + continue + } + lines = append(lines, "- "+entry.line) + used += charCount(entry.line) + 3 + } + + sections := []string{"## Preserved evidence (verbatim, extracted by senior-dev)"} + if len(lines) > 0 { + sections = append(sections, "### Command outcomes & errors") + sections = append(sections, lines...) + } + sections = append(sections, fileBlock...) + if len(sections) == 1 { + return nil + } + text := strings.Join(sections, "\n") + return &text +} + +func optionMaxChars(opts *SelectEvidenceOptions) float64 { + if opts == nil || opts.MaxChars == nil { + return defaultMaxChars + } + return *opts.MaxChars +} + +func languageTruthy(language any) bool { + switch value := language.(type) { + case nil: + return false + case bool: + return value + case string: + return value != "" + case float64: + return value != 0 + case float32: + return value != 0 + case int: + return value != 0 + default: + return true + } +} + +func evidencePrompt(corpus string) string { + return strings.Join([]string{ + "The transcript region below is about to be replaced by a summary.", + "Select the LOAD-BEARING lines that must survive VERBATIM because a", + "paraphrase would lose their value: commands with their outcomes/exit", + "codes, exact error messages and signatures, and file paths central to", + "the work. Copy each selected line EXACTLY as it appears (you may", + "truncate a line after 300 characters). Skip conversational prose,", + "reasoning, and anything a summary can safely restate. Max 25 lines;", + "return an empty list if nothing qualifies.", + "", + "--- TRANSCRIPT REGION ---", + corpus, + }, "\n") +} + +// SelectEvidence renders low-tier-selected evidence or falls back to the +// deterministic regex harvester when the judge is unavailable/fails. +func SelectEvidence(blocks []string, language any, opts *SelectEvidenceOptions) SelectedEvidence { + maxChars := optionMaxChars(opts) + if len(blocks) == 0 { + return SelectedEvidence{Text: nil, Source: SourceFallback} + } + + corpus := strings.Join(blocks, "\n---\n") + if charCount(corpus) > maxCorpusChars { + corpus = sliceChars(corpus, charCount(corpus)-maxCorpusChars, charCount(corpus)) + } + + if opts == nil || opts.Judge == nil || !languageTruthy(language) { + return SelectedEvidence{Text: HarvestEvidence(blocks, maxChars), Source: SourceFallback} + } + judged := opts.Judge.JudgeEvidence(evidencePrompt(corpus), language) + if judged.Source != SourceLLM { + return SelectedEvidence{Text: HarvestEvidence(blocks, maxChars), Source: SourceFallback} + } + + lines := []string{} + used := 0 + for _, raw := range judged.Lines { + line := truncate(raw) + if float64(used+charCount(line)+3) > maxChars { + break + } + lines = append(lines, "- "+line) + used += charCount(line) + 3 + } + if len(lines) == 0 { + return SelectedEvidence{Text: nil, Source: SourceLLM} + } + text := strings.Join( + append([]string{"## Preserved evidence (verbatim, low-tier selected)"}, lines...), + "\n", + ) + return SelectedEvidence{Text: &text, Source: SourceLLM} +} + +// TextBlocksOf flattens string content and typed text/output parts. +func TextBlocksOf(messages []Message) []string { + blocks := []string{} + for _, message := range messages { + if content, ok := message.Content.(string); ok { + if strings.TrimSpace(content) != "" { + blocks = append(blocks, content) + } + continue + } + parts, ok := message.Content.([]any) + if !ok { + continue + } + texts := []string{} + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok { + continue + } + if text, ok := part["text"].(string); ok { + if text != "" { + texts = append(texts, text) + } + continue + } + output, exists := part["output"] + if !exists { + continue + } + if text, ok := output.(string); ok { + if text != "" { + texts = append(texts, text) + } + continue + } + if object, ok := output.(map[string]any); ok { + if text, ok := object["value"].(string); ok && text != "" { + texts = append(texts, text) + } + } + } + text := strings.Join(texts, "\n") + if strings.TrimSpace(text) != "" { + blocks = append(blocks, text) + } + } + return blocks +} diff --git a/internal/seniordev/session/evidenceharvest/evidenceharvest_test.go b/internal/seniordev/session/evidenceharvest/evidenceharvest_test.go new file mode 100644 index 000000000..8bcabeac6 --- /dev/null +++ b/internal/seniordev/session/evidenceharvest/evidenceharvest_test.go @@ -0,0 +1,85 @@ +//go:build !windows + +package evidenceharvest + +import ( + "strings" + "testing" +) + +func TestBackslashPathsAreNotHarvested(t *testing.T) { + got := HarvestEvidence([]string{ + `opened C:\repo\src\a.ts`, + `changed C:\repo\src\a.ts`, + }) + if got != nil { + t.Fatalf("Windows-only path unexpectedly harvested: %q", *got) + } +} + +func TestFileSectionIsKeptEvenOverBudget(t *testing.T) { + got := HarvestEvidence([]string{ + "src/really-long-name.ts", + "src/really-long-name.ts", + }, 1) + if got == nil || len(*got) <= 1 { + t.Fatalf("expected over-budget structural file block, got %v", got) + } +} + +func TestSelectEvidencePromptEndsWithTheCorpus(t *testing.T) { + var prompt string + opts := &SelectEvidenceOptions{ + Judge: EvidenceJudgeFunc(func(got string, _ any) EvidenceJudgment { + prompt = got + return EvidenceJudgment{Source: SourceLLM} + }), + } + result := SelectEvidence([]string{"alpha", "beta"}, struct{}{}, opts) + if result.Source != SourceLLM || result.Text != nil { + t.Fatalf("unexpected result: %+v", result) + } + if !strings.HasSuffix(prompt, "--- TRANSCRIPT REGION ---\nalpha\n---\nbeta") { + t.Fatalf("prompt corpus mismatch:\n%s", prompt) + } +} + +func TestHarvestEvidenceKeepsCurrentSuiteAbortAheadOfOldProbeNoise(t *testing.T) { + got := HarvestEvidence([]string{ + "Error: obsolete scratch probe failed", + "error[E0063]: missing fields in Config\nerror: could not compile `mycrate`", + }) + if got == nil || !strings.Contains(*got, "error: could not compile `mycrate`") { + t.Fatalf("current compiler failure was not preserved: %v", got) + } + if strings.Index(*got, "could not compile") > strings.Index(*got, "obsolete scratch") { + t.Fatalf("old equal-strength noise outranked the current failure:\n%s", *got) + } +} + +func TestHarvestEvidenceKeepsLineOrderInsideCurrentFailure(t *testing.T) { + got := HarvestEvidence([]string{ + "Error: stale probe", + "Exception during run: loader abort\nTransform failed with 1 error", + }) + if got == nil || strings.Index(*got, "Exception during run") > + strings.Index(*got, "Transform failed with 1 error") { + t.Fatalf("current failure lines were reversed:\n%v", got) + } +} + +func TestSemanticEvidenceJudgeSeesTheRecentEndOfALargeTranscript(t *testing.T) { + const latest = "LATEST failure: error: could not compile mycrate" + var prompt string + SelectEvidence([]string{ + "STALE-BEGIN " + strings.Repeat("x", maxCorpusChars), latest, + }, struct{}{}, &SelectEvidenceOptions{Judge: EvidenceJudgeFunc( + func(got string, _ any) EvidenceJudgment { + prompt = got + return EvidenceJudgment{Source: SourceLLM} + }, + )}) + if !strings.Contains(prompt, latest) || strings.Contains(prompt, "STALE-BEGIN") { + t.Fatalf("judge did not receive the recent transcript tail") + } +} diff --git a/internal/seniordev/session/fullverification/discovery.go b/internal/seniordev/session/fullverification/discovery.go new file mode 100644 index 000000000..c1441e78d --- /dev/null +++ b/internal/seniordev/session/fullverification/discovery.go @@ -0,0 +1,884 @@ +//go:build !windows + +// Package fullverification discovers the project-wide build and test +// entrypoints that form the session-end machine verification floor. +package fullverification + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type EntrypointKind string + +const ( + KindBuild EntrypointKind = "build" + KindTest EntrypointKind = "test" +) + +type Entrypoint struct { + Kind EntrypointKind `json:"kind"` + Command string `json:"command"` + Workdir string `json:"workdir,omitempty"` + Source string `json:"source"` +} + +type Plan struct { + Entrypoints []Entrypoint `json:"entrypoints"` + // BuildExpected reports whether this project is required to have a + // build/typecheck step. True for any accountable workspace — including one + // whose ecosystem we do not recognize — so the gate stays fail-closed; a + // plain Python package is the one case we can positively identify as + // having nothing to compile. + BuildExpected bool `json:"buildExpected"` + // TestExpected reports whether this project is required to have a test + // step. True for any accountable workspace: unlike a build, no ecosystem + // is exempt from having tests. Both flags are false only for a workspace + // that is not accountable at all (see accountableWorkspace). + TestExpected bool `json:"testExpected"` +} + +var ( + testCommandPattern = regexp.MustCompile(`(?i)(?:^|(?:&&|\|\||[;&|])[[:space:]]*)(?:env[[:space:]]+)?(?:[A-Z_][A-Z0-9_]*=[^[:space:]]+[[:space:]]+)*(?:go[[:space:]]+test|cargo[[:space:]]+test|bun[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|npm[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|pnpm[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|yarn[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|python(?:3)?[[:space:]]+-m[[:space:]]+(?:pytest|unittest)|pytest|tox|nox|make[[:space:]]+(?:test|check|verify)|just[[:space:]]+(?:test|check|verify)|(?:\./)?mvnw?[[:space:]].*(?:test|verify)|\./gradlew[[:space:]].*(?:test|check)|dotnet[[:space:]]+test|ctest(?:[[:space:]]|$))`) + buildCommandPattern = regexp.MustCompile(`(?i)(?:^|(?:&&|\|\||[;&|])[[:space:]]*)(?:env[[:space:]]+)?(?:[A-Z_][A-Z0-9_]*=[^[:space:]]+[[:space:]]+)*(?:go[[:space:]]+build|cargo[[:space:]]+build|npm[[:space:]]+run[[:space:]]+(?:build|compile|typecheck)|pnpm[[:space:]]+(?:run[[:space:]]+)?(?:build|compile|typecheck)|yarn[[:space:]]+(?:run[[:space:]]+)?(?:build|compile|typecheck)|bun[[:space:]]+run[[:space:]]+(?:build|compile|typecheck)|make[[:space:]]+(?:build|all)|just[[:space:]]+(?:build|all)|(?:\./)?mvnw?[[:space:]].*(?:package|compile)|\./gradlew[[:space:]].*(?:build|assemble)|dotnet[[:space:]]+build|cmake[[:space:]]+--build|python(?:3)?[[:space:]]+-m[[:space:]]+(?:build|compileall|mypy|pyright|ruff[[:space:]]+check)|(?:\./)?(?:mypy|pyright)(?:[[:space:]]|$)|(?:\./)?ruff[[:space:]]+check|tsc(?:[[:space:]]|$))`) + makeTargetPattern = regexp.MustCompile(`(?m)^([A-Za-z0-9_.-]+)[[:space:]]*:(?:[^=]|$)`) + inlineCodePattern = regexp.MustCompile("`([^`\n]+)`") + leadingCDPattern = regexp.MustCompile(`^cd[[:space:]]+((?:'[^']*'|"[^"]*"|[^;&|[:space:]]+))[[:space:]]*&&[[:space:]]*(.+)$`) + standaloneCDPattern = regexp.MustCompile(`^cd[[:space:]]+((?:'[^']*'|"[^"]*"|[^;&|[:space:]]+))[[:space:]]*$`) + interactiveRunnerPattern = regexp.MustCompile(`(?i)(?:^|(?:&&|\|\||[;&|])[[:space:]]*)cypress[[:space:]]+open(?:[[:space:]]|$)`) + heredocPattern = regexp.MustCompile(`<<-?[[:space:]]*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?`) + numericFlagPattern = regexp.MustCompile(`^[0-9]+$`) + + // Any package-manager invocation disqualifies a script from being chosen + // as the build entrypoint. `npm run x` hides another script's effects, and + // `npm publish` / `npm version` are outright destructive — a body like + // "tsc -p tsconfig.json && npm publish" reads as a compile right up to the + // point where satisfying the verification gate ships a release. A false + // negative here costs a discovered entrypoint; a false positive publishes a + // package. npx is excluded from the ban: it is a runner, not a lifecycle + // manager, so `npx tsc` stays selectable. + packageScriptDelegationPattern = regexp.MustCompile( + `(?i)(?:^|[^[:alnum:]_./-])(?:npm|pnpm|yarn|bun)(?:[[:space:]]|$)`) +) + +// Discover prefers CI, then repository instructions, declared +// scripts/manifests, and finally ecosystem defaults. Each kind gets one +// project-wide entrypoint. +func Discover(workspace string) Plan { + selected := map[EntrypointKind]Entrypoint{} + add := func(kind EntrypointKind, command, workdir, source string) { + command = normalizeCommand(command) + if command == "" { + return + } + if _, exists := selected[kind]; !exists { + selected[kind] = Entrypoint{ + Kind: kind, Command: command, Workdir: workdir, Source: source, + } + } + } + addCandidates := func(candidates []commandCandidate) { + for _, candidate := range candidates { + if candidate.kind != "" { + add(candidate.kind, candidate.command, candidate.workdir, candidate.source) + continue + } + if isBuildCommand(candidate.command) { + add(KindBuild, candidate.command, candidate.workdir, candidate.source) + } + if isTestCommand(candidate.command) && !isInteractiveTestCommand(candidate.command) { + add(KindTest, candidate.command, candidate.workdir, candidate.source) + } + } + } + + addCandidates(ciCandidates(workspace)) + addCandidates(documentCandidates(workspace, []string{"AGENTS.md"})) + addCandidates(scriptCandidates(workspace)) + addCandidates(documentCandidates(workspace, []string{ + "README.md", "README", "CONTRIBUTING.md", "CONTRIBUTING", + })) + + defaults := ecosystemDefaults(workspace) + for _, entrypoint := range defaults { + add(entrypoint.Kind, entrypoint.Command, entrypoint.Workdir, entrypoint.Source) + } + + _, hasBuild := selected[KindBuild] + _, hasTest := selected[KindTest] + // Both demands hang off one question: is there a project here to hold to a + // standard? Discovering any command answers it outright — someone wrote + // that command down — and otherwise the ecosystem markers decide. + accountable := hasBuild || hasTest || accountableWorkspace(workspace) + plan := Plan{ + Entrypoints: []Entrypoint{}, + BuildExpected: hasBuild || (accountable && !ecosystemLacksBuild(workspace)), + TestExpected: accountable, + } + for _, kind := range []EntrypointKind{KindBuild, KindTest} { + if entrypoint, ok := selected[kind]; ok { + plan.Entrypoints = append(plan.Entrypoints, entrypoint) + } + } + return plan +} + +type commandCandidate struct { + command string + workdir string + source string + // kind pins the classification when the caller already established it from + // something other than the command text. `npm run check:type:js` is a + // typecheck entrypoint, but only its script BODY says so — the invocation + // itself is indistinguishable from any other named script. + kind EntrypointKind +} + +func ciCandidates(workspace string) []commandCandidate { + paths := []string{ + ".gitlab-ci.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", + filepath.Join(".circleci", "config.yml"), + } + workflows, _ := filepath.Glob(filepath.Join(workspace, ".github", "workflows", "*.y*ml")) + for _, path := range workflows { + relative, err := filepath.Rel(workspace, path) + if err == nil { + paths = append(paths, relative) + } + } + sort.Strings(paths) + var candidates []commandCandidate + for _, relative := range paths { + body, ok := readSmallFile(filepath.Join(workspace, relative)) + if !ok { + continue + } + var document yaml.Node + if yaml.Unmarshal([]byte(body), &document) != nil { + continue + } + candidates = appendYAMLCommandCandidates(candidates, &document, "", relative) + } + return candidates +} + +func appendYAMLCommandCandidates(out []commandCandidate, node *yaml.Node, inheritedWorkdir, source string) []commandCandidate { + workdir := inheritedWorkdir + if node.Kind == yaml.MappingNode { + if defaults := yamlMappingValue(node, "defaults"); defaults != nil { + if run := yamlMappingValue(defaults, "run"); run != nil { + if value := yamlMappingValue(run, "working-directory"); value != nil && value.Kind == yaml.ScalarNode { + workdir = strings.TrimSpace(value.Value) + } + } + } + if value := yamlMappingValue(node, "working-directory"); value != nil && value.Kind == yaml.ScalarNode { + workdir = strings.TrimSpace(value.Value) + } + for index := 0; index+1 < len(node.Content); index += 2 { + key, value := node.Content[index].Value, node.Content[index+1] + if key == "run" || key == "script" { + out = appendYAMLCommandValue(out, value, workdir, source) + } + } + } + for _, child := range node.Content { + out = appendYAMLCommandCandidates(out, child, workdir, source) + } + return out +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1] + } + } + return nil +} + +func appendYAMLCommandValue(out []commandCandidate, node *yaml.Node, workdir, source string) []commandCandidate { + switch node.Kind { + case yaml.ScalarNode: + return appendCIShellCandidates(out, node.Value, workdir, source) + case yaml.SequenceNode: + for _, child := range node.Content { + if child.Kind == yaml.ScalarNode { + out = appendCIShellCandidates(out, child.Value, workdir, source) + } + } + } + return out +} + +func appendCIShellCandidates(out []commandCandidate, raw, workdir, source string) []commandCandidate { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + if !strings.Contains(raw, "\n") { + return appendShellCandidatesFrom(out, raw, workdir, source) + } + activeWorkdir := workdir + pending := "" + heredocEnd := "" + for _, line := range strings.Split(raw, "\n") { + if heredocEnd != "" { + if strings.TrimSpace(line) == heredocEnd { + heredocEnd = "" + } + continue + } + logical := line + if pending != "" { + logical = pending + strings.TrimSpace(line) + } + if shellLineContinues(logical) { + pending = strings.TrimSpace(strings.TrimSuffix(strings.TrimRight(logical, " \t"), "\\")) + " " + continue + } + pending = "" + command := normalizeCommand(logical) + if command == "" { + continue + } + if match := standaloneCDPattern.FindStringSubmatch(command); match != nil { + activeWorkdir = combineWorkingDirectories(activeWorkdir, strings.Trim(match[1], "\"'")) + continue + } + out = appendShellCandidatesFrom(out, command, activeWorkdir, source) + if match := heredocPattern.FindStringSubmatch(logical); match != nil { + heredocEnd = match[1] + } + } + return out +} + +func shellLineContinues(line string) bool { + line = strings.TrimRight(line, " \t") + backslashes := 0 + for index := len(line) - 1; index >= 0 && line[index] == '\\'; index-- { + backslashes++ + } + return backslashes%2 == 1 +} + +func documentCandidates(workspace string, names []string) []commandCandidate { + var candidates []commandCandidate + for _, relative := range names { + body, ok := readSmallFile(filepath.Join(workspace, relative)) + if !ok { + continue + } + inFence := false + for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + continue + } + for _, match := range inlineCodePattern.FindAllStringSubmatch(line, -1) { + candidates = appendShellCandidates(candidates, match[1], relative) + } + if inFence || strings.HasPrefix(trimmed, "$") { + candidates = appendShellCandidates(candidates, strings.TrimSpace(strings.TrimPrefix(trimmed, "$")), relative) + } + } + } + return candidates +} + +func scriptCandidates(workspace string) []commandCandidate { + var candidates []commandCandidate + if body, ok := readSmallFile(filepath.Join(workspace, "package.json")); ok { + var manifest struct { + Scripts map[string]string `json:"scripts"` + } + if json.Unmarshal([]byte(body), &manifest) == nil { + manager := packageManager(workspace) + foundBuild := false + for _, script := range []string{"build", "compile", "typecheck"} { + if _, ok := manifest.Scripts[script]; !ok { + continue + } + candidates = append(candidates, commandCandidate{ + command: managerRun(manager, script), source: "package.json#scripts." + script, + }) + foundBuild = true + break + } + if !foundBuild { + // A project may name its compile/typecheck script anything + // (`check:type` -> `tsc -p tsconfig.json`, say). Matching only + // on the three blessed NAMES misses it, the gate reports that no + // build/typecheck entrypoint was discoverable, and the agent's + // rational way out is to invent a no-op `build` script that + // upstream would never merge. isBuildCommand already knows what + // a build/typecheck command looks like, so apply it to the + // script BODY rather than requiring a blessed name. + // + // Leaf scripts only. A body that shells out to other package + // scripts can carry a publish or deploy step alongside the + // compile, and running that to satisfy a verification gate would + // be far worse than failing the gate. Names are sorted so the + // choice is deterministic across runs. + for _, name := range sortedScriptNames(manifest.Scripts) { + script := manifest.Scripts[name] + if scriptRunsOtherScripts(script) || !isBuildCommand(script) { + continue + } + if isTestCommand(script) || isInteractiveTestCommand(script) { + continue + } + candidates = append(candidates, commandCandidate{ + command: managerRun(manager, name), + source: "package.json#scripts." + name, + kind: KindBuild, + }) + break + } + } + for _, script := range []string{"test", "unit", "test:unit", "verify", "check"} { + body, ok := manifest.Scripts[script] + if !ok || isInteractiveTestCommand(body) { + continue + } + command := managerRun(manager, script) + if script == "test" { + command = managerTest(manager) + } + candidates = append(candidates, commandCandidate{command: command, source: "package.json#scripts." + script}) + break + } + } + } + for _, file := range []string{"Makefile", "makefile", "GNUmakefile", "Justfile", "justfile"} { + body, ok := readSmallFile(filepath.Join(workspace, file)) + if !ok { + continue + } + targets := map[string]bool{} + for _, match := range makeTargetPattern.FindAllStringSubmatch(body, -1) { + targets[strings.ToLower(match[1])] = true + } + command := "make " + if strings.EqualFold(file, "Justfile") { + command = "just " + } + for _, target := range []string{"build", "all"} { + if targets[target] { + candidates = append(candidates, commandCandidate{command: command + target, source: file + "#" + target}) + break + } + } + for _, target := range []string{"test", "check", "verify"} { + if targets[target] { + candidates = append(candidates, commandCandidate{command: command + target, source: file + "#" + target}) + break + } + } + } + return candidates +} + +// accountableWorkspace reports whether the workspace looks like a software +// project at all: a language manifest, a build system, or a test suite. It is +// the precondition for BOTH verification demands. +// +// A workspace with none of these — a fresh `git init` carrying a README, a +// directory of loose data files — cannot satisfy either demand no matter what +// an agent does to it. There is nothing to compile and nothing to test, so +// "no build entrypoint was discoverable" and "no test entrypoint was +// discoverable" are not defects to repair; they are descriptions of an empty +// room. Reporting them as verification failures sends the run chasing a +// target that does not exist until the cost ceiling stops it, with the +// requested deliverable already sitting on disk. +// +// Recognizing a project is deliberately generous: anything here means the +// full fail-closed floor applies, so a real repository whose test command is +// merely undiscoverable still fails, which is the point of the floor. +func accountableWorkspace(workspace string) bool { + for _, marker := range []string{ + // Language and dependency manifests. + "go.mod", "Cargo.toml", "package.json", "deno.json", "deno.jsonc", + "tsconfig.json", "pom.xml", "build.gradle", "build.gradle.kts", + "gradlew", "Gemfile", "composer.json", "mix.exs", "pubspec.yaml", + // Build systems that stand in for a manifest. + "Makefile", "makefile", "GNUmakefile", "justfile", "Justfile", + "CMakeLists.txt", "meson.build", "BUILD", "BUILD.bazel", + // Test configuration implies a suite even with no manifest at all. + "pytest.ini", "tox.ini", "noxfile.py", "conftest.py", + "phpunit.xml", "phpunit.xml.dist", ".rspec", + } { + if fileExists(filepath.Join(workspace, marker)) { + return true + } + } + for _, runner := range []string{"jest", "vitest", "playwright", "karma", "cypress"} { + for _, ext := range []string{".js", ".ts", ".mjs", ".cjs", ".json"} { + if fileExists(filepath.Join(workspace, runner+".config"+ext)) { + return true + } + } + } + if hasSuffixFile(workspace, ".sln") || hasSuffixFile(workspace, ".csproj") { + return true + } + // Covers packaging metadata, Python test config, and test_*.py layouts. + if isPythonProject(workspace) { + return true + } + return hasTestDirectory(workspace) +} + +// hasTestDirectory reports a conventional test directory at the project root — +// the last signal that a suite is expected when no manifest names one. +func hasTestDirectory(workspace string) bool { + for _, dir := range []string{"tests", "test", "spec", "specs", "__tests__"} { + if info, err := os.Stat(filepath.Join(workspace, dir)); err == nil && info.IsDir() { + return true + } + } + return false +} + +// ecosystemLacksBuild reports the one ecosystem we can positively identify as +// having no build or typecheck step: a plain Python package, which has tests to +// run but nothing to compile. Every other project — including one whose +// ecosystem we do not recognize — is still required to produce a build +// entrypoint, so the gate stays fail-closed by default. +func ecosystemLacksBuild(workspace string) bool { + if isPythonProject(workspace) { + lacks := true + for _, marker := range []string{ + "go.mod", "Cargo.toml", "pom.xml", "gradlew", "package.json", "tsconfig.json", + } { + if fileExists(filepath.Join(workspace, marker)) { + lacks = false + break + } + } + if lacks { + return true + } + } + // The same carve-out the plain-Python case gets. A package.json project + // with no TypeScript config compiles nothing, so demanding a build + // entrypoint from it is unsatisfiable by construction — and an agent facing + // an unsatisfiable gate fabricates a no-op `build` script to get past it. + // Discover still overrides this the moment any build/typecheck step is + // found, from a script, CI, or the repository instructions. + if fileExists(filepath.Join(workspace, "package.json")) && + !fileExists(filepath.Join(workspace, "tsconfig.json")) && + !hasTypeScriptSources(workspace) { + for _, marker := range []string{"go.mod", "Cargo.toml", "pom.xml", "gradlew"} { + if fileExists(filepath.Join(workspace, marker)) { + return false + } + } + return true + } + return false +} + +// hasTypeScriptSources reports whether the project ships TypeScript that a +// typecheck step would be expected to cover, without walking the whole tree. +func hasTypeScriptSources(workspace string) bool { + for _, dir := range []string{".", "src", "lib", "types"} { + for _, pattern := range []string{"*.ts", "*.tsx", "*.mts", "*.cts"} { + matches, _ := filepath.Glob(filepath.Join(workspace, dir, pattern)) + if len(matches) > 0 { + return true + } + } + } + return false +} + +// sortedScriptNames gives package-script iteration a stable order; Go map +// ranging is randomized and the chosen entrypoint must not vary between runs. +func sortedScriptNames(scripts map[string]string) []string { + names := make([]string, 0, len(scripts)) + for name := range scripts { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// scriptRunsOtherScripts reports whether a package script delegates to other +// package scripts, which makes its full effect unknowable from its own body. +func scriptRunsOtherScripts(body string) bool { + return packageScriptDelegationPattern.MatchString(body) +} + +func ecosystemDefaults(workspace string) []Entrypoint { + entries := []Entrypoint{} + add := func(kind EntrypointKind, command, source string) { + entries = append(entries, Entrypoint{Kind: kind, Command: command, Source: source}) + } + switch { + case fileExists(filepath.Join(workspace, "go.mod")): + add(KindBuild, "go build ./...", "go.mod") + add(KindTest, "go test ./...", "go.mod") + case fileExists(filepath.Join(workspace, "Cargo.toml")): + add(KindBuild, "cargo build --workspace", "Cargo.toml") + add(KindTest, "cargo test --workspace", "Cargo.toml") + case fileExists(filepath.Join(workspace, "pom.xml")): + binary := "mvn" + if fileExists(filepath.Join(workspace, "mvnw")) { + binary = "./mvnw" + } + add(KindBuild, binary+" -DskipTests package", "pom.xml") + add(KindTest, binary+" test", "pom.xml") + case fileExists(filepath.Join(workspace, "gradlew")): + add(KindBuild, "./gradlew assemble", "gradlew") + add(KindTest, "./gradlew test", "gradlew") + case hasSuffixFile(workspace, ".sln") || hasSuffixFile(workspace, ".csproj"): + add(KindBuild, "dotnet build", "dotnet project") + add(KindTest, "dotnet test", "dotnet project") + case isPythonProject(workspace): + if hasPythonTests(workspace) { + add(KindTest, "python3 -m pytest", "Python test files") + } + } + return entries +} + +func appendShellCandidates(out []commandCandidate, raw, source string) []commandCandidate { + return appendShellCandidatesFrom(out, raw, "", source) +} + +func appendShellCandidatesFrom(out []commandCandidate, raw, baseWorkdir, source string) []commandCandidate { + raw = strings.TrimSpace(strings.Trim(raw, "`")) + if raw == "" || strings.Contains(raw, "${{") { + return out + } + command, inlineWorkdir := preserveLeadingWorkingDirectory(raw) + workdir := combineWorkingDirectories(baseWorkdir, inlineWorkdir) + if isBuildCommand(command) || isTestCommand(command) { + out = append(out, commandCandidate{ + command: command, workdir: workdir, source: source, + }) + } + return out +} + +func preserveLeadingWorkingDirectory(raw string) (string, string) { + command := normalizeCommand(raw) + match := leadingCDPattern.FindStringSubmatch(command) + if match == nil { + return command, "" + } + workdir := strings.Trim(match[1], "\"'") + return normalizeCommand(match[2]), workdir +} + +func combineWorkingDirectories(base, nested string) string { + if nested == "" { + return base + } + if base == "" || filepath.IsAbs(nested) { + return filepath.Clean(nested) + } + // A CI step starts in working-directory before its shell runs, so a relative + // inline cd is appended to that directory; an absolute cd replaces it, the + // same way a fresh shell process would resolve it. + return filepath.Clean(filepath.Join(base, nested)) +} + +func normalizeCommand(command string) string { + command = executableShellText(strings.TrimSpace(strings.Trim(command, "`"))) + return strings.Join(strings.Fields(command), " ") +} + +// executableShellText removes shell comments before command-name matching. +// Only commands that would actually execute count, not command-shaped prose, +// so quoted hashes remain data while an unquoted # at a shell word boundary +// hides everything through the newline. +func executableShellText(command string) string { + var out strings.Builder + singleQuoted := false + doubleQuoted := false + escaped := false + for index := 0; index < len(command); index++ { + character := command[index] + if escaped { + out.WriteByte(character) + escaped = false + continue + } + if character == '\\' && !singleQuoted { + out.WriteByte(character) + escaped = true + continue + } + if character == '\'' && !doubleQuoted { + singleQuoted = !singleQuoted + out.WriteByte(character) + continue + } + if character == '"' && !singleQuoted { + doubleQuoted = !doubleQuoted + out.WriteByte(character) + continue + } + if character == '#' && !singleQuoted && !doubleQuoted && shellCommentBoundary(command, index) { + for index < len(command) && command[index] != '\n' { + index++ + } + if index < len(command) { + out.WriteString(" ; ") + } + continue + } + if character == '\n' && !singleQuoted && !doubleQuoted { + out.WriteString(" ; ") + continue + } + out.WriteByte(character) + } + return out.String() +} + +func shellCommentBoundary(command string, index int) bool { + if index == 0 { + return true + } + previous := command[index-1] + return previous == ' ' || previous == '\t' || previous == '\r' || previous == '\n' || + strings.ContainsRune(";&|()", rune(previous)) +} + +func isBuildCommand(command string) bool { + return safeShellControlFlow(command) && buildCommandPattern.MatchString(classifiableShellText(command)) +} + +func isTestCommand(command string) bool { + return safeShellControlFlow(command) && testCommandPattern.MatchString(classifiableShellText(command)) +} + +// safeShellControlFlow admits only structures whose exit status the +// verification run can trust: simple commands, && chains, and output capture +// through tee. Alternative/sequence/background clauses can skip a classified +// tool or replace its status, so discovery fails closed on them. +func safeShellControlFlow(command string) bool { + text := classifiableShellText(command) + if strings.Contains(text, "||") || strings.Contains(text, ";") { + return false + } + for index := 0; index < len(text); index++ { + switch text[index] { + case '&': + if index+1 < len(text) && text[index+1] == '&' { + index++ + continue + } + if index > 0 && (text[index-1] == '>' || text[index-1] == '<') { + continue + } + return false + case '|': + if index+1 < len(text) && text[index+1] == '|' { + return false + } + remainder := strings.TrimSpace(text[index+1:]) + if remainder != "tee" && !strings.HasPrefix(remainder, "tee ") { + return false + } + } + } + return true +} + +// classifiableShellText keeps shell structure and unquoted command words but +// masks quoted arguments. Without this, `echo "x; go build"` looks like a +// second command even though the semicolon and build words are only echo data. +func classifiableShellText(command string) string { + command = normalizeCommand(command) + var out strings.Builder + singleQuoted := false + doubleQuoted := false + escaped := false + for index := 0; index < len(command); index++ { + character := command[index] + if escaped { + if !singleQuoted && !doubleQuoted { + out.WriteByte(character) + } + escaped = false + continue + } + if character == '\\' && !singleQuoted { + escaped = true + continue + } + if character == '\'' && !doubleQuoted { + if !singleQuoted { + out.WriteByte('Q') + } + singleQuoted = !singleQuoted + continue + } + if character == '"' && !singleQuoted { + if !doubleQuoted { + out.WriteByte('Q') + } + doubleQuoted = !doubleQuoted + continue + } + if !singleQuoted && !doubleQuoted { + out.WriteByte(character) + } + } + return out.String() +} + +func isInteractiveTestCommand(command string) bool { + command = normalizeCommand(command) + if interactiveRunnerPattern.MatchString(classifiableShellText(command)) { + return true + } + words := strings.Fields(command) + for index := 0; index < len(words); index++ { + word := strings.ToLower(strings.Trim(words[index], ";&|")) + name, value, assigned := strings.Cut(word, "=") + switch name { + case "-w": + if assigned { + if !numericFlagPattern.MatchString(value) { + return true + } + continue + } + if index+1 < len(words) && numericFlagPattern.MatchString(strings.Trim(words[index+1], ";&|")) { + index++ + continue + } + return true + case "--watch", "--watch-all", "--watchall", "--ui", "--interactive": + if assigned { + if !falseFlagValue(value) { + return true + } + continue + } + if index+1 < len(words) && falseFlagValue(strings.Trim(words[index+1], ";&|")) { + index++ + continue + } + return true + } + } + return false +} + +func falseFlagValue(value string) bool { + switch strings.ToLower(value) { + case "false", "0", "no", "off": + return true + default: + return false + } +} + +func packageManager(workspace string) string { + for _, candidate := range []struct { + file string + manager string + }{ + {"bun.lock", "bun"}, {"bun.lockb", "bun"}, + {"pnpm-lock.yaml", "pnpm"}, {"yarn.lock", "yarn"}, + } { + if fileExists(filepath.Join(workspace, candidate.file)) { + return candidate.manager + } + } + return "npm" +} + +func managerRun(manager, script string) string { + if manager == "npm" || manager == "bun" { + return manager + " run " + script + } + return manager + " " + script +} + +func managerTest(manager string) string { + if manager == "bun" { + return "bun run test" + } + return manager + " test" +} + +func readSmallFile(path string) (string, bool) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > 1_000_000 { + return "", false + } + body, err := os.ReadFile(path) + return string(body), err == nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func isPythonProject(workspace string) bool { + for _, marker := range []string{ + "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "noxfile.py", + } { + if fileExists(filepath.Join(workspace, marker)) { + return true + } + } + return hasPythonTests(workspace) +} + +func hasSuffixFile(workspace, suffix string) bool { + entries, err := os.ReadDir(workspace) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(strings.ToLower(entry.Name()), suffix) { + return true + } + } + return false +} + +func hasPythonTests(workspace string) bool { + for _, config := range []string{"pytest.ini", "tox.ini", "noxfile.py"} { + if fileExists(filepath.Join(workspace, config)) { + return true + } + } + found := false + _ = filepath.WalkDir(workspace, func(path string, entry os.DirEntry, err error) error { + if err != nil || found { + return filepath.SkipDir + } + if entry.IsDir() { + name := entry.Name() + if path != workspace && (strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor") { + return filepath.SkipDir + } + return nil + } + name := strings.ToLower(entry.Name()) + if strings.HasPrefix(name, "test_") && strings.HasSuffix(name, ".py") || + strings.HasSuffix(name, "_test.py") { + found = true + } + return nil + }) + return found +} diff --git a/internal/seniordev/session/fullverification/discovery_test.go b/internal/seniordev/session/fullverification/discovery_test.go new file mode 100644 index 000000000..832b5b6f6 --- /dev/null +++ b/internal/seniordev/session/fullverification/discovery_test.go @@ -0,0 +1,598 @@ +//go:build !windows + +package fullverification + +import ( + "os" + "path/filepath" + "reflect" + "strconv" + "testing" +) + +func writeDiscoveryFile(t *testing.T, root, relative, body string) { + t.Helper() + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestDiscoverPrefersCIEntrypoints(t *testing.T) { + // Command discovery prefers CI over an ecosystem default. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "go.mod", "module example.test/ci\n") + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", ` +jobs: + verify: + steps: + - run: go build ./cmd/... + - run: | + go test -count=1 ./... +`) + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "go build ./cmd/...", Source: ".github/workflows/verify.yml"}, + {Kind: KindTest, Command: "go test -count=1 ./...", Source: ".github/workflows/verify.yml"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverHonorsAgentInstructionsBeforePackageScripts(t *testing.T) { + // Repository agent guidance can name the canonical commands and wins over + // generic package-script detection. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "AGENTS.md", "Run `npm run compile` and then `npm test -- --runInBand`.\n") + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"vite build","test":"vitest"}}`) + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "npm run compile", Source: "AGENTS.md"}, + {Kind: KindTest, Command: "npm test -- --runInBand", Source: "AGENTS.md"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverPackageManagerScripts(t *testing.T) { + // Manifest scripts retain the repository's package manager rather than + // assuming npm or a language-specific command. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"tsc","test":"vitest run"}}`) + writeDiscoveryFile(t, workspace, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "pnpm build", Source: "package.json#scripts.build"}, + {Kind: KindTest, Command: "pnpm test", Source: "package.json#scripts.test"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverREADMEEntrypoints(t *testing.T) { + // Documented commands remain discoverable when the repository has no CI + // or declared task-runner scripts. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "README.md", "# Development\n\n"+ + "Build with `cargo build --all-targets`.\n\n"+ + "Run the suite:\n\n```sh\ncargo test --all-targets\n```\n") + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "cargo build --all-targets", Source: "README.md"}, + {Kind: KindTest, Command: "cargo test --all-targets", Source: "README.md"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverGitLabScriptListAndUnitScript(t *testing.T) { + // List-form CI scripts and the standard `unit` script fallback are both + // recognized. + ciWorkspace := t.TempDir() + writeDiscoveryFile(t, ciWorkspace, ".gitlab-ci.yml", `verify: + script: + - cargo build --workspace + - cargo test --workspace +`) + wantCI := []Entrypoint{ + {Kind: KindBuild, Command: "cargo build --workspace", Source: ".gitlab-ci.yml"}, + {Kind: KindTest, Command: "cargo test --workspace", Source: ".gitlab-ci.yml"}, + } + if plan := Discover(ciWorkspace); !reflect.DeepEqual(plan.Entrypoints, wantCI) { + t.Fatalf("CI plan = %#v, want %#v", plan.Entrypoints, wantCI) + } + + packageWorkspace := t.TempDir() + writeDiscoveryFile(t, packageWorkspace, "package.json", `{"scripts":{"build":"tsc","unit":"vitest run"}}`) + wantPackage := []Entrypoint{ + {Kind: KindBuild, Command: "npm run build", Source: "package.json#scripts.build"}, + {Kind: KindTest, Command: "npm run unit", Source: "package.json#scripts.unit"}, + } + if plan := Discover(packageWorkspace); !reflect.DeepEqual(plan.Entrypoints, wantPackage) { + t.Fatalf("package plan = %#v, want %#v", plan.Entrypoints, wantPackage) + } +} + +func TestDiscoverGoFullEntrypointsAndEmptyFallback(t *testing.T) { + // Go is one ecosystem fallback among several, and a repository with no + // discoverable convention does not invent go test. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "go.mod", "module example.test/default\n") + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "go build ./...", Source: "go.mod"}, + {Kind: KindTest, Command: "go test ./...", Source: "go.mod"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("go plan = %#v, want %#v", plan.Entrypoints, want) + } + if empty := Discover(t.TempDir()); len(empty.Entrypoints) != 0 { + t.Fatalf("empty repository plan = %#v", empty.Entrypoints) + } +} + +func TestDiscoverRejectsWatcherScriptsAndFallsThrough(t *testing.T) { + // A manifest key is not usable evidence when its body starts an + // interactive runner. A later non-watcher candidate wins, while a + // watcher-only manifest yields no test entrypoint. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{ + "scripts": { + "build": "tsc", + "test": "vitest --watch", + "unit": "vitest run" + } +}`) + want := []Entrypoint{ + {Kind: KindBuild, Command: "npm run build", Source: "package.json#scripts.build"}, + {Kind: KindTest, Command: "npm run unit", Source: "package.json#scripts.unit"}, + } + if plan := Discover(workspace); !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("watcher fallback plan = %#v, want %#v", plan.Entrypoints, want) + } + + watcherOnly := t.TempDir() + writeDiscoveryFile(t, watcherOnly, "package.json", `{"scripts":{"build":"tsc","test":"vitest --ui"}}`) + want = []Entrypoint{ + {Kind: KindBuild, Command: "npm run build", Source: "package.json#scripts.build"}, + } + if plan := Discover(watcherOnly); !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("watcher-only plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverPreservesCIWorkingDirectoryAndCommandChain(t *testing.T) { + // Extracting a recognized command from a CI chain must retain its leading + // cd and every remaining shell step. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", ` +jobs: + verify: + steps: + - run: cd frontend && npm ci && npm run build && npm test +`) + want := []Entrypoint{ + { + Kind: KindBuild, Command: "npm ci && npm run build && npm test", + Workdir: "frontend", Source: ".github/workflows/verify.yml", + }, + { + Kind: KindTest, Command: "npm ci && npm run build && npm test", + Workdir: "frontend", Source: ".github/workflows/verify.yml", + }, + } + if plan := Discover(workspace); !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("CI chain plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestCommandClassificationUsesExecutableShellText(t *testing.T) { + // Words in comments and echo arguments are not process evidence, while + // genuine build and test invocations remain discoverable. + tests := []struct { + name string + command string + build bool + test bool + }{ + {name: "true with test comment", command: "true # pytest", test: false}, + {name: "colon with test comment", command: ": # npm test", test: false}, + {name: "echo quoted build", command: `echo "go build ./..."`, build: false}, + {name: "echo quoted shell clause", command: `echo "ignored; go build ./..."`, build: false}, + {name: "echo unquoted test", command: "echo go test ./...", test: false}, + {name: "comment after real build", command: "go build ./... # pytest", build: true, test: false}, + {name: "quoted hash is argument", command: `pytest -k '# smoke'`, test: true}, + {name: "real test", command: "go test ./...", test: true}, + {name: "masked failing test", command: "go test ./... || true", test: false}, + {name: "skipped test after true", command: "true || go test ./...", test: false}, + {name: "skipped test after exit", command: "exit 0; go test ./...", test: false}, + {name: "preceding true clause", command: "true; go test ./...", test: false}, + {name: "unguarded pipe", command: "go test ./... | cat", test: false}, + {name: "backgrounded test", command: "go test ./... & true", test: false}, + {name: "tee pipeline", command: "go test ./... 2>&1 | tee test.log", test: true}, + {name: "leading cd chain", command: "cd frontend && go test ./...", test: true}, + {name: "build and test chain", command: "go build ./... && go test ./...", build: true, test: true}, + {name: "environment assignment", command: "CGO_ENABLED=0 go test ./...", test: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isBuildCommand(test.command); got != test.build { + t.Fatalf("isBuildCommand(%q) = %t, want %t", test.command, got, test.build) + } + if got := isTestCommand(test.command); got != test.test { + t.Fatalf("isTestCommand(%q) = %t, want %t", test.command, got, test.test) + } + }) + } +} + +func TestDiscoverPythonTypechecksAsBuildEntrypoints(t *testing.T) { + // Each explicit compile/typecheck command satisfies the mandatory build + // role. + commands := []string{ + "mypy src", + "pyright", + "ruff check .", + "tsc --noEmit", + "python3 -m compileall src", + } + for _, command := range commands { + t.Run(command, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "pyproject.toml", "[project]\nname = \"demo\"\n") + writeDiscoveryFile(t, workspace, "AGENTS.md", "Run `"+command+"`.\n") + plan := Discover(workspace) + want := Entrypoint{Kind: KindBuild, Command: command, Source: "AGENTS.md"} + if len(plan.Entrypoints) != 1 || plan.Entrypoints[0] != want { + t.Fatalf("plan = %#v, want build %#v", plan, want) + } + if !plan.BuildExpected { + t.Fatal("explicit Python typecheck did not make build evidence mandatory") + } + }) + } +} + +func TestPlainPythonBuildExemptionDependsOnDiscoveredTypecheck(t *testing.T) { + // The Python carve-out applies only when discovery found no explicit + // compile/typecheck step anywhere. + plain := t.TempDir() + writeDiscoveryFile(t, plain, "pyproject.toml", "[project]\nname = \"plain\"\n") + writeDiscoveryFile(t, plain, "test_demo.py", "def test_green():\n assert True\n") + if plan := Discover(plain); plan.BuildExpected || planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("plain Python plan = %#v, want test-only exemption", plan) + } + + typed := t.TempDir() + writeDiscoveryFile(t, typed, "pyproject.toml", "[project]\nname = \"typed\"\n") + writeDiscoveryFile(t, typed, "AGENTS.md", "Run `mypy src` and `python3 -m unittest`.\n") + if plan := Discover(typed); !plan.BuildExpected || !planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("typed Python plan = %#v, want required build", plan) + } +} + +func TestDiscoverPlainPythonWithoutPackagingMetadata(t *testing.T) { + // Test/config markers identify plain Python repositories even without + // packaging metadata, while the established metadata path remains. + for _, test := range []struct { + name string + marker string + }{ + {name: "requirements and pytest config", marker: "requirements.txt"}, + {name: "pytest config", marker: "pytest.ini"}, + {name: "tox config", marker: "tox.ini"}, + {name: "test layout only", marker: "tests/test_example.py"}, + {name: "packaging metadata", marker: "pyproject.toml"}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, test.marker, "# marker\n") + if test.marker != "tests/test_example.py" { + writeDiscoveryFile(t, workspace, "tests/test_example.py", "def test_green():\n assert True\n") + } + plan := Discover(workspace) + want := []Entrypoint{{Kind: KindTest, Command: "python3 -m pytest", Source: "Python test files"}} + if plan.BuildExpected || !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plain Python plan = %#v, want %#v with no build", plan, want) + } + }) + } +} + +func TestInteractiveTestFlagsRespectTheirMeaningAndValue(t *testing.T) { + // Jest's -w means workers, and an explicit false watch value is + // non-interactive; modes that actually wait for a user remain unusable. + tests := []struct { + command string + interactive bool + }{ + {command: "jest -w 1"}, + {command: "jest -w=2"}, + {command: "jest --maxWorkers=2"}, + {command: "vitest -w", interactive: true}, + {command: "jest -w", interactive: true}, + {command: "vitest --watch=false"}, + {command: "vitest --watch false"}, + {command: "vitest --watch", interactive: true}, + {command: "jest --watch", interactive: true}, + {command: "vitest --ui", interactive: true}, + {command: "cypress open", interactive: true}, + } + for _, test := range tests { + t.Run(test.command, func(t *testing.T) { + if got := isInteractiveTestCommand(test.command); got != test.interactive { + t.Fatalf("isInteractiveTestCommand(%q) = %t, want %t", test.command, got, test.interactive) + } + }) + } +} + +func TestDiscoverAcceptsNonInteractiveWatchLikeScripts(t *testing.T) { + // The package-script path, where watcher filtering happens before the + // generated npm command is classified. + for _, body := range []string{"jest -w 1", "jest -w=2", "jest --maxWorkers=2", "vitest --watch=false", "vitest --watch false"} { + t.Run(body, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"tsc","test":`+strconv.Quote(body)+`}}`) + if plan := Discover(workspace); !planHasEntrypointKind(plan, KindTest) { + t.Fatalf("plan = %#v, want usable test script", plan) + } + }) + } +} + +func TestDiscoverRejectsBareShortWatchFlagAndFallsThrough(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"tsc","test":"vitest -w","unit":"vitest run"}}`) + plan := Discover(workspace) + want := Entrypoint{Kind: KindTest, Command: "npm run unit", Source: "package.json#scripts.unit"} + if len(plan.Entrypoints) != 2 || plan.Entrypoints[1] != want { + t.Fatalf("short-watch plan = %#v, want fallback %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverCIWorkingDirectoryForms(t *testing.T) { + tests := []struct { + name string + workflow string + workdir string + }{ + { + name: "step field", + workflow: `jobs: + verify: + steps: + - run: go test ./... + working-directory: frontend +`, + workdir: "frontend", + }, + { + name: "multiline leading cd", + workflow: `jobs: + verify: + steps: + - run: | + cd frontend + go test ./... +`, + workdir: "frontend", + }, + { + name: "step field then inline cd", + workflow: `jobs: + verify: + steps: + - working-directory: packages + run: cd frontend && go test ./... +`, + workdir: filepath.Join("packages", "frontend"), + }, + { + name: "workflow defaults", + workflow: `defaults: + run: + working-directory: frontend +jobs: + verify: + steps: + - run: go test ./... +`, + workdir: "frontend", + }, + { + name: "job defaults override workflow", + workflow: `defaults: + run: + working-directory: ignored +jobs: + verify: + defaults: + run: + working-directory: frontend + steps: + - run: go test ./... +`, + workdir: "frontend", + }, + { + name: "multiline cd after benign setup", + workflow: `jobs: + verify: + steps: + - run: | + set -e + export MODE=ci + # setup complete + cd frontend + go test ./... +`, + workdir: "frontend", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", test.workflow) + plan := Discover(workspace) + want := []Entrypoint{{ + Kind: KindTest, Command: "go test ./...", Workdir: test.workdir, + Source: ".github/workflows/verify.yml", + }} + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } + }) + } +} + +func TestDiscoverJoinsCIContinuationsAndSkipsHeredocBodies(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", `jobs: + verify: + steps: + - run: | + cmake \ + --build build + python3 <<'PY' + go test ./... + PY +`) + plan := Discover(workspace) + want := []Entrypoint{{ + Kind: KindBuild, Command: "cmake --build build", Source: ".github/workflows/verify.yml", + }} + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("multiline CI plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +// TestUnaccountableWorkspaceExpectsNothing: a workspace with no project in it +// demands neither role. Both demands are unsatisfiable there — nothing to +// compile, nothing to test — and reporting them as failures sends the run +// chasing a target that does not exist. +func TestUnaccountableWorkspaceExpectsNothing(t *testing.T) { + for _, test := range []struct { + name string + files map[string]string + }{ + {name: "empty", files: map[string]string{}}, + {name: "readme only", files: map[string]string{"README.md": "# demo\n"}}, + {name: "loose data files", files: map[string]string{ + "notes.txt": "hello\n", "data.csv": "a,b\n1,2\n", + }}, + // A shell script whose name starts with "test" is not a Python test + // file and not a suite. + {name: "loose shell script", files: map[string]string{ + "README.md": "# demo\n", "test_hello.sh": "#!/bin/sh\nexit 0\n", + }}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + for name, body := range test.files { + writeDiscoveryFile(t, workspace, name, body) + } + plan := Discover(workspace) + if plan.BuildExpected || plan.TestExpected || len(plan.Entrypoints) != 0 { + t.Fatalf("unaccountable plan = %#v, want no demands and no entrypoints", plan) + } + }) + } +} + +// TestAccountableWorkspaceExpectsBothRoles: the moment a workspace looks like a +// project, the fail-closed floor applies. A real repository whose test +// command is merely undiscoverable must still fail — that strictness is the +// point of the gate. +func TestAccountableWorkspaceExpectsBothRoles(t *testing.T) { + for _, test := range []struct { + name string + files map[string]string + wantBuildExpected bool + }{ + {name: "typescript config", files: map[string]string{ + "tsconfig.json": `{"compilerOptions":{"strict":true}}`, + }, wantBuildExpected: true}, + {name: "makefile without either target", files: map[string]string{ + "Makefile": "lint:\n\techo lint\n", + }, wantBuildExpected: true}, + {name: "test directory alone", files: map[string]string{ + "spec/example_spec.rb": "# spec\n", + }, wantBuildExpected: true}, + // A plain-Python project keeps its build carve-out but is still held to + // a test entrypoint, which pytest markers here do not supply. + {name: "python packaging without tests", files: map[string]string{ + "pyproject.toml": "[project]\nname = \"demo\"\n", + }, wantBuildExpected: false}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + for name, body := range test.files { + writeDiscoveryFile(t, workspace, name, body) + } + plan := Discover(workspace) + if !plan.TestExpected { + t.Errorf("plan = %#v, want TestExpected", plan) + } + if plan.BuildExpected != test.wantBuildExpected { + t.Errorf("plan = %#v, want BuildExpected=%v", plan, test.wantBuildExpected) + } + if planHasEntrypointKind(plan, KindTest) { + t.Errorf("plan = %#v, want no discoverable test entrypoint in this fixture", plan) + } + }) + } +} + +// TestDiscoveredEntrypointsMakeAWorkspaceAccountable: a documented command is +// itself proof that a project is here, whatever its shape. This is the path +// that keeps an unrecognized-ecosystem repository fail-closed. +func TestDiscoveredEntrypointsMakeAWorkspaceAccountable(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "checks/check_green.py", "# not a pytest layout\n") + writeDiscoveryFile(t, workspace, "AGENTS.md", + "Run `python3 -m unittest discover -s checks -p 'check_*.py'`.\n") + plan := Discover(workspace) + if !planHasEntrypointKind(plan, KindTest) { + t.Fatalf("plan = %#v, want the documented test command discovered", plan) + } + if !plan.BuildExpected || !plan.TestExpected { + t.Fatalf("plan = %#v, want both demands once a command was discovered", plan) + } +} + +// TestGoAndJSProjectsKeepTheirDiscoveredEntrypoints pins that the common +// ecosystems are both demanded and discovered. +func TestGoAndJSProjectsKeepTheirDiscoveredEntrypoints(t *testing.T) { + goWorkspace := t.TempDir() + writeDiscoveryFile(t, goWorkspace, "go.mod", "module example.test/demo\n") + writeDiscoveryFile(t, goWorkspace, "demo_test.go", "package demo\n") + plan := Discover(goWorkspace) + if !plan.BuildExpected || !plan.TestExpected || + !planHasEntrypointKind(plan, KindTest) || !planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("go plan = %#v, want both demanded and both discovered", plan) + } + + jsWorkspace := t.TempDir() + writeDiscoveryFile(t, jsWorkspace, "package.json", `{"scripts":{"test":"vitest run"}}`) + plan = Discover(jsWorkspace) + if !plan.TestExpected || !planHasEntrypointKind(plan, KindTest) { + t.Fatalf("js plan = %#v, want a discovered test entrypoint", plan) + } +} + +func planHasEntrypointKind(plan Plan, kind EntrypointKind) bool { + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind == kind { + return true + } + } + return false +} diff --git a/internal/seniordev/session/fullverification/noop_evidence_test.go b/internal/seniordev/session/fullverification/noop_evidence_test.go new file mode 100644 index 000000000..e64ec165e --- /dev/null +++ b/internal/seniordev/session/fullverification/noop_evidence_test.go @@ -0,0 +1,44 @@ +//go:build !windows + +package fullverification + +import "testing" + +// A command whose executed portion does nothing must never be accepted as +// build or test evidence, whatever a trailing comment claims: `true # pytest` +// exits 0 and runs no tests, and a substring classifier that saw "pytest" +// would record a green suite. +func TestNoOpCommandsAreNeverEvidence(t *testing.T) { + for _, command := range []string{ + "true # pytest", + "true # npm test", + ": # go test ./...", + `echo "go build"`, + "echo go test ./...", + "true # go build ./...", + " true # cargo test ", + } { + if isTestCommand(command) { + t.Errorf("no-op accepted as TEST evidence: %q", command) + } + if isBuildCommand(command) { + t.Errorf("no-op accepted as BUILD evidence: %q", command) + } + } + // Real commands must still classify, including with a trailing comment. + for _, command := range []string{ + "go test ./...", + "go test ./... # run the suite", + "pytest -q", + "npm test", + } { + if !isTestCommand(command) { + t.Errorf("real test command rejected: %q", command) + } + } + for _, command := range []string{"go build ./...", "npm run build", "tsc --noEmit"} { + if !isBuildCommand(command) { + t.Errorf("real build command rejected: %q", command) + } + } +} diff --git a/internal/seniordev/session/fullverification/script_body_discovery_test.go b/internal/seniordev/session/fullverification/script_body_discovery_test.go new file mode 100644 index 000000000..090ec9cc3 --- /dev/null +++ b/internal/seniordev/session/fullverification/script_body_discovery_test.go @@ -0,0 +1,141 @@ +//go:build !windows + +package fullverification + +import "testing" + +// Build/typecheck discovery from script bodies. A project may declare real +// `tsc` typecheck scripts under its own names (check:type:ts, check:type:js); +// if discovery matched only the three blessed NAMES, the gate would report +// that no build/typecheck entrypoint was discoverable while `npm test` exited +// 0, and the agent's rational way out would be to fabricate a no-op `build` +// script that upstream would never merge. The contract: +// +// - a compile/typecheck script is discoverable under ANY name, judged by what +// its body runs; +// - a script that delegates to other package scripts is never chosen, since +// its full effect is unknowable from its own body (it may publish); +// - the blessed names still win when present, so existing plans do not move; +// - a project that genuinely compiles nothing is not asked for a build at all. +func TestBuildDiscoveryFromScriptBodies(t *testing.T) { + manifest := func(scripts string) string { + return "{\n \"name\": \"demo\",\n \"scripts\": {\n" + scripts + "\n }\n}\n" + } + + t.Run("finds a tsc typecheck declared under a project-specific name", func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "check:type": "npm run check:type:js && npm run check:type:ts", + "check:type:ts": "tsd && tsc -p tsconfig.ts.json", + "check:type:js": "tsc -p tsconfig.js.json", + "check:lint": "eslint .", + "test": "jest"`)) + writeDiscoveryFile(t, workspace, "tsconfig.json", "{}\n") + + plan := Discover(workspace) + if !planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("no build entrypoint; gate would be unsatisfiable. plan = %#v", plan) + } + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind != KindBuild { + continue + } + if entrypoint.Command != "npm run check:type:js" { + t.Errorf("build command = %q, want %q", entrypoint.Command, "npm run check:type:js") + } + if entrypoint.Source != "package.json#scripts.check:type:js" { + t.Errorf("build source = %q", entrypoint.Source) + } + } + }) + + t.Run("never picks a script that delegates to other package scripts", func(t *testing.T) { + // `release` bundles a compile with a publish. Running it to satisfy a + // verification gate would push a package to the registry. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "release": "tsc -p tsconfig.json && npm publish", + "test": "jest"`)) + writeDiscoveryFile(t, workspace, "tsconfig.json", "{}\n") + + for _, entrypoint := range Discover(workspace).Entrypoints { + if entrypoint.Kind == KindBuild { + t.Fatalf("chose a delegating script as the build entrypoint: %#v", entrypoint) + } + } + }) + + t.Run("blessed names still win so existing plans do not move", func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "build": "rollup -c", + "check:type:js": "tsc -p tsconfig.js.json", + "test": "jest"`)) + + for _, entrypoint := range Discover(workspace).Entrypoints { + if entrypoint.Kind != KindBuild { + continue + } + if entrypoint.Command != "npm run build" { + t.Errorf("build command = %q, want the blessed %q", entrypoint.Command, "npm run build") + } + } + }) + + t.Run("a script name is chosen deterministically", func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "zeta:types": "tsc -p tsconfig.zeta.json", + "alpha:types": "tsc -p tsconfig.alpha.json", + "test": "jest"`)) + writeDiscoveryFile(t, workspace, "tsconfig.json", "{}\n") + + first := "" + for run := 0; run < 8; run++ { + got := "" + for _, entrypoint := range Discover(workspace).Entrypoints { + if entrypoint.Kind == KindBuild { + got = entrypoint.Command + } + } + if run == 0 { + first = got + } else if got != first { + t.Fatalf("discovery is not deterministic: %q then %q", first, got) + } + } + if first != "npm run alpha:types" { + t.Errorf("build command = %q, want the name-sorted %q", first, "npm run alpha:types") + } + }) +} + +func TestBuildlessJavaScriptProjectIsNotAskedForABuild(t *testing.T) { + // Plain JS with nothing to compile is the JS twin of the plain-Python + // carve-out: no tsconfig, no TypeScript sources, no compile script. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", + "{\n \"name\": \"plain\",\n \"scripts\": { \"test\": \"jest\" }\n}\n") + writeDiscoveryFile(t, workspace, "index.js", "module.exports = 1\n") + + plan := Discover(workspace) + if plan.BuildExpected { + t.Errorf("BuildExpected = true for a project that compiles nothing; the gate "+ + "is unsatisfiable and invites a fabricated build script. plan = %#v", plan) + } + if !planHasEntrypointKind(plan, KindTest) { + t.Errorf("lost the test entrypoint: %#v", plan) + } +} + +func TestTypeScriptProjectStillOwesABuild(t *testing.T) { + // The carve-out must not swallow projects that really do compile. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", + "{\n \"name\": \"typed\",\n \"scripts\": { \"test\": \"jest\" }\n}\n") + writeDiscoveryFile(t, workspace, "src/index.ts", "export const x = 1\n") + + if plan := Discover(workspace); !plan.BuildExpected { + t.Errorf("BuildExpected = false for a TypeScript project: %#v", plan) + } +} diff --git a/internal/seniordev/session/instruction/instruction.go b/internal/seniordev/session/instruction/instruction.go new file mode 100644 index 000000000..16c104184 --- /dev/null +++ b/internal/seniordev/session/instruction/instruction.go @@ -0,0 +1,500 @@ +//go:build !windows + +// Package instruction discovers the project's instruction files (AGENTS.md +// and the files named in config, local or remote) and tracks which ones a +// session has already loaded, so each is injected into the prompt once. +package instruction + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +type OrderedSet struct { + keys []string + set map[string]struct{} +} + +func NewOrderedSet() *OrderedSet { + return &OrderedSet{set: make(map[string]struct{})} +} + +func (set *OrderedSet) Add(value string) { + if _, exists := set.set[value]; exists { + return + } + set.set[value] = struct{}{} + set.keys = append(set.keys, value) +} + +func (set *OrderedSet) Has(value string) bool { + _, exists := set.set[value] + return exists +} + +func (set *OrderedSet) Values() []string { + return append([]string{}, set.keys...) +} + +func Loaded(messages []msgmodel.WithParts) *OrderedSet { + paths := NewOrderedSet() + for _, message := range messages { + for _, part := range message.Parts { + tool, ok := part.(msgmodel.ToolPart) + if !ok || tool.Tool != "read" { + continue + } + completed, ok := tool.State.(msgmodel.ToolStateCompleted) + if !ok || completed.Time.Compacted != nil && *completed.Time.Compacted != 0 { + continue + } + raw, ok := completed.Metadata.Field("loaded") + if !ok { + continue + } + var loaded []json.RawMessage + if json.Unmarshal(raw, &loaded) != nil || loaded == nil { + continue + } + for _, item := range loaded { + trimmed := strings.TrimSpace(string(item)) + if len(trimmed) == 0 || trimmed[0] != '"' { + continue + } + var path string + if json.Unmarshal(item, &path) == nil { + paths.Add(path) + } + } + } + } + return paths +} + +type Config struct { + Instructions []string +} + +type Global struct { + Config string + Home string +} + +type Instance struct { + Directory string + Worktree string +} + +type Flags struct { + DisableClaudeCodePrompt bool + DisableProjectConfig bool +} + +type FileSystem interface { + ExistsSafe(path string) bool + ReadFileString(path string) (string, error) + FindUp(target, start, stop string) ([]string, error) + GlobUp(pattern, start, stop string) ([]string, error) + Glob(pattern, cwd string) ([]string, error) +} + +type HTTPClient interface { + Fetch(ctx context.Context, url string) ([]byte, error) +} + +type Options struct { + Config Config + Global Global + Instance Instance + Flags Flags + FS FileSystem + HTTP HTTPClient +} + +type Service struct { + options Options + files []string + + mu sync.Mutex + claims map[string]*OrderedSet +} + +func New(options Options) *Service { + if options.FS == nil { + options.FS = OSFileSystem{} + } + if options.HTTP == nil { + options.HTTP = &DefaultHTTPClient{} + } + files := []string{"AGENTS.md"} + if !options.Flags.DisableClaudeCodePrompt { + files = append(files, "CLAUDE.md") + } + files = append(files, "CONTEXT.md") + return &Service{ + options: options, files: files, claims: make(map[string]*OrderedSet), + } +} + +func (service *Service) Clear(messageID string) { + service.mu.Lock() + delete(service.claims, messageID) + service.mu.Unlock() +} + +func (service *Service) SystemPaths() *OrderedSet { + paths := NewOrderedSet() + globalFiles := []string{filepath.Join(service.options.Global.Config, "AGENTS.md")} + if !service.options.Flags.DisableClaudeCodePrompt { + globalFiles = append(globalFiles, filepath.Join( + service.options.Global.Home, ".claude", "CLAUDE.md", + )) + } + for _, file := range globalFiles { + if service.options.FS.ExistsSafe(file) { + paths.Add(resolve(file)) + break + } + } + + if !service.options.Flags.DisableProjectConfig { + for _, file := range service.files { + matches, _ := service.options.FS.FindUp( + file, service.options.Instance.Directory, service.options.Instance.Worktree, + ) + if len(matches) > 0 { + for _, item := range matches { + paths.Add(resolve(item)) + } + break + } + } + } + + for _, raw := range service.options.Config.Instructions { + if isURL(raw) { + continue + } + instruction := raw + if strings.HasPrefix(raw, "~/") { + instruction = filepath.Join(service.options.Global.Home, raw[2:]) + } + var matches []string + if filepath.IsAbs(instruction) { + matches, _ = service.options.FS.Glob( + filepath.Base(instruction), filepath.Dir(instruction), + ) + } else if !service.options.Flags.DisableProjectConfig { + matches, _ = service.options.FS.GlobUp( + instruction, service.options.Instance.Directory, + service.options.Instance.Worktree, + ) + } else { + matches, _ = service.options.FS.GlobUp( + instruction, service.options.Global.Config, + service.options.Global.Config, + ) + } + for _, item := range matches { + paths.Add(resolve(item)) + } + } + return paths +} + +func (service *Service) System(ctx context.Context) []string { + paths := service.SystemPaths().Values() + urls := []string{} + for _, item := range service.options.Config.Instructions { + if isURL(item) { + urls = append(urls, item) + } + } + files := parallelStrings(len(paths), 8, func(index int) string { + content, err := service.options.FS.ReadFileString(paths[index]) + if err != nil { + return "" + } + return content + }) + remote := parallelStrings(len(urls), 4, func(index int) string { + callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + body, err := service.options.HTTP.Fetch(callCtx, urls[index]) + if err != nil { + return "" + } + return strings.ToValidUTF8(string(body), "\uFFFD") + }) + out := []string{} + for index, path := range paths { + if files[index] != "" { + out = append(out, "Instructions from: "+path+"\n"+files[index]) + } + } + for index, url := range urls { + if remote[index] != "" { + out = append(out, "Instructions from: "+url+"\n"+remote[index]) + } + } + return out +} + +func (service *Service) Find(dir string) *string { + for _, file := range service.files { + path := resolve(filepath.Join(dir, file)) + if service.options.FS.ExistsSafe(path) { + return &path + } + } + return nil +} + +type Resolved struct { + Filepath string `json:"filepath"` + Content string `json:"content"` +} + +func (service *Service) Resolve( + messages []msgmodel.WithParts, file string, messageID string, +) []Resolved { + system := service.SystemPaths() + already := Loaded(messages) + results := []Resolved{} + root := resolve(service.options.Instance.Directory) + target := resolve(file) + current := filepath.Dir(target) + for strings.HasPrefix(current, root) && current != root { + found := service.Find(current) + if found == nil || *found == target || system.Has(*found) || already.Has(*found) { + current = filepath.Dir(current) + continue + } + + service.mu.Lock() + claimed := service.claims[messageID] + if claimed == nil { + claimed = NewOrderedSet() + service.claims[messageID] = claimed + } + if claimed.Has(*found) { + service.mu.Unlock() + current = filepath.Dir(current) + continue + } + claimed.Add(*found) + service.mu.Unlock() + + content, err := service.options.FS.ReadFileString(*found) + if err == nil && content != "" { + results = append(results, Resolved{ + Filepath: *found, + Content: "Instructions from: " + *found + "\n" + content, + }) + } + current = filepath.Dir(current) + } + return results +} + +func parallelStrings(count, limit int, work func(int) string) []string { + out := make([]string, count) + if count == 0 { + return out + } + semaphore := make(chan struct{}, limit) + var group sync.WaitGroup + for index := range count { + group.Add(1) + go func(index int) { + defer group.Done() + semaphore <- struct{}{} + out[index] = work(index) + <-semaphore + }(index) + } + group.Wait() + return out +} + +func isURL(value string) bool { + return strings.HasPrefix(value, "https://") || + strings.HasPrefix(value, "http://") +} + +func resolve(path string) string { + value, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(value) +} + +type OSFileSystem struct{} + +func (OSFileSystem) ExistsSafe(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func (OSFileSystem) ReadFileString(path string) (string, error) { + data, err := os.ReadFile(path) + return strings.ToValidUTF8(string(data), "\uFFFD"), err +} + +func (filesystem OSFileSystem) FindUp( + target, start, stop string, +) ([]string, error) { + result := []string{} + current := start + for { + search := filepath.Join(current, target) + if filesystem.ExistsSafe(search) { + result = append(result, search) + } + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result, nil +} + +func (filesystem OSFileSystem) GlobUp( + pattern, start, stop string, +) ([]string, error) { + result := []string{} + current := start + for { + matches, _ := filesystem.Glob(pattern, current) + result = append(result, matches...) + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result, nil +} + +func (OSFileSystem) Glob(pattern, cwd string) ([]string, error) { + re, err := globRegexp(filepath.ToSlash(pattern)) + if err != nil { + return nil, err + } + result := []string{} + err = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(cwd, path) + if err != nil { + return err + } + if re.MatchString(filepath.ToSlash(relative)) { + result = append(result, resolve(path)) + } + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return result, err +} + +func globRegexp(pattern string) (*regexp.Regexp, error) { + var out strings.Builder + out.WriteString("^") + for index := 0; index < len(pattern); { + switch pattern[index] { + case '*': + if index+1 < len(pattern) && pattern[index+1] == '*' { + index += 2 + if index < len(pattern) && pattern[index] == '/' { + index++ + out.WriteString("(?:.*/)?") + } else { + out.WriteString(".*") + } + } else { + index++ + out.WriteString("[^/]*") + } + case '?': + index++ + out.WriteString("[^/]") + default: + out.WriteString(regexp.QuoteMeta(string(pattern[index]))) + index++ + } + } + out.WriteString("$") + return regexp.Compile(out.String()) +} + +type DefaultHTTPClient struct { + Client *http.Client +} + +func (client *DefaultHTTPClient) Fetch( + ctx context.Context, url string, +) ([]byte, error) { + httpClient := client.Client + if httpClient == nil { + httpClient = http.DefaultClient + } + var last error + for attempt := 0; attempt < 3; attempt++ { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + response, err := httpClient.Do(request) + if err == nil && response.StatusCode >= 200 && response.StatusCode < 300 { + data, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + return data, readErr + } + transient := err != nil + if response != nil { + _ = response.Body.Close() + err = errors.New(response.Status) + transient = response.StatusCode == http.StatusRequestTimeout || + response.StatusCode == http.StatusTooManyRequests || + response.StatusCode >= 500 + } + last = err + if !transient { + return nil, last + } + if attempt < 2 { + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case <-time.After(time.Duration(200*(1< 0 { + picked, err := router.PickContext(ctx, input.Agent.Name, tier) + if err != nil { + return ResolvedCall{}, err + } + choice = &picked + if picked.Candidate.ID != fullID(model) { + providerID, modelID := splitModelID(picked.Candidate.ID) + routed, resolveErr := s.getModel(ctx, providerID, modelID) + if resolveErr != nil { + if ctx.Err() != nil { + router.RegisterCanceled(picked) + return ResolvedCall{}, context.Cause(ctx) + } + router.Register(picked, 0, 0, errors.New("unresolvable model "+picked.Candidate.ID)) + choice = nil + } else { + model = routed + } + } + } + } + + // The language/client resolution fallback walks the complete pool in its + // existing order. The actual client is opened by Stream after assembly. + if s.Models != nil { + if _, err := s.Models.GetModel(ctx, model.ProviderID, model.ID); err != nil { + if ctx.Err() != nil { + if choice != nil { + router.RegisterCanceled(*choice) + } + return ResolvedCall{}, context.Cause(ctx) + } + found := false + for _, candidate := range candidates { + providerID, modelID := splitModelID(candidate.ID) + alternate, altErr := s.Models.GetModel(ctx, providerID, modelID) + if altErr == nil { + model = alternate + found = true + break + } + } + if !found { + return ResolvedCall{}, err + } + } + } + + params := model.Params + params.ModelID = model.ID + params.Prompt = make([]msgmodel.ModelMessage, 0, len(input.System)+len(input.Messages)) + for _, system := range input.System { + params.Prompt = append(params.Prompt, msgmodel.ModelMessage{Role: "system", Content: system}) + } + params.Prompt = append(params.Prompt, input.Messages...) + params.ToolChoice = input.ToolChoice + params.Tools = resolveTools(input) + + isLiteLLM := strings.Contains(strings.ToLower(model.ProviderID), "litellm") || + strings.Contains(strings.ToLower(model.APIID), "litellm") + if (isLiteLLM || strings.Contains(model.ProviderID, "github-copilot")) && + len(params.Tools) == 0 && HasToolCalls(input.Messages) { + params.Tools = []orclient.Tool{noopTool()} + } + sort.SliceStable(params.Tools, func(i, j int) bool { + return params.Tools[i].Name < params.Tools[j].Name + }) + return ResolvedCall{Model: model, Choice: choice, Candidates: candidates, Params: params}, nil +} + +func (s *Service) Stream(ctx context.Context, input StreamInput) (Stream, error) { + if s.Clients == nil { + return nil, errors.New("llmcall: ClientFactory is required") + } + call, err := s.ResolveAndAssemble(ctx, input) + if err != nil { + return nil, err + } + if ctx.Err() != nil { + if call.Choice != nil { + s.resolveRouter().RegisterCanceled(*call.Choice) + } + return nil, context.Cause(ctx) + } + client, err := s.Clients.Client(ctx, call.Model, call.Choice, s.resolveRouter()) + if err != nil { + if call.Choice != nil && ctx.Err() != nil && + (errors.Is(err, ctx.Err()) || errors.Is(err, context.Cause(ctx))) { + s.resolveRouter().RegisterCanceled(*call.Choice) + } + return nil, err + } + return client.DoStream(ctx, call.Params) +} + +func (s *Service) getModel(ctx context.Context, providerID, modelID string) (Model, error) { + if s.Models == nil { + return Model{}, errors.New("llmcall: ModelResolver is required") + } + return s.Models.GetModel(ctx, providerID, modelID) +} + +func resolveTools(input StreamInput) []orclient.Tool { + out := make([]orclient.Tool, 0, len(input.Tools)) + for _, tool := range input.Tools { + if input.DisabledTools[tool.Name] { + continue + } + if enabled, present := input.UserTools[tool.Name]; present && !enabled { + continue + } + out = append(out, tool) + } + return out +} + +// HasToolCalls reports whether any message carries a tool call or tool +// result. Only array content is inspected. +func HasToolCalls(messages []msgmodel.ModelMessage) bool { + for _, message := range messages { + parts, ok := message.Content.([]any) + if !ok { + switch typed := message.Content.(type) { + case []msgmodel.ToolCallContent: + if len(typed) > 0 { + return true + } + case []msgmodel.ToolResultContent: + if len(typed) > 0 { + return true + } + } + continue + } + for _, part := range parts { + switch item := part.(type) { + case msgmodel.ToolCallContent, msgmodel.ToolResultContent: + return true + case map[string]any: + if item["type"] == "tool-call" || item["type"] == "tool-result" { + return true + } + case json.RawMessage: + var probe struct { + Type string `json:"type"` + } + if json.Unmarshal(item, &probe) == nil && (probe.Type == "tool-call" || probe.Type == "tool-result") { + return true + } + } + } + } + return false +} + +func splitModelID(full string) (string, string) { + index := strings.IndexByte(full, '/') + if index <= 0 { + return full, "" + } + return full[:index], full[index+1:] +} + +func fullID(model Model) string { return model.ProviderID + "/" + model.ID } + +func noopTool() orclient.Tool { + return orclient.Tool{ + Type: "function", + Name: "_noop", + Description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", + InputSchema: json.RawMessage(`{"type":"object","properties":{"reason":{"type":"string","description":"Unused"}}}`), + } +} + +// OpenRouterClientFactory wires a configured endpoint builder to llmcall. +type OpenRouterClientFactory func(ctx context.Context, model Model) (*orclient.Client, error) + +func (f OpenRouterClientFactory) Client(ctx context.Context, model Model, choice *adaptive.RouteChoice, router *adaptive.AdaptiveModelRouter) (StreamClient, error) { + client, err := f(ctx, model) + if err != nil { + return nil, err + } + client.Router = router + client.RouteChoice = choice + return concreteClient{client}, nil +} + +type concreteClient struct{ client *orclient.Client } + +func (c concreteClient) DoStream(ctx context.Context, params orclient.RequestParams) (Stream, error) { + return c.client.DoStream(ctx, params) +} diff --git a/internal/seniordev/session/llmcall/llmcall_test.go b/internal/seniordev/session/llmcall/llmcall_test.go new file mode 100644 index 000000000..6293153e3 --- /dev/null +++ b/internal/seniordev/session/llmcall/llmcall_test.go @@ -0,0 +1,107 @@ +//go:build !windows + +package llmcall + +import ( + "context" + "encoding/json" + "errors" + "io" + "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/router/adaptive" +) + +func TestResolveAssemblySortsAndInjectsNoop(t *testing.T) { + service := &Service{DisableRouting: true} + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + SessionID: "s", + Model: Model{ProviderID: "litellm-proxy", ID: "m", APIID: "m"}, + Messages: []msgmodel.ModelMessage{{ + Role: "assistant", + Content: []any{map[string]any{"type": "tool-call"}}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(call.Params.Tools) != 1 || call.Params.Tools[0].Name != "_noop" { + t.Fatalf("tools = %#v", call.Params.Tools) + } + if string(call.Params.Tools[0].InputSchema) != `{"type":"object","properties":{"reason":{"type":"string","description":"Unused"}}}` { + t.Fatalf("schema = %s", call.Params.Tools[0].InputSchema) + } +} + +func TestResolveUsesRouterChoiceAndFallback(t *testing.T) { + seed := float64(1) + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{{ID: "p/routed"}}, + RandomSeed: &seed, + }) + resolver := ModelResolverFunc(func(_ context.Context, provider, model string) (Model, error) { + if provider == "p" && model == "routed" { + return Model{ProviderID: provider, ID: model}, nil + } + if provider == "orig" { + return Model{ProviderID: provider, ID: model}, nil + } + return Model{}, errors.New("missing") + }) + service := &Service{Router: router, Models: resolver} + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + Model: Model{ProviderID: "orig", ID: "m"}, + Agent: Agent{Name: "coder"}, + }) + if err != nil { + t.Fatal(err) + } + if call.Model.ProviderID != "p" || call.Model.ID != "routed" || call.Choice == nil { + t.Fatalf("call = %#v", call) + } + // Release the pick for tests that share no process router state. + router.Register(*call.Choice, 0, 0, nil) +} + +func TestStreamPropagatesContextAndExactParams(t *testing.T) { + var got orclient.RequestParams + client := &fakeClient{run: func(ctx context.Context, params orclient.RequestParams) (Stream, error) { + if ctx.Value(contextKey{}) != "value" { + t.Fatal("context not propagated") + } + got = params + return &fakeStream{}, nil + }} + service := &Service{DisableRouting: true, Clients: ClientFactoryFunc(func(context.Context, Model, *adaptive.RouteChoice, *adaptive.AdaptiveModelRouter) (StreamClient, error) { + return client, nil + })} + ctx := context.WithValue(context.Background(), contextKey{}, "value") + stream, err := service.Stream(ctx, StreamInput{ + Model: Model{ProviderID: "p", ID: "m"}, + System: []string{"sys"}, + Tools: []orclient.Tool{{Name: "z", InputSchema: json.RawMessage(`{}`)}, {Name: "a", InputSchema: json.RawMessage(`{}`)}}, + }) + if err != nil { + t.Fatal(err) + } + _ = stream.Close() + if got.ModelID != "m" || len(got.Prompt) != 1 || got.Tools[0].Name != "a" { + t.Fatalf("params = %#v", got) + } +} + +type contextKey struct{} +type fakeClient struct { + run func(context.Context, orclient.RequestParams) (Stream, error) +} + +func (f *fakeClient) DoStream(ctx context.Context, params orclient.RequestParams) (Stream, error) { + return f.run(ctx, params) +} + +type fakeStream struct{} + +func (*fakeStream) Next() (orclient.StreamPart, error) { return nil, io.EOF } +func (*fakeStream) Close() error { return nil } diff --git a/internal/seniordev/session/llmcall/tier_test.go b/internal/seniordev/session/llmcall/tier_test.go new file mode 100644 index 000000000..201742952 --- /dev/null +++ b/internal/seniordev/session/llmcall/tier_test.go @@ -0,0 +1,78 @@ +//go:build !windows + +package llmcall + +import ( + "context" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// tierService wires a router with the given pools to a resolver that accepts +// every "p/" model, so the model a call ends up on names the pool it +// was routed from. +func tierService(low, frontier []adaptive.ModelCandidate) (*Service, *adaptive.AdaptiveModelRouter) { + seed := float64(3) + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{{ID: "p/high"}}, + LowModels: low, + FrontierModels: frontier, + RandomSeed: &seed, + }) + resolver := ModelResolverFunc(func(_ context.Context, provider, model string) (Model, error) { + if provider != "p" { + return Model{}, errors.New("missing") + } + return Model{ProviderID: provider, ID: model}, nil + }) + return &Service{Router: router, Models: resolver}, router +} + +func TestAgentTierSelectsThePool(t *testing.T) { + service, router := tierService( + []adaptive.ModelCandidate{{ID: "p/low"}}, + []adaptive.ModelCandidate{{ID: "p/frontier"}}, + ) + for tier, want := range map[adaptive.ModelTier]string{ + adaptive.ModelTierHigh: "high", + adaptive.ModelTierLow: "low", + adaptive.ModelTierFrontier: "frontier", + "": "high", + } { + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + Model: Model{ProviderID: "p", ID: "requested"}, + Agent: Agent{Name: "agent", Tier: tier}, + }) + if err != nil { + t.Fatalf("tier %q: %v", tier, err) + } + if call.Model.ID != want { + t.Errorf("tier %q routed to %q, want %q", tier, call.Model.ID, want) + } + router.Register(*call.Choice, 0, 0, nil) + } +} + +func TestAgentTierWithNoPoolRoutesOnHigh(t *testing.T) { + service, router := tierService(nil, nil) + for _, tier := range []adaptive.ModelTier{ + adaptive.ModelTierLow, adaptive.ModelTierFrontier, + } { + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + Model: Model{ProviderID: "p", ID: "requested"}, + Agent: Agent{Name: "agent", Tier: tier}, + }) + if err != nil { + t.Fatalf("tier %q: %v", tier, err) + } + if call.Model.ID != "high" { + t.Errorf("tier %q routed to %q, want the high pool", tier, call.Model.ID) + } + if call.Choice.Tier != adaptive.ModelTierHigh { + t.Errorf("tier %q recorded %q on the choice", tier, call.Choice.Tier) + } + router.Register(*call.Choice, 0, 0, nil) + } +} diff --git a/internal/seniordev/session/loopguard/loopguard.go b/internal/seniordev/session/loopguard/loopguard.go new file mode 100644 index 000000000..a7601bc13 --- /dev/null +++ b/internal/seniordev/session/loopguard/loopguard.go @@ -0,0 +1,338 @@ +//go:build !windows + +// Package loopguard is a pure repetition and budget guard for agent action +// loops: it stops a run that repeats the same action, cycles through a short +// sequence of actions, or exceeds a cost or action budget, and warns once +// before a budget runs out. It has no I/O and no clock; the caller persists +// the snapshot it returns. +package loopguard + +import ( + "math" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + repeatCapDefault = 3 + maxCyclePeriodDefault = 4 + cycleMinOccurrencesDefault = 2 + warnFractionDefault = 0.8 + // maxCyclePeriodScan bounds the cycle scan regardless of configuration: + // a cycle longer than half the retained history cannot be observed twice. + maxCyclePeriodScan = 512 +) + +// LoopAction is one observed tool invocation. CostUsd is optional. +type LoopAction struct { + Tool string `json:"tool"` + ArgsKey string `json:"argsKey"` + CostUsd *float64 `json:"costUsd,omitempty"` +} + +// LoopStatus is the verdict severity. +type LoopStatus string + +const ( + LoopStatusOK LoopStatus = "ok" + LoopStatusWarn LoopStatus = "warn" + LoopStatusStop LoopStatus = "stop" +) + +// LoopVerdict is the result of observing one action. Reason is nil for an +// "ok" verdict. +type LoopVerdict struct { + Status LoopStatus `json:"status"` + Reason *string `json:"reason,omitempty"` +} + +// LoopGuardOptions configures a guard. A nil field selects the default. +type LoopGuardOptions struct { + // RepeatCap is the number of identical consecutive actions that terminates the loop. + RepeatCap *float64 `json:"repeatCap,omitempty"` + // MaxCyclePeriod is the largest cycle period to inspect. + MaxCyclePeriod *float64 `json:"maxCyclePeriod,omitempty"` + // CycleMinOccurrences is the number of repeated copies required to identify a cycle. + CycleMinOccurrences *float64 `json:"cycleMinOccurrences,omitempty"` + // MaxCostUsd is an optional cumulative USD budget. + MaxCostUsd *float64 `json:"maxCostUsd,omitempty"` + // MaxActions is an optional maximum number of observed actions. + MaxActions *float64 `json:"maxActions,omitempty"` + // WarnFraction is the fraction of a budget at which a one-shot warning is emitted. + WarnFraction *float64 `json:"warnFraction,omitempty"` +} + +// LoopGuardSnapshot is the guard's persistable state. +type LoopGuardSnapshot struct { + Version int `json:"version"` + Actions []string `json:"actions"` + ActionCount int `json:"actionCount"` + CumulativeCostUsd float64 `json:"cumulativeCostUsd"` + WarnedCost bool `json:"warnedCost"` + WarnedActions bool `json:"warnedActions"` + Stopped bool `json:"stopped"` +} + +// LoopGuard observes actions and costs and reports when the loop should stop. +type LoopGuard interface { + Observe(action LoopAction) LoopVerdict + // ObserveCost charges provider calls that have no tool action. It does + // not alter repetition or action counts. + ObserveCost(costUsd *float64) LoopVerdict + Snapshot() LoopGuardSnapshot + // Restore ignores a nil snapshot and one with an unknown version. + Restore(snapshot *LoopGuardSnapshot) +} + +type loopGuard struct { + repeatCap int + maxCyclePeriod int + cycleMinOccurrences int + maxCostUsd *float64 + maxActions *int + warnFraction float64 + historyLimit int + + actions []string + actionCount int + cumulativeCostUsd float64 + warnedCost bool + warnedActions bool + stopped bool +} + +// CreateLoopGuard creates a stateful but otherwise pure loop guard. +func CreateLoopGuard(options LoopGuardOptions) LoopGuard { + repeatCap := positiveInteger(options.RepeatCap, repeatCapDefault) + maxCyclePeriod := max(2, positiveInteger(options.MaxCyclePeriod, maxCyclePeriodDefault)) + cycleMinOccurrences := max(2, positiveInteger(options.CycleMinOccurrences, cycleMinOccurrencesDefault)) + var maxActions *int + if options.MaxActions != nil && isFinite(*options.MaxActions) { + n := int(math.Ceil(math.Max(0, *options.MaxActions))) + maxActions = &n + } + warnFraction := warnFractionDefault + if options.WarnFraction != nil { + warnFraction = math.Min(1, math.Max(0, *options.WarnFraction)) + } + return &loopGuard{ + repeatCap: repeatCap, + maxCyclePeriod: maxCyclePeriod, + cycleMinOccurrences: cycleMinOccurrences, + maxCostUsd: optionalNonNegative(options.MaxCostUsd), + maxActions: maxActions, + warnFraction: warnFraction, + historyLimit: max(repeatCap, maxCyclePeriod*cycleMinOccurrences), + actions: []string{}, + } +} + +func (g *loopGuard) Observe(action LoopAction) LoopVerdict { + if g.stopped { + return LoopVerdict{Status: LoopStatusStop, Reason: strptr("loop guard already stopped")} + } + + g.actions = append(g.actions, encodeAction(action)) + if len(g.actions) > g.historyLimit { + g.actions = sliceLast(g.actions, g.historyLimit) + } + g.actionCount++ + + if action.CostUsd != nil && isFinite(*action.CostUsd) && *action.CostUsd > 0 { + g.cumulativeCostUsd += *action.CostUsd + } + + // A terminal repetition finding takes precedence over a budget warning. + if reason := exactRepeatReason(g.actions, g.repeatCap); reason != nil { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: reason} + } + if reason := cycleDetectionReason(g.actions, g.maxCyclePeriod, g.cycleMinOccurrences); reason != nil { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: reason} + } + + var stopReasons, warnReasons []string + if g.maxCostUsd != nil { + maxCostUsd := *g.maxCostUsd + if g.cumulativeCostUsd >= maxCostUsd { + stopReasons = append(stopReasons, + "cost budget reached ("+formatNumber(g.cumulativeCostUsd)+"/"+formatNumber(maxCostUsd)+" USD)") + } else if !g.warnedCost && g.cumulativeCostUsd >= maxCostUsd*g.warnFraction { + g.warnedCost = true + warnReasons = append(warnReasons, "cost budget at "+formatPercent(g.cumulativeCostUsd/maxCostUsd)) + } + } + if g.maxActions != nil { + maxActions := *g.maxActions + if g.actionCount >= maxActions { + stopReasons = append(stopReasons, + "action budget reached ("+strconv.Itoa(g.actionCount)+"/"+strconv.Itoa(maxActions)+")") + } else if !g.warnedActions && float64(g.actionCount) >= float64(maxActions)*g.warnFraction { + g.warnedActions = true + warnReasons = append(warnReasons, + "action budget at "+formatPercent(float64(g.actionCount)/float64(maxActions))) + } + } + + if len(stopReasons) > 0 { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: strptr(strings.Join(stopReasons, "; "))} + } + if len(warnReasons) > 0 { + return LoopVerdict{Status: LoopStatusWarn, Reason: strptr(strings.Join(warnReasons, "; "))} + } + return LoopVerdict{Status: LoopStatusOK} +} + +func (g *loopGuard) ObserveCost(costUsd *float64) LoopVerdict { + if g.stopped { + return LoopVerdict{Status: LoopStatusStop, Reason: strptr("loop guard already stopped")} + } + if costUsd != nil && isFinite(*costUsd) && *costUsd > 0 { + g.cumulativeCostUsd += *costUsd + } + if g.maxCostUsd == nil { + return LoopVerdict{Status: LoopStatusOK} + } + maximum := *g.maxCostUsd + if g.cumulativeCostUsd >= maximum { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: strptr( + "cost budget reached (" + formatNumber(g.cumulativeCostUsd) + "/" + formatNumber(maximum) + " USD)", + )} + } + if !g.warnedCost && g.cumulativeCostUsd >= maximum*g.warnFraction { + g.warnedCost = true + return LoopVerdict{Status: LoopStatusWarn, Reason: strptr( + "cost budget at " + formatPercent(g.cumulativeCostUsd/maximum), + )} + } + return LoopVerdict{Status: LoopStatusOK} +} + +func (g *loopGuard) Snapshot() LoopGuardSnapshot { + actions := make([]string, len(g.actions)) + copy(actions, g.actions) + return LoopGuardSnapshot{ + Version: 1, + Actions: actions, + ActionCount: g.actionCount, + CumulativeCostUsd: g.cumulativeCostUsd, + WarnedCost: g.warnedCost, + WarnedActions: g.warnedActions, + Stopped: g.stopped, + } +} + +func (g *loopGuard) Restore(snapshot *LoopGuardSnapshot) { + if snapshot == nil || snapshot.Version != 1 { + return + } + if snapshot.Actions != nil { + g.actions = sliceLast(snapshot.Actions, g.historyLimit) + } + if snapshot.ActionCount >= 0 { + g.actionCount = snapshot.ActionCount + } + if isFinite(snapshot.CumulativeCostUsd) && snapshot.CumulativeCostUsd >= 0 { + g.cumulativeCostUsd = snapshot.CumulativeCostUsd + } + g.warnedCost = snapshot.WarnedCost + g.warnedActions = snapshot.WarnedActions + g.stopped = snapshot.Stopped +} + +// encodeAction is the history key for an action: the JSON array of its tool +// name and argument key. +func encodeAction(action LoopAction) string { + encoded, err := jsonutil.Marshal([2]string{action.Tool, action.ArgsKey}) + if err != nil { + return action.Tool + "\x00" + action.ArgsKey + } + return string(encoded) +} + +func exactRepeatReason(history []string, cap int) *string { + if len(history) < cap { + return nil + } + last := history[len(history)-1] + for i := len(history) - 2; i >= len(history)-cap; i-- { + if history[i] != last { + return nil + } + } + return strptr("exact action repeated " + strconv.Itoa(cap) + " times consecutively") +} + +func cycleDetectionReason(history []string, maxPeriod int, minOccurrences int) *string { + limit := min(maxPeriod, len(history)/2, maxCyclePeriodScan) + for period := 2; period <= limit; period++ { + required := period * minOccurrences + if len(history) < required { + continue + } + start := len(history) - required + matches := true + for offset := period; offset < required && matches; offset++ { + if history[start+offset] != history[start+offset%period] { + matches = false + } + } + if matches { + return strptr("cycle detected with period " + strconv.Itoa(period) + + " (" + strconv.Itoa(minOccurrences) + " occurrences)") + } + } + return nil +} + +func positiveInteger(value *float64, fallback int) int { + if value != nil && isFinite(*value) { + return int(math.Max(1, math.Min(math.Floor(*value), math.MaxInt32))) + } + return fallback +} + +func optionalNonNegative(value *float64) *float64 { + if value != nil && isFinite(*value) { + clamped := math.Max(0, *value) + return &clamped + } + return nil +} + +func strptr(value string) *string { return &value } + +// formatNumber prints a USD amount with at most four decimals. +func formatNumber(value float64) string { + text := strconv.FormatFloat(value, 'f', 4, 64) + if strings.Contains(text, ".") { + text = strings.TrimRight(text, "0") + text = strings.TrimSuffix(text, ".") + } + return text +} + +// formatPercent prints a ratio as a whole percentage. +func formatPercent(value float64) string { + return strconv.Itoa(int(math.Round(value*100))) + "%" +} + +// sliceLast returns a copy of the last limit values. +func sliceLast(values []string, limit int) []string { + start := 0 + if len(values) > limit { + start = len(values) - limit + } + out := make([]string, len(values)-start) + copy(out, values[start:]) + return out +} + +func isFinite(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/internal/seniordev/session/loopguard/loopguard_test.go b/internal/seniordev/session/loopguard/loopguard_test.go new file mode 100644 index 000000000..6de2555ac --- /dev/null +++ b/internal/seniordev/session/loopguard/loopguard_test.go @@ -0,0 +1,231 @@ +//go:build !windows + +package loopguard + +import ( + "fmt" + "math" + "reflect" + "strings" + "testing" +) + +func action(tool string, argsKey string, costUsd ...float64) LoopAction { + act := LoopAction{Tool: tool, ArgsKey: argsKey} + if len(costUsd) > 0 { + value := costUsd[0] + act.CostUsd = &value + } + return act +} + +// expectVerdict asserts the status and the absence of a reason. +func expectVerdict(t *testing.T, got LoopVerdict, status LoopStatus) { + t.Helper() + if got.Status != status || got.Reason != nil { + t.Fatalf("expected {status: %q}, got %s", status, describeVerdict(got)) + } +} + +func expectStatus(t *testing.T, got LoopVerdict, status LoopStatus) { + t.Helper() + if got.Status != status { + t.Fatalf("expected status %q, got %s", status, describeVerdict(got)) + } +} + +func expectReasonContains(t *testing.T, got LoopVerdict, substring string) { + t.Helper() + if got.Reason == nil || !strings.Contains(*got.Reason, substring) { + t.Fatalf("expected reason containing %q, got %s", substring, describeVerdict(got)) + } +} + +func describeVerdict(verdict LoopVerdict) string { + if verdict.Reason == nil { + return fmt.Sprintf("{status: %q, reason: nil}", verdict.Status) + } + return fmt.Sprintf("{status: %q, reason: %q}", verdict.Status, *verdict.Reason) +} + +func floatptr(value float64) *float64 { return &value } + +func TestCreateLoopGuardRepetitionDetection(t *testing.T) { + t.Run("stops exactly at the consecutive-repeat cap", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + expectVerdict(t, guard.Observe(action("search", "same")), LoopStatusOK) + expectVerdict(t, guard.Observe(action("search", "same")), LoopStatusOK) + verdict := guard.Observe(action("search", "same")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "repeated 3 times") + }) + + t.Run("detects a period-two cycle after two occurrences", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Observe(action("read", "a")) + guard.Observe(action("write", "b")) + guard.Observe(action("read", "a")) + verdict := guard.Observe(action("write", "b")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "period 2") + }) + + t.Run("detects a period-three cycle after two occurrences", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + for _, item := range [][2]string{{"a", "1"}, {"b", "2"}, {"c", "3"}, {"a", "1"}, {"b", "2"}} { + expectStatus(t, guard.Observe(action(item[0], item[1])), LoopStatusOK) + } + verdict := guard.Observe(action("c", "3")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "period 3") + }) + + t.Run("does not flag progressing work with varied arguments", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + for i := 0; i < 20; i++ { + verdict := guard.Observe(action("search", fmt.Sprintf("query-%d", i))) + expectStatus(t, verdict, LoopStatusOK) + } + }) + + t.Run("a huge max cycle period is bounded by the history", func(t *testing.T) { + huge := 1e21 + guard := CreateLoopGuard(LoopGuardOptions{MaxCyclePeriod: &huge}) + guard.Observe(action("read", "a")) + guard.Observe(action("write", "b")) + guard.Observe(action("read", "a")) + verdict := guard.Observe(action("write", "b")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "period 2") + }) +} + +func TestCreateLoopGuardBudgets(t *testing.T) { + t.Run("warns before stopping on a cost budget", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(10)}) + expectStatus(t, guard.Observe(action("a", "1", 4)), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "2", 4)), LoopStatusWarn) + expectStatus(t, guard.Observe(action("a", "3", 2)), LoopStatusStop) + }) + + t.Run("warns before stopping on an action budget", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{MaxActions: floatptr(5)}) + expectStatus(t, guard.Observe(action("a", "1")), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "2")), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "3")), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "4")), LoopStatusWarn) + verdict := guard.Observe(action("a", "5")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "action budget reached (5/5)") + }) + + t.Run("cost-only observations charge the budget", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(1)}) + expectStatus(t, guard.ObserveCost(floatptr(0.5)), LoopStatusOK) + expectStatus(t, guard.ObserveCost(floatptr(0.3)), LoopStatusWarn) + verdict := guard.ObserveCost(floatptr(0.25)) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "cost budget reached (1.05/1 USD)") + }) +} + +func TestCreateLoopGuardSnapshotAndRestore(t *testing.T) { + t.Run("round-trips repetition and budget state", func(t *testing.T) { + original := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(10)}) + original.Observe(action("read", "same", 4)) + expectStatus(t, original.Observe(action("read", "same", 4)), LoopStatusWarn) + + restored := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(10)}) + originalSnapshot := original.Snapshot() + restored.Restore(&originalSnapshot) + expectStatus(t, restored.Observe(action("read", "same", 2)), LoopStatusStop) + expectStatus(t, original.Observe(action("read", "same", 2)), LoopStatusStop) + if !reflect.DeepEqual(restored.Snapshot(), original.Snapshot()) { + t.Fatalf("snapshots differ:\nrestored: %+v\noriginal: %+v", restored.Snapshot(), original.Snapshot()) + } + }) + + t.Run("ignores malformed snapshots", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Restore(nil) + guard.Restore(&LoopGuardSnapshot{Version: 2, Actions: []string{}}) + expectStatus(t, guard.Observe(action("a", "1")), LoopStatusOK) + }) + + t.Run("rejects non-finite or negative numbers", func(t *testing.T) { + for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), -1} { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Observe(action("a", "1", 2.5)) + guard.Restore(&LoopGuardSnapshot{Version: 1, ActionCount: -1, CumulativeCostUsd: value}) + snapshot := guard.Snapshot() + if snapshot.ActionCount != 1 || snapshot.CumulativeCostUsd != 2.5 { + t.Errorf("restore(%v) overwrote state: %+v", value, snapshot) + } + } + }) + + t.Run("does not alias guard state", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Observe(action("a", "1")) + snapshot := guard.Snapshot() + snapshot.Actions[0] = "tampered" + if guard.Snapshot().Actions[0] == "tampered" { + t.Error("Snapshot aliases the guard's action history") + } + + other := CreateLoopGuard(LoopGuardOptions{}) + restoreFrom := LoopGuardSnapshot{Version: 1, Actions: []string{`["a","1"]`}} + other.Restore(&restoreFrom) + other.Observe(action("b", "2")) + if len(restoreFrom.Actions) != 1 || restoreFrom.Actions[0] != `["a","1"]` { + t.Errorf("Restore aliases the caller's snapshot: %+v", restoreFrom.Actions) + } + }) +} + +func TestEncodeActionIsAJSONArray(t *testing.T) { + got := encodeAction(LoopAction{Tool: "bash", ArgsKey: "ls & echo"}) + want := `["bash","ls & echo"]` + if got != want { + t.Fatalf("encodeAction = %q, want %q", got, want) + } + if encodeAction(LoopAction{Tool: "a", ArgsKey: "b"}) == encodeAction(LoopAction{Tool: "ab", ArgsKey: ""}) { + t.Fatal("distinct actions must not collide") + } +} + +func TestFormatNumber(t *testing.T) { + cases := []struct { + value float64 + want string + }{ + {10, "10"}, + {0, "0"}, + {0.5, "0.5"}, + {12.345678, "12.3457"}, + {0.00001, "0"}, + {-10.5, "-10.5"}, + } + for _, testCase := range cases { + if got := formatNumber(testCase.value); got != testCase.want { + t.Errorf("formatNumber(%v) = %q, want %q", testCase.value, got, testCase.want) + } + } +} + +func TestFormatPercent(t *testing.T) { + cases := []struct { + value float64 + want string + }{ + {0.8, "80%"}, + {0.845, "85%"}, + {2.0 / 3.0, "67%"}, + {0, "0%"}, + } + for _, testCase := range cases { + if got := formatPercent(testCase.value); got != testCase.want { + t.Errorf("formatPercent(%v) = %q, want %q", testCase.value, got, testCase.want) + } + } +} diff --git a/internal/seniordev/session/outputoffload/outputoffload.go b/internal/seniordev/session/outputoffload/outputoffload.go new file mode 100644 index 000000000..f1f90b3d6 --- /dev/null +++ b/internal/seniordev/session/outputoffload/outputoffload.go @@ -0,0 +1,347 @@ +//go:build !windows + +// Package outputoffload keeps large tool outputs out of the context window: +// the full text is written to a file under .senior-dev/tool-output and the model +// sees a bounded extract (head, tail and diagnostic-looking lines) plus the +// path. +package outputoffload + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + HEAD_LINES = 20 + TAIL_LINES = 40 + EXTRACT_MAX_CHARS = 6_000 + OFFLOAD_THRESHOLD_CHARS = 12_000 +) + +var unsafeCallIDRE = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +type DistillHook func(fullOutput string) (string, error) + +type ExtractRelevantOptions struct { + FullOutputPath *string `json:"fullOutputPath"` + Path *string `json:"path"` + MaxChars *float64 `json:"maxChars"` +} + +type OutputOffloadInput struct { + Output string `json:"output"` + Workspace string `json:"workspace"` + ToolName string `json:"toolName"` + CallID string `json:"callId"` + SessionID string `json:"sessionId,omitempty"` + EscalationWanted bool `json:"escalationWanted"` +} + +type OutputOffloadOptions struct { + Hook DistillHook `json:"-"` + EscalationWanted bool `json:"escalationWanted"` + Force bool `json:"-"` +} + +type OutputOffloadResult struct { + Inline string + OffloadPath *string + EscalationWanted bool +} + +// MarshalJSON emits inline first and the optional offloadPath and +// escalationWanted only when they are set. +func (r OutputOffloadResult) MarshalJSON() ([]byte, error) { + inline, err := jsonutil.Marshal(r.Inline) + if err != nil { + return nil, err + } + var b bytes.Buffer + b.WriteString(`{"inline":`) + b.Write(inline) + if r.OffloadPath != nil { + path, err := jsonutil.Marshal(*r.OffloadPath) + if err != nil { + return nil, err + } + b.WriteString(`,"offloadPath":`) + b.Write(path) + } + if r.EscalationWanted { + b.WriteString(`,"escalationWanted":true`) + } + b.WriteByte('}') + return b.Bytes(), nil +} + +// OutputSink is the filesystem side effect behind offloadLargeOutput. +type OutputSink interface { + WriteOutput(path string, output string) (string, error) +} + +type DiskSink struct{} + +func (DiskSink) WriteOutput(path string, output string) (string, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + ext := filepath.Ext(path) + stem := strings.TrimSuffix(path, ext) + // Bounded so a pathological collision space cannot livelock a + // synchronous tool call past the run deadline. + const maxCollisions = 100 + for collision := 1; collision <= maxCollisions; collision++ { + candidate := path + if collision > 1 { + candidate = stem + "-" + strconv.Itoa(collision) + ext + } + file, err := os.OpenFile(candidate, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return "", err + } + _, writeErr := file.WriteString(output) + closeErr := file.Close() + if writeErr != nil { + _ = os.Remove(candidate) + return "", writeErr + } + if closeErr != nil { + _ = os.Remove(candidate) + return "", closeErr + } + return candidate, nil + } + return "", fmt.Errorf("outputoffload: exhausted %d collision candidates for %s", maxCollisions, path) +} + +type Offloader struct { + Sink OutputSink +} + +var DefaultOffloader = Offloader{Sink: DiskSink{}} + +// ExtractRelevant keeps the head, tail, and diagnostic-looking lines. +func ExtractRelevant(output string, opts *ExtractRelevantOptions) string { + fullOutputPath := "" + maxChars := float64(EXTRACT_MAX_CHARS) + if opts != nil { + if opts.FullOutputPath != nil { + fullOutputPath = *opts.FullOutputPath + } else if opts.Path != nil { + fullOutputPath = *opts.Path + } + if opts.MaxChars != nil { + maxChars = *opts.MaxChars + } + } + lines := splitCRLF(output) + selected := make([]bool, len(lines)) + for i := 0; i < min(HEAD_LINES, len(lines)); i++ { + selected[i] = true + } + for i := max(0, len(lines)-TAIL_LINES); i < len(lines); i++ { + selected[i] = true + } + for i, line := range lines { + if hasErrorText(line) { + selected[i] = true + } + } + + chunks := []string{} + seenLines := make(map[string]bool) + cursor := 0 + for index, keep := range selected { + if !keep { + continue + } + if index > cursor { + chunks = append(chunks, omission(index-cursor, fullOutputPath)) + } + line := lines[index] + if !seenLines[line] { + chunks = append(chunks, line) + seenLines[line] = true + } + cursor = index + 1 + } + if cursor < len(lines) { + chunks = append(chunks, omission(len(lines)-cursor, fullOutputPath)) + } + return capExtract(strings.Join(chunks, "\n"), maxChars, fullOutputPath) +} + +// OffloadLargeOutput uses the production filesystem sink. options accepts +// nil, OutputOffloadOptions (or a pointer), or a DistillHook. +func OffloadLargeOutput(input OutputOffloadInput, options any) OutputOffloadResult { + return DefaultOffloader.OffloadLargeOutput(input, options) +} + +// OffloadLargeOutput applies the offload policy with an injected sink. +func (o Offloader) OffloadLargeOutput(input OutputOffloadInput, options any) OutputOffloadResult { + escalationRequested := input.EscalationWanted + force := false + switch value := options.(type) { + case DistillHook: + escalationRequested = true + case func(string) (string, error): + escalationRequested = true + case OutputOffloadOptions: + escalationRequested = escalationRequested || value.EscalationWanted + force = value.Force + case *OutputOffloadOptions: + if value != nil { + escalationRequested = escalationRequested || value.EscalationWanted + force = value.Force + } + } + + if !force && charCount(input.Output) <= OFFLOAD_THRESHOLD_CHARS { + return resultWithEscalation(input.Output, nil, escalationRequested, ExtractRelevant(input.Output, nil)) + } + + offloadPath := outputPathFor(input) + sink := o.Sink + if sink == nil { + sink = DiskSink{} + } + actualPath, err := sink.WriteOutput(offloadPath, input.Output) + if err != nil { + return resultWithEscalation(plainTruncation(input.Output), nil, escalationRequested, ExtractRelevant(input.Output, nil)) + } + offloadPath = actualPath + + extract := ExtractRelevant(input.Output, &ExtractRelevantOptions{FullOutputPath: &offloadPath}) + handle := "Full output saved to " + offloadPath + " — read it only if the extract is insufficient." + return resultWithEscalation(extract+"\n\n"+handle, &offloadPath, escalationRequested, extract) +} + +func outputPathFor(input OutputOffloadInput) string { + safeCallID := unsafeCallIDRE.ReplaceAllString(input.CallID, "_") + if safeCallID == "" { + safeCallID = "unknown" + } + dir := filepath.Join(input.Workspace, ".senior-dev", "tool-output") + if input.SessionID != "" { + safeSessionID := unsafeCallIDRE.ReplaceAllString(input.SessionID, "_") + if safeSessionID == "" { + safeSessionID = "unknown" + } + dir = filepath.Join(dir, safeSessionID) + } + return filepath.Join(dir, safeCallID+".log") +} + +func resultWithEscalation(inline string, path *string, requested bool, relevant string) OutputOffloadResult { + return OutputOffloadResult{ + Inline: inline, + OffloadPath: path, + EscalationWanted: requested && !hasErrorLine(relevant), + } +} + +func hasErrorLine(output string) bool { + for _, line := range splitCRLF(output) { + if hasErrorText(line) { + return true + } + } + return false +} + +func hasErrorText(line string) bool { + lower := asciiLower(line) + return strings.Contains(lower, "error") || + strings.Contains(lower, "fail") || + strings.Contains(lower, "assert") || + strings.Contains(lower, "exception") || + strings.Contains(lower, "panic") || + strings.Contains(line, "✗") +} + +func omission(count int, fullOutputPath string) string { + return "[... " + strconv.Itoa(count) + " lines omitted — full output at " + fullOutputPath + "]" +} + +func capExtract(text string, maxChars float64, fullOutputPath string) string { + limit := int(maxChars) + if charCount(text) <= limit { + return text + } + marker := "\n[... extract capped at " + strconv.Itoa(limit) + " chars — full output at " + fullOutputPath + "]" + if charCount(marker) >= limit { + return firstChars(marker, limit) + } + return firstChars(text, limit-charCount(marker)) + marker +} + +func plainTruncation(output string) string { + note := "[... full output could not be saved; showing a plain truncation]" + if charCount(output) <= EXTRACT_MAX_CHARS { + return output + } + if charCount(note) >= EXTRACT_MAX_CHARS { + return firstChars(note, EXTRACT_MAX_CHARS) + } + return firstChars(output, EXTRACT_MAX_CHARS-charCount(note)-1) + "\n" + note +} + +func splitCRLF(s string) []string { + lines := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] != '\n' { + continue + } + end := i + if end > start && s[end-1] == '\r' { + end-- + } + lines = append(lines, s[start:end]) + start = i + 1 + } + return append(lines, s[start:]) +} + +// charCount is the length of s in characters (runes). +func charCount(s string) int { return utf8.RuneCountInString(s) } + +// firstChars returns the first n characters of s without splitting a +// multi-byte character. +func firstChars(s string, n int) string { + if n <= 0 { + return "" + } + for i := range s { + if n == 0 { + return s[:i] + } + n-- + } + return s +} + +func asciiLower(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + b.WriteByte(c) + } + return b.String() +} diff --git a/internal/seniordev/session/overflow/overflow.go b/internal/seniordev/session/overflow/overflow.go new file mode 100644 index 000000000..8b77b99ab --- /dev/null +++ b/internal/seniordev/session/overflow/overflow.go @@ -0,0 +1,37 @@ +//go:build !windows + +// Package overflow is the session-layer view of the compaction budget. +// +// The arithmetic lives in internal/engine/calc, where the engine already +// consumes this module's decisions. Type aliases keep both call sites on one +// implementation. +package overflow + +import "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + +type CompactionConfig = calc.CompactionConfig +type Config = calc.Config +type ModelLimit = calc.ModelLimit +type Model = calc.Model +type TokenCache = calc.TokenCache +type Tokens = calc.Tokens + +type UsableInput = calc.UsableInput +type OverflowInput = calc.OverflowInput +type CompactionWatermarks = calc.CompactionWatermarks + +const PolicyWindow = calc.PolicyWindow + +func ValidatePolicy(cfg Config) error { return calc.ValidatePolicy(cfg) } + +func EffectiveInputCapacity(input UsableInput) float64 { + return calc.EffectiveInputCapacity(input) +} + +func Watermarks(input UsableInput) CompactionWatermarks { + return calc.Watermarks(input) +} + +func IsOverflow(input OverflowInput) bool { + return calc.IsOverflow(input) +} diff --git a/internal/seniordev/session/projectors/busy_retry.go b/internal/seniordev/session/projectors/busy_retry.go new file mode 100644 index 000000000..d032e0ffb --- /dev/null +++ b/internal/seniordev/session/projectors/busy_retry.go @@ -0,0 +1,165 @@ +//go:build !windows + +// The SQLite cold-start retry policy. It lives beside the projector database +// because the only retried operation is that database's first journal_mode +// pragma. +package projectors + +import ( + "fmt" + "io" + "math/rand/v2" + "os" + "strings" + "time" +) + +const ( + defaultMaxAttempts = 5 + defaultBaseDelayMS = 100 + defaultMaxDelayMS = 400 +) + +// BusyRetryOptions controls WithBusyRetry. Nil numeric fields select the +// defaults; pointers preserve the distinction between omitted and explicitly +// zero options. +type BusyRetryOptions struct { + MaxAttempts *int + BaseDelayMS *int + MaxDelayMS *int + DBPath string + Random func() float64 + Sleep func(time.Duration) + Log io.Writer +} + +type sqliteCodeError interface { + Code() int +} + +type namedCodeError interface { + CodeName() string +} + +// IsBusyError reports whether err or one of at most depth wrapped causes is a +// SQLite BUSY-class error. The default depth is 5, meaning the outer error +// plus five causes are inspected. +func IsBusyError(err error, depth ...int) bool { + limit := 5 + if len(depth) > 0 { + limit = depth[0] + } + for i := 0; i <= limit && err != nil; i++ { + if isBusyErrorShallow(err) { + return true + } + err = unwrapOnce(err) + } + return false +} + +func isBusyErrorShallow(err error) bool { + if named, ok := err.(namedCodeError); ok { + switch named.CodeName() { + case "SQLITE_BUSY", "SQLITE_BUSY_RECOVERY", "SQLITE_BUSY_SNAPSHOT", "SQLITE_BUSY_TIMEOUT": + return true + } + } + if coded, ok := err.(sqliteCodeError); ok { + code := coded.Code() + if code&0xff == 5 || code == 5 { + return true + } + } + return strings.Contains(strings.ToUpper(err.Error()), "SQLITE_BUSY") +} + +type unwrapper interface { + Unwrap() error +} + +func unwrapOnce(err error) error { + if wrapped, ok := err.(unwrapper); ok { + return wrapped.Unwrap() + } + return nil +} + +// BusyRetryError is returned after all BUSY-class attempts are exhausted. +// Unwrap preserves the last SQLite failure as the cause. +type BusyRetryError struct { + Message string + Cause error +} + +func (e *BusyRetryError) Error() string { return e.Message } +func (e *BusyRetryError) Unwrap() error { return e.Cause } + +// WithBusyRetry runs fn and retries only BUSY-class errors, sleeping a +// 100–400 ms (inclusive, by default) jitter between attempts. +func WithBusyRetry[T any](fn func() (T, error), opts BusyRetryOptions) (T, error) { + attempts := defaultMaxAttempts + if opts.MaxAttempts != nil { + attempts = max(1, *opts.MaxAttempts) + } + base := defaultBaseDelayMS + if opts.BaseDelayMS != nil { + base = *opts.BaseDelayMS + } + upper := defaultMaxDelayMS + if opts.MaxDelayMS != nil { + upper = *opts.MaxDelayMS + } + upper = max(base, upper) + random := opts.Random + if random == nil { + random = rand.Float64 + } + sleep := opts.Sleep + if sleep == nil { + sleep = time.Sleep + } + log := opts.Log + if log == nil { + log = os.Stderr + } + span := upper - base + 1 + + var zero T + var lastErr error + for attempt := 1; attempt <= attempts; attempt++ { + value, err := fn() + if err == nil { + return value, nil + } + if !IsBusyError(err) { + return zero, err + } + lastErr = err + if attempt >= attempts { + break + } + delay := base + int(random()*float64(span)) + where := "" + if opts.DBPath != "" { + where = " on " + opts.DBPath + } + fmt.Fprintf(log, "[busy-retry] SQLite busy%s (attempt %d/%d), backing off %dms\n", + where, attempt, attempts, delay) + sleep(time.Duration(delay) * time.Millisecond) + } + + where := "" + if opts.DBPath != "" { + where = " on database " + opts.DBPath + } + lastCode := "" + if named, ok := lastErr.(namedCodeError); ok && named.CodeName() != "" { + lastCode = " (last error: " + named.CodeName() + ")" + } + message := fmt.Sprintf( + "SQLite remained BUSY%s after %d attempts%s. This is a cold-start contention race between concurrent processes opening the same database — reduce launch concurrency or stagger process starts, then retry.", + where, attempts, lastCode, + ) + return zero, &BusyRetryError{Message: message, Cause: lastErr} +} diff --git a/internal/seniordev/session/projectors/busy_retry_test.go b/internal/seniordev/session/projectors/busy_retry_test.go new file mode 100644 index 000000000..778cd3f08 --- /dev/null +++ b/internal/seniordev/session/projectors/busy_retry_test.go @@ -0,0 +1,144 @@ +//go:build !windows + +package projectors + +import ( + "bytes" + "errors" + "io" + "reflect" + "testing" + "time" +) + +type codedError struct { + name string + code int + msg string + cause error +} + +func (e *codedError) Error() string { + if e.msg != "" { + return e.msg + } + return e.name +} +func (e *codedError) Code() int { return e.code } +func (e *codedError) CodeName() string { return e.name } +func (e *codedError) Unwrap() error { return e.cause } + +func intPtr(value int) *int { return &value } + +func TestIsBusyError(t *testing.T) { + tests := []struct { + name string + err error + depth []int + want bool + }{ + {"named recovery", &codedError{name: "SQLITE_BUSY_RECOVERY"}, nil, true}, + {"primary numeric", &codedError{code: 5}, nil, true}, + {"extended numeric", &codedError{code: 261}, nil, true}, + {"message token", errors.New("Failed to run: SQLITE_BUSY: database is locked"), nil, true}, + {"constraint", &codedError{name: "SQLITE_CONSTRAINT", code: 19}, nil, false}, + {"ioerr", &codedError{code: 266}, nil, false}, + {"plain locked message", errors.New("database is locked"), nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsBusyError(tt.err, tt.depth...); got != tt.want { + t.Fatalf("IsBusyError() = %v, want %v", got, tt.want) + } + }) + } + + deep := &codedError{msg: "outer", cause: &codedError{msg: "mid", cause: &codedError{code: 261}}} + if !IsBusyError(deep) { + t.Fatal("two-level wrapped busy error was not recognized") + } + var chain error = &codedError{code: 261} + for i := 0; i < 6; i++ { + chain = &codedError{msg: "wrapper", cause: chain} + } + if IsBusyError(chain) { + t.Fatal("busy cause beyond the default depth was recognized") + } + if !IsBusyError(chain, 10) { + t.Fatal("explicitly deeper cause was not recognized") + } +} + +func TestWithBusyRetry(t *testing.T) { + calls := 0 + sleeps := []time.Duration{} + var logs bytes.Buffer + got, err := WithBusyRetry(func() (string, error) { + calls++ + if calls < 3 { + return "", &codedError{name: "SQLITE_BUSY_RECOVERY", code: 261} + } + return "ok", nil + }, BusyRetryOptions{ + Random: func() float64 { return 0.5 }, + Sleep: func(delay time.Duration) { sleeps = append(sleeps, delay) }, + Log: &logs, + }) + if err != nil { + t.Fatal(err) + } + if got != "ok" || calls != 3 { + t.Fatalf("got %q after %d calls", got, calls) + } + if want := []time.Duration{250 * time.Millisecond, 250 * time.Millisecond}; !reflect.DeepEqual(sleeps, want) { + t.Fatalf("sleeps = %v, want %v", sleeps, want) + } + wantLog := "" + + "[busy-retry] SQLite busy (attempt 1/5), backing off 250ms\n" + + "[busy-retry] SQLite busy (attempt 2/5), backing off 250ms\n" + if logs.String() != wantLog { + t.Fatalf("log:\n%q\nwant:\n%q", logs.String(), wantLog) + } +} + +func TestWithBusyRetryFailureModes(t *testing.T) { + calls := 0 + sleeps := 0 + wantErr := errors.New("real bug: not busy") + _, err := WithBusyRetry(func() (int, error) { + calls++ + return 0, wantErr + }, BusyRetryOptions{ + Sleep: func(time.Duration) { sleeps++ }, + Log: io.Discard, + }) + if !errors.Is(err, wantErr) || calls != 1 || sleeps != 0 { + t.Fatalf("non-busy result: err=%v calls=%d sleeps=%d", err, calls, sleeps) + } + + last := &codedError{name: "SQLITE_BUSY_RECOVERY", code: 261} + calls = 0 + _, err = WithBusyRetry(func() (int, error) { + calls++ + return 0, last + }, BusyRetryOptions{ + MaxAttempts: intPtr(2), + Random: func() float64 { return 0 }, + Sleep: func(time.Duration) {}, + DBPath: "/tmp/senior-dev.db", + Log: io.Discard, + }) + var exhausted *BusyRetryError + if !errors.As(err, &exhausted) { + t.Fatalf("error type = %T, want *BusyRetryError", err) + } + wantMessage := "SQLite remained BUSY on database /tmp/senior-dev.db after 2 attempts (last error: SQLITE_BUSY_RECOVERY). " + + "This is a cold-start contention race between concurrent processes opening the same database — " + + "reduce launch concurrency or stagger process starts, then retry." + if exhausted.Error() != wantMessage { + t.Fatalf("message:\n%s\nwant:\n%s", exhausted, wantMessage) + } + if !errors.Is(exhausted, last) || calls != 2 { + t.Fatalf("cause/calls: cause=%v calls=%d", errors.Unwrap(exhausted), calls) + } +} diff --git a/internal/seniordev/session/projectors/database.go b/internal/seniordev/session/projectors/database.go new file mode 100644 index 000000000..cc13aac11 --- /dev/null +++ b/internal/seniordev/session/projectors/database.go @@ -0,0 +1,60 @@ +//go:build !windows + +// Projector database bootstrap. Migrations and the global path policy remain +// owned by the storage layer; this package owns the projector connection +// settings. +package projectors + +import ( + "context" + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +// SQLExecutor is the minimal database surface needed by Configure. +type SQLExecutor interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +// Configure applies the startup PRAGMA sequence. Only journal_mode is +// retried: it is the first file-touching statement, and so the point where +// concurrent processes opening the same database collide at cold start. +func Configure(ctx context.Context, db SQLExecutor, retry BusyRetryOptions) error { + _, err := WithBusyRetry(func() (sql.Result, error) { + return db.ExecContext(ctx, "PRAGMA journal_mode = WAL") + }, retry) + if err != nil { + return err + } + for _, statement := range []string{ + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 5000", + "PRAGMA cache_size = -64000", + "PRAGMA foreign_keys = ON", + "PRAGMA wal_checkpoint(PASSIVE)", + } { + if _, err := db.ExecContext(ctx, statement); err != nil { + return err + } + } + return nil +} + +// Open creates the cgo-free SQLite connection and configures it for projector +// use. A single physical connection keeps connection-local PRAGMAs effective. +func Open(ctx context.Context, path string, retry BusyRetryOptions) (*sql.DB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("projectors: open sqlite: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + retry.DBPath = path + if err := Configure(ctx, db, retry); err != nil { + db.Close() + return nil, err + } + return db, nil +} diff --git a/internal/seniordev/session/projectors/database_test.go b/internal/seniordev/session/projectors/database_test.go new file mode 100644 index 000000000..351352d16 --- /dev/null +++ b/internal/seniordev/session/projectors/database_test.go @@ -0,0 +1,124 @@ +//go:build !windows + +package projectors + +import ( + "context" + "database/sql" + "database/sql/driver" + "io" + "path/filepath" + "reflect" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +type recordingExecutor struct { + statements []string + journal int +} + +func (r *recordingExecutor) ExecContext(_ context.Context, statement string, _ ...any) (sql.Result, error) { + r.statements = append(r.statements, statement) + if statement == "PRAGMA journal_mode = WAL" { + r.journal++ + if r.journal == 1 { + return nil, &codedError{name: "SQLITE_BUSY_RECOVERY", code: 261} + } + } + return driver.RowsAffected(0), nil +} + +func TestConfigurePragmaOrderAndNarrowRetry(t *testing.T) { + exec := &recordingExecutor{} + if err := Configure(context.Background(), exec, BusyRetryOptions{ + Random: func() float64 { return 0 }, + Sleep: func(time.Duration) {}, + Log: io.Discard, + }); err != nil { + t.Fatal(err) + } + want := []string{ + "PRAGMA journal_mode = WAL", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 5000", + "PRAGMA cache_size = -64000", + "PRAGMA foreign_keys = ON", + "PRAGMA wal_checkpoint(PASSIVE)", + } + if !reflect.DeepEqual(exec.statements, want) { + t.Fatalf("statements:\n%q\nwant:\n%q", exec.statements, want) + } +} + +func TestOpenConfiguresRealSQLite(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "working-memory.db") + db, err := Open(ctx, path, BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + checkPragma(t, db, "journal_mode", "wal") + checkPragma(t, db, "synchronous", int64(1)) + checkPragma(t, db, "busy_timeout", int64(5000)) + checkPragma(t, db, "cache_size", int64(-64000)) + checkPragma(t, db, "foreign_keys", int64(1)) +} + +func TestOpenRetriesRealSQLiteJournalContention(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "contended.db") + + holder, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + holder.SetMaxOpenConns(1) + if _, err := holder.ExecContext(ctx, "CREATE TABLE hold (id integer)"); err != nil { + t.Fatal(err) + } + if _, err := holder.ExecContext(ctx, "BEGIN EXCLUSIVE"); err != nil { + t.Fatal(err) + } + if _, err := holder.ExecContext(ctx, "INSERT INTO hold VALUES (1)"); err != nil { + t.Fatal(err) + } + + sleeps := 0 + db, err := Open(ctx, path, BusyRetryOptions{ + MaxAttempts: intPtr(3), + Random: func() float64 { return 0 }, + Sleep: func(time.Duration) { + sleeps++ + if _, rollbackErr := holder.ExecContext(ctx, "ROLLBACK"); rollbackErr != nil { + t.Fatalf("release holder: %v", rollbackErr) + } + }, + Log: io.Discard, + }) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if sleeps != 1 { + t.Fatalf("retry sleeps = %d, want 1", sleeps) + } + checkPragma(t, db, "journal_mode", "wal") +} + +func checkPragma(t *testing.T, db *sql.DB, name string, want any) { + t.Helper() + var got any + if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", name, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("PRAGMA %s = %#v (%T), want %#v (%T)", name, got, got, want, want) + } +} diff --git a/internal/seniordev/session/projectors/projectors.go b/internal/seniordev/session/projectors/projectors.go new file mode 100644 index 000000000..23eaabd74 --- /dev/null +++ b/internal/seniordev/session/projectors/projectors.go @@ -0,0 +1,583 @@ +//go:build !windows + +// Package projectors applies session, message and part events to the SQLite +// tables that mirror flat storage. Each Apply call is one transaction. +package projectors + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + EventSessionCreated = "session.created" + EventSessionUpdated = "session.updated" + EventSessionDeleted = "session.deleted" + EventMessageUpdated = "message.updated" + EventMessageRemoved = "message.removed" + EventMessagePartRemoved = "message.part.removed" + EventMessagePartUpdated = "message.part.updated" +) + +// Event is the serialized subset consumed by a projector. +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// Warning is emitted for the two deliberately ignored late-write cases. +type Warning struct { + Message string + Fields PartialRow +} + +// StoreOptions supplies the clock and the warning sink. +type StoreOptions struct { + Now func() int64 + Warn func(Warning) +} + +// Store applies the ordered projector registry to a SQLite database. +type Store struct { + db *sql.DB + now func() int64 + warn func(Warning) +} + +// NewStore binds the projector chain to an initialized database. +func NewStore(db *sql.DB, options StoreOptions) *Store { + now := func() int64 { return time.Now().UnixMilli() } + if options.Now != nil { + now = options.Now + } + warn := func(Warning) {} + if options.Warn != nil { + warn = options.Warn + } + return &Store{db: db, now: now, warn: warn} +} + +// NotFoundError is returned when session.updated names a session that does +// not exist. The useful detail is in Message. +type NotFoundError struct { + Message string +} + +func (e *NotFoundError) Error() string { return "NotFoundError" } + +// PartialRow is a set of column values keyed by column name. +type PartialRow map[string]any + +// row is a decoded JSON object. +type row = map[string]any + +// ToPartialRow maps a JSON session patch onto the snake_case update columns. +// A nested field whose parent is null yields a null column. +func ToPartialRow(info json.RawMessage) (PartialRow, error) { + value, err := decodeObject(info) + if err != nil { + return nil, errors.New("projectors: session patch must be an object") + } + out := PartialRow{} + fields := []struct { + source string + column string + nested string + }{ + {"id", "id", ""}, + {"projectID", "project_id", ""}, + {"workspaceID", "workspace_id", ""}, + {"parentID", "parent_id", ""}, + {"slug", "slug", ""}, + {"directory", "directory", ""}, + {"path", "path", ""}, + {"title", "title", ""}, + {"version", "version", ""}, + {"share", "share_url", "url"}, + {"summary", "summary_additions", "additions"}, + {"summary", "summary_deletions", "deletions"}, + {"summary", "summary_files", "files"}, + {"summary", "summary_diffs", "diffs"}, + {"revert", "revert", ""}, + {"permission", "permission", ""}, + {"time", "time_created", "created"}, + {"time", "time_updated", "updated"}, + {"time", "time_compacting", "compacting"}, + {"time", "time_archived", "archived"}, + } + for _, field := range fields { + if item, ok := grab(value, field.source, field.nested); ok { + out[field.column] = item + } + } + return out, nil +} + +func grab(object row, field, nested string) (any, bool) { + value, ok := object[field] + if !ok { + return nil, false + } + if nested == "" { + return value, true + } + switch typed := value.(type) { + case row: + item, ok := typed[nested] + return item, ok + case []any: + return nil, false + } + return value, true +} + +// Apply projects one event inside a SQLite transaction. +func (s *Store) Apply(ctx context.Context, event Event) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := s.ApplyTx(ctx, tx, event); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +// ApplyTx projects one event into an existing transaction. +func (s *Store) ApplyTx(ctx context.Context, tx *sql.Tx, event Event) error { + data, err := decodeObject(event.Data) + if err != nil { + return errors.New("projectors: event data must be an object") + } + switch event.Type { + case EventSessionCreated: + err = s.projectSessionCreated(ctx, tx, data) + case EventSessionUpdated: + err = s.projectSessionUpdated(ctx, tx, data) + case EventSessionDeleted: + err = s.projectSessionDeleted(ctx, tx, data) + case EventMessageUpdated: + err = s.projectMessageUpdated(ctx, tx, data) + case EventMessageRemoved: + err = s.projectMessageRemoved(ctx, tx, data) + case EventMessagePartRemoved: + err = s.projectPartRemoved(ctx, tx, data) + case EventMessagePartUpdated: + err = s.projectPartUpdated(ctx, tx, data) + default: + err = fmt.Errorf("Projector not found for event: %s", event.Type) + } + if err != nil { + return normalizeSQLiteError(err) + } + return nil +} + +// ApplyReconcileTx upserts every authoritative field from a flat-storage +// event. Startup reconciliation must repair stale key and timestamp columns as +// well as JSON payloads, so it does not go through the live projectors. +func (s *Store) ApplyReconcileTx(ctx context.Context, tx *sql.Tx, event Event) error { + data, err := decodeObject(event.Data) + if err != nil { + return errors.New("projectors: event data must be an object") + } + switch event.Type { + case EventSessionCreated: + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: session.created info must be an object") + } + values := sessionInsertRow(info) + for _, column := range []string{ + "workspace_id", "parent_id", "path", "share_url", + "summary_additions", "summary_deletions", "summary_files", "summary_diffs", + "revert", "permission", "agent", "model", "time_compacting", "time_archived", + } { + if _, exists := values[column]; !exists { + values[column] = nil + } + } + err = upsertRow(ctx, tx, "session", values, "id", sessionJSONColumns) + case EventMessageUpdated: + err = s.reconcileMessageUpdated(ctx, tx, data) + case EventMessagePartUpdated: + err = s.reconcilePartUpdated(ctx, tx, data) + default: + err = s.ApplyTx(ctx, tx, event) + } + if err != nil { + return normalizeSQLiteError(err) + } + return nil +} + +func (s *Store) reconcileMessageUpdated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: message.updated info must be an object") + } + restJSON, err := restJSON(info, "id", "sessionID") + if err != nil { + return err + } + values := row{ + "id": stringOf(info["id"]), + "session_id": stringOf(info["sessionID"]), + "time_created": nested(info, "time", "created"), + "time_updated": float64(s.now()), + "data": restJSON, + } + return upsertRow(ctx, tx, "message", values, "id", nil) +} + +func (s *Store) reconcilePartUpdated(ctx context.Context, tx *sql.Tx, data row) error { + part, ok := data["part"].(row) + if !ok { + return errors.New("projectors: message.part.updated part must be an object") + } + restJSON, err := restJSON(part, "id", "messageID", "sessionID") + if err != nil { + return err + } + values := row{ + "id": stringOf(part["id"]), + "message_id": stringOf(part["messageID"]), + "session_id": stringOf(part["sessionID"]), + "time_created": data["time"], + "time_updated": float64(s.now()), + "data": restJSON, + } + return upsertRow(ctx, tx, "part", values, "id", nil) +} + +func normalizeSQLiteError(err error) error { + coded, ok := err.(sqliteCodeError) + if !ok || coded.Code()&0xff != 19 { + return err + } + message := err.Error() + const prefix = "constraint failed: " + if strings.HasPrefix(message, prefix) { + message = strings.TrimPrefix(message, prefix) + if open := strings.LastIndex(message, " ("); open >= 0 && strings.HasSuffix(message, ")") { + message = message[:open] + } + return errors.New(message) + } + return err +} + +func (s *Store) projectSessionCreated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: session.created info must be an object") + } + return insertRow(ctx, tx, "session", sessionInsertRow(info), sessionJSONColumns) +} + +func sessionInsertRow(info row) row { + values := row{} + copyField(values, "id", info, "id") + copyField(values, "project_id", info, "projectID") + copyField(values, "workspace_id", info, "workspaceID") + copyField(values, "parent_id", info, "parentID") + copyField(values, "slug", info, "slug") + copyField(values, "directory", info, "directory") + copyField(values, "path", info, "path") + copyField(values, "title", info, "title") + copyField(values, "agent", info, "agent") + copyField(values, "model", info, "model") + copyField(values, "version", info, "version") + copyNestedField(values, "share_url", info, "share", "url") + copyNestedField(values, "summary_additions", info, "summary", "additions") + copyNestedField(values, "summary_deletions", info, "summary", "deletions") + copyNestedField(values, "summary_files", info, "summary", "files") + copyNestedField(values, "summary_diffs", info, "summary", "diffs") + values["revert"] = info["revert"] + copyField(values, "permission", info, "permission") + copyNestedField(values, "time_created", info, "time", "created") + copyNestedField(values, "time_updated", info, "time", "updated") + copyNestedField(values, "time_compacting", info, "time", "compacting") + copyNestedField(values, "time_archived", info, "time", "archived") + return values +} + +func copyField(values row, column string, object row, field string) { + if value, ok := object[field]; ok { + values[column] = value + } +} + +func copyNestedField(values row, column string, object row, parent, field string) { + if inner, ok := object[parent].(row); ok { + if value, ok := inner[field]; ok { + values[column] = value + } + } +} + +// nested returns object[parent][field], or nil when either level is absent. +func nested(object row, parent, field string) any { + if inner, ok := object[parent].(row); ok { + return inner[field] + } + return nil +} + +func stringOf(value any) string { + text, _ := value.(string) + return text +} + +var sessionJSONColumns = map[string]bool{ + "summary_diffs": true, + "revert": true, + "permission": true, + "model": true, +} + +func (s *Store) projectSessionUpdated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"] + if !ok { + return errors.New("projectors: session.updated info is required") + } + encoded, err := json.Marshal(info) + if err != nil { + return err + } + partial, err := ToPartialRow(encoded) + if err != nil { + return err + } + values := row(partial) + if len(values) == 0 { + return errors.New("No values to set") + } + if _, explicit := values["time_updated"]; !explicit { + values["time_updated"] = float64(s.now()) + } + sessionID := stringOf(data["sessionID"]) + affected, err := updateRow(ctx, tx, "session", values, "id", sessionID, sessionJSONColumns) + if err != nil { + return err + } + if affected == 0 { + return &NotFoundError{Message: "Session not found: " + sessionID} + } + return nil +} + +func (s *Store) projectSessionDeleted(ctx context.Context, tx *sql.Tx, data row) error { + _, err := tx.ExecContext(ctx, "DELETE FROM session WHERE id = ?", stringOf(data["sessionID"])) + return err +} + +func (s *Store) projectMessageUpdated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: message.updated info must be an object") + } + id := stringOf(info["id"]) + sessionID := stringOf(info["sessionID"]) + restJSON, err := restJSON(info, "id", "sessionID") + if err != nil { + return err + } + created, err := sqlValue(nested(info, "time", "created"), false) + if err != nil { + return err + } + now := s.now() + _, err = tx.ExecContext(ctx, `INSERT INTO message + (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, time_updated = ?`, + id, sessionID, created, now, restJSON, now) + if err != nil && isForeignKeyError(err) { + s.warn(Warning{ + Message: "ignored late message update", + Fields: PartialRow{"messageID": id, "sessionID": sessionID}, + }) + return nil + } + return err +} + +func (s *Store) projectMessageRemoved(ctx context.Context, tx *sql.Tx, data row) error { + _, err := tx.ExecContext(ctx, "DELETE FROM message WHERE id = ? AND session_id = ?", + stringOf(data["messageID"]), stringOf(data["sessionID"])) + return err +} + +func (s *Store) projectPartRemoved(ctx context.Context, tx *sql.Tx, data row) error { + _, err := tx.ExecContext(ctx, "DELETE FROM part WHERE id = ? AND session_id = ?", + stringOf(data["partID"]), stringOf(data["sessionID"])) + return err +} + +func (s *Store) projectPartUpdated(ctx context.Context, tx *sql.Tx, data row) error { + part, ok := data["part"].(row) + if !ok { + return errors.New("projectors: message.part.updated part must be an object") + } + id := stringOf(part["id"]) + messageID := stringOf(part["messageID"]) + sessionID := stringOf(part["sessionID"]) + restJSON, err := restJSON(part, "id", "messageID", "sessionID") + if err != nil { + return err + } + created, err := sqlValue(data["time"], false) + if err != nil { + return err + } + now := s.now() + _, err = tx.ExecContext(ctx, `INSERT INTO part + (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, time_updated = ?`, + id, messageID, sessionID, created, now, restJSON, now) + if err != nil && isForeignKeyError(err) { + s.warn(Warning{ + Message: "ignored late part update", + Fields: PartialRow{"partID": id, "messageID": messageID, "sessionID": sessionID}, + }) + return nil + } + return err +} + +func isForeignKeyError(err error) bool { + if coded, ok := err.(sqliteCodeError); ok && coded.Code() == 787 { + return true + } + return strings.Contains(err.Error(), "FOREIGN KEY constraint failed") +} + +// decodeObject decodes a JSON object into a map; anything else is an error. +func decodeObject(data []byte) (row, error) { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + object, ok := value.(row) + if !ok { + return nil, errors.New("projectors: expected a JSON object") + } + return object, nil +} + +// restJSON encodes object without the named keys, which live in their own +// columns. +func restJSON(object row, omit ...string) (string, error) { + rest := make(row, len(object)) + for key, value := range object { + rest[key] = value + } + for _, key := range omit { + delete(rest, key) + } + encoded, err := jsonutil.Marshal(rest) + return string(encoded), err +} + +// sqlValue converts a decoded JSON value into a SQLite parameter. Objects and +// arrays are stored as compact JSON; asJSON forces that for scalars too. +func sqlValue(value any, asJSON bool) (any, error) { + if value == nil { + return nil, nil + } + if !asJSON { + switch typed := value.(type) { + case bool, float64, string: + return typed, nil + } + } + encoded, err := jsonutil.Marshal(value) + return string(encoded), err +} + +func sortedColumns(values row) []string { + columns := make([]string, 0, len(values)) + for column := range values { + columns = append(columns, column) + } + sort.Strings(columns) + return columns +} + +func insertRow(ctx context.Context, tx *sql.Tx, table string, values row, jsonColumns map[string]bool) error { + columns := sortedColumns(values) + placeholders := make([]string, len(columns)) + args := make([]any, len(columns)) + for index, column := range columns { + placeholders[index] = "?" + value, err := sqlValue(values[column], jsonColumns[column]) + if err != nil { + return err + } + args[index] = value + } + statement := "INSERT INTO " + table + " (" + strings.Join(columns, ", ") + ") VALUES (" + + strings.Join(placeholders, ", ") + ")" + _, err := tx.ExecContext(ctx, statement, args...) + return err +} + +func upsertRow(ctx context.Context, tx *sql.Tx, table string, values row, conflictColumn string, jsonColumns map[string]bool) error { + columns := sortedColumns(values) + placeholders := make([]string, len(columns)) + updates := make([]string, 0, len(columns)) + args := make([]any, len(columns)) + for index, column := range columns { + placeholders[index] = "?" + value, err := sqlValue(values[column], jsonColumns[column]) + if err != nil { + return err + } + args[index] = value + if column != conflictColumn { + updates = append(updates, column+" = excluded."+column) + } + } + statement := "INSERT INTO " + table + " (" + strings.Join(columns, ", ") + ") VALUES (" + + strings.Join(placeholders, ", ") + ") ON CONFLICT(" + conflictColumn + ") " + if len(updates) == 0 { + statement += "DO NOTHING" + } else { + statement += "DO UPDATE SET " + strings.Join(updates, ", ") + } + _, err := tx.ExecContext(ctx, statement, args...) + return err +} + +func updateRow(ctx context.Context, tx *sql.Tx, table string, values row, whereColumn string, whereValue any, jsonColumns map[string]bool) (int64, error) { + columns := sortedColumns(values) + sets := make([]string, len(columns)) + args := make([]any, 0, len(columns)+1) + for index, column := range columns { + sets[index] = column + " = ?" + value, err := sqlValue(values[column], jsonColumns[column]) + if err != nil { + return 0, err + } + args = append(args, value) + } + args = append(args, whereValue) + result, err := tx.ExecContext(ctx, + "UPDATE "+table+" SET "+strings.Join(sets, ", ")+" WHERE "+whereColumn+" = ?", + args..., + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/seniordev/session/projectors/projectors_test.go b/internal/seniordev/session/projectors/projectors_test.go new file mode 100644 index 000000000..d11e34a70 --- /dev/null +++ b/internal/seniordev/session/projectors/projectors_test.go @@ -0,0 +1,76 @@ +//go:build !windows + +package projectors + +import ( + "context" + "encoding/json" + "io" + "reflect" + "testing" +) + +func TestLateForeignWritesWarnAndOtherConstraintsFail(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, ":memory:", BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE project (id text PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + if err := ApplySchema(ctx, db); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO project VALUES ('p1')`); err != nil { + t.Fatal(err) + } + warnings := []Warning{} + store := NewStore(db, StoreOptions{ + Now: func() int64 { return 1000 }, + Warn: func(warning Warning) { warnings = append(warnings, warning) }, + }) + + for _, event := range []Event{ + { + ID: "e1", + Type: EventMessageUpdated, + Data: json.RawMessage(`{"sessionID":"gone","info":{"id":"m1","sessionID":"gone","time":{"created":1}}}`), + }, + { + ID: "e2", + Type: EventMessagePartUpdated, + Data: json.RawMessage(`{"sessionID":"gone","time":1,"part":{"id":"pt1","messageID":"gone","sessionID":"gone","type":"text"}}`), + }, + } { + if err := store.Apply(ctx, event); err != nil { + t.Fatal(err) + } + } + if len(warnings) != 2 { + t.Fatalf("warnings = %#v", warnings) + } + if warnings[0].Message != "ignored late message update" || + warnings[1].Message != "ignored late part update" { + t.Fatalf("warning messages = %#v", warnings) + } + if want := (PartialRow{"messageID": "m1", "sessionID": "gone"}); !reflect.DeepEqual(warnings[0].Fields, want) { + t.Fatalf("message warning fields = %#v", warnings[0].Fields) + } + if want := (PartialRow{"partID": "pt1", "messageID": "gone", "sessionID": "gone"}); !reflect.DeepEqual(warnings[1].Fields, want) { + t.Fatalf("part warning fields = %#v", warnings[1].Fields) + } + + err = store.Apply(ctx, Event{ + ID: "e3", + Type: EventMessageUpdated, + Data: json.RawMessage(`{"sessionID":"p1","info":{"id":"m2","sessionID":"p1","time":{}}}`), + }) + if err == nil || err.Error() != "NOT NULL constraint failed: message.time_created" { + t.Fatalf("non-foreign constraint error = %v", err) + } + if len(warnings) != 2 { + t.Fatalf("non-foreign constraint emitted warning: %#v", warnings) + } +} diff --git a/internal/seniordev/session/projectors/schema.go b/internal/seniordev/session/projectors/schema.go new file mode 100644 index 000000000..71821e3a3 --- /dev/null +++ b/internal/seniordev/session/projectors/schema.go @@ -0,0 +1,115 @@ +//go:build !windows + +// The session SQLite schema. The project table that two foreign keys +// reference is owned by the caller. +package projectors + +import ( + "context" + "database/sql" + "fmt" +) + +// SchemaStatements creates the session-owned tables and indexes. +var SchemaStatements = []string{ + `CREATE TABLE IF NOT EXISTS session ( + id text PRIMARY KEY, + project_id text NOT NULL, + workspace_id text, + parent_id text, + slug text NOT NULL, + directory text NOT NULL, + path text, + title text NOT NULL, + version text NOT NULL, + share_url text, + summary_additions integer, + summary_deletions integer, + summary_files integer, + summary_diffs text, + revert text, + permission text, + agent text, + model text, + time_created integer NOT NULL, + time_updated integer NOT NULL, + time_compacting integer, + time_archived integer, + CONSTRAINT fk_session_project_id_project_id_fk + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS session_project_idx ON session (project_id)`, + `CREATE INDEX IF NOT EXISTS session_workspace_idx ON session (workspace_id)`, + `CREATE INDEX IF NOT EXISTS session_parent_idx ON session (parent_id)`, + `CREATE TABLE IF NOT EXISTS message ( + id text PRIMARY KEY, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_message_session_id_session_id_fk + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS message_session_time_created_id_idx + ON message (session_id, time_created, id)`, + `CREATE TABLE IF NOT EXISTS part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_part_message_id_message_id_fk + FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS part_message_id_id_idx ON part (message_id, id)`, + `CREATE INDEX IF NOT EXISTS part_session_idx ON part (session_id)`, + `CREATE TABLE IF NOT EXISTS todo ( + session_id text NOT NULL, + content text NOT NULL, + status text NOT NULL, + priority text NOT NULL, + position integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + CONSTRAINT todo_pk PRIMARY KEY (session_id, position), + CONSTRAINT fk_todo_session_id_session_id_fk + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS todo_session_idx ON todo (session_id)`, + `CREATE TABLE IF NOT EXISTS session_message ( + id text PRIMARY KEY, + session_id text NOT NULL, + type text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_session_message_session_id_session_id_fk + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS session_message_session_idx + ON session_message (session_id)`, + `CREATE INDEX IF NOT EXISTS session_message_session_type_idx + ON session_message (session_id, type)`, + `CREATE INDEX IF NOT EXISTS session_message_time_created_idx + ON session_message (time_created)`, + `CREATE TABLE IF NOT EXISTS permission ( + project_id text PRIMARY KEY, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_permission_project_id_project_id_fk + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE + )`, +} + +// ApplySchema installs the session-owned tables and indexes. The project table +// referenced by two foreign keys must be installed by the caller. +func ApplySchema(ctx context.Context, db *sql.DB) error { + for _, statement := range SchemaStatements { + if _, err := db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("projectors: apply schema: %w", err) + } + } + return nil +} diff --git a/internal/seniordev/session/projectors/schema_test.go b/internal/seniordev/session/projectors/schema_test.go new file mode 100644 index 000000000..f6f258415 --- /dev/null +++ b/internal/seniordev/session/projectors/schema_test.go @@ -0,0 +1,157 @@ +//go:build !windows + +package projectors + +import ( + "context" + "database/sql" + "io" + "reflect" + "testing" +) + +func TestApplySchemaExactTablesColumnsAndIndexes(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, ":memory:", BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE project (id text PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + if err := ApplySchema(ctx, db); err != nil { + t.Fatal(err) + } + + wantColumns := map[string][]string{ + "session": { + "id", "project_id", "workspace_id", "parent_id", "slug", "directory", "path", + "title", "version", "share_url", "summary_additions", "summary_deletions", + "summary_files", "summary_diffs", "revert", "permission", "agent", "model", + "time_created", "time_updated", "time_compacting", "time_archived", + }, + "message": {"id", "session_id", "time_created", "time_updated", "data"}, + "part": {"id", "message_id", "session_id", "time_created", "time_updated", "data"}, + "todo": {"session_id", "content", "status", "priority", "position", "time_created", "time_updated"}, + "session_message": {"id", "session_id", "type", "time_created", "time_updated", "data"}, + "permission": {"project_id", "time_created", "time_updated", "data"}, + } + for table, want := range wantColumns { + if got := tableColumns(t, db, table); !reflect.DeepEqual(got, want) { + t.Errorf("%s columns:\n%v\nwant:\n%v", table, got, want) + } + } + + wantIndexes := []string{ + "message_session_time_created_id_idx", + "part_message_id_id_idx", + "part_session_idx", + "session_message_session_idx", + "session_message_session_type_idx", + "session_message_time_created_idx", + "session_parent_idx", + "session_project_idx", + "session_workspace_idx", + "todo_session_idx", + } + rows, err := db.Query(`SELECT name FROM sqlite_master + WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%' ORDER BY name`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + gotIndexes := []string{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + gotIndexes = append(gotIndexes, name) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(gotIndexes, wantIndexes) { + t.Fatalf("indexes:\n%v\nwant:\n%v", gotIndexes, wantIndexes) + } +} + +func TestSchemaForeignKeyCascades(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, ":memory:", BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE project (id text PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + if err := ApplySchema(ctx, db); err != nil { + t.Fatal(err) + } + statements := []string{ + `INSERT INTO project VALUES ('p1')`, + `INSERT INTO session + (id, project_id, slug, directory, title, version, time_created, time_updated) + VALUES ('s1', 'p1', 'slug', '/tmp', 'title', 'v', 1, 1)`, + `INSERT INTO message VALUES ('m1', 's1', 1, 1, '{}')`, + `INSERT INTO part VALUES ('pt1', 'm1', 's1', 1, 1, '{}')`, + `INSERT INTO todo VALUES ('s1', 'x', 'pending', 'high', 0, 1, 1)`, + `INSERT INTO session_message VALUES ('e1', 's1', 'user', 1, 1, '{}')`, + `INSERT INTO permission VALUES ('p1', 1, 1, '{}')`, + `DELETE FROM session WHERE id = 's1'`, + } + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("%s: %v", statement, err) + } + } + for _, table := range []string{"message", "part", "todo", "session_message"} { + if got := rowCount(t, db, table); got != 0 { + t.Errorf("%s rows after session delete = %d", table, got) + } + } + if got := rowCount(t, db, "permission"); got != 1 { + t.Fatalf("permission rows after session delete = %d", got) + } + if _, err := db.Exec(`DELETE FROM project WHERE id = 'p1'`); err != nil { + t.Fatal(err) + } + if got := rowCount(t, db, "permission"); got != 0 { + t.Fatalf("permission rows after project delete = %d", got) + } +} + +func tableColumns(t *testing.T, db *sql.DB, table string) []string { + t.Helper() + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := []string{} + for rows.Next() { + var cid int + var name, typ string + var notNull, pk int + var defaultValue any + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + t.Fatal(err) + } + out = append(out, name) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func rowCount(t *testing.T, db *sql.DB, table string) int { + t.Helper() + var count int + if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} diff --git a/internal/seniordev/session/runbudget/runbudget.go b/internal/seniordev/session/runbudget/runbudget.go new file mode 100644 index 000000000..8e6fd1545 --- /dev/null +++ b/internal/seniordev/session/runbudget/runbudget.go @@ -0,0 +1,151 @@ +//go:build !windows + +// Package runbudget resolves the optional run-level cost and wall-clock +// budget from flags and environment and tracks spend against it. +package runbudget + +import ( + "math" + "os" + "strconv" + "strings" + "time" +) + +// RunBudget is an explicitly supplied run-level budget. A nil field means +// that dimension is unbounded. +type RunBudget struct { + MaxCostUSD *float64 `json:"maxCostUsd,omitempty"` + MaxWallMS *float64 `json:"maxWallMs,omitempty"` +} + +// RunBudgetFlags mirrors the two CLI flag values. +type RunBudgetFlags struct { + MaxCost *float64 `json:"maxCost"` + MaxHours *float64 `json:"maxHours"` +} + +// BudgetExhaustion is the result of a tracker check. +type BudgetExhaustion struct { + Yes bool `json:"yes"` + Reason *string `json:"reason,omitempty"` +} + +func finite(n float64) bool { return !math.IsNaN(n) && !math.IsInf(n, 0) } + +func positiveNumber(n float64) (float64, bool) { + return n, finite(n) && n > 0 +} + +func positiveString(raw string, present bool) (float64, bool) { + if !present || strings.TrimSpace(raw) == "" { + return 0, false + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return 0, false + } + return positiveNumber(parsed) +} + +// ResolveRunBudget applies flag-over-environment precedence. A non-nil env map +// is used exactly (including an explicitly empty map); nil reads process env. +func ResolveRunBudget(flags *RunBudgetFlags, env map[string]string) RunBudget { + lookup := func(name string) (string, bool) { + if env != nil { + value, ok := env[name] + return value, ok + } + return os.LookupEnv(name) + } + + var maxCost float64 + var hasMaxCost bool + if flags != nil && flags.MaxCost != nil { + maxCost, hasMaxCost = positiveNumber(*flags.MaxCost) + } + if !hasMaxCost { + raw, present := lookup("SENIOR_DEV_MAX_COST_USD") + maxCost, hasMaxCost = positiveString(raw, present) + } + + var maxHours float64 + var hasMaxHours bool + if flags != nil && flags.MaxHours != nil { + maxHours, hasMaxHours = positiveNumber(*flags.MaxHours) + } + if !hasMaxHours { + raw, present := lookup("SENIOR_DEV_MAX_WALL_H") + maxHours, hasMaxHours = positiveString(raw, present) + } + + budget := RunBudget{} + if hasMaxCost { + budget.MaxCostUSD = &maxCost + } + if hasMaxHours { + maxWall := maxHours * 3_600_000 + budget.MaxWallMS = &maxWall + } + return budget +} + +// IsBounded reports whether either property is present. +func IsBounded(budget RunBudget) bool { + return budget.MaxCostUSD != nil || budget.MaxWallMS != nil +} + +// BudgetTracker is the mutable accumulator returned by MakeBudgetTracker. +type BudgetTracker struct { + Budget RunBudget + startTS float64 + cost float64 + now func() float64 +} + +// MakeBudgetTracker creates a tracker. The optional prior cost defaults to 0. +func MakeBudgetTracker(budget RunBudget, startTS float64, priorCostUSD ...float64) *BudgetTracker { + prior := float64(0) + if len(priorCostUSD) > 0 && finite(priorCostUSD[0]) && priorCostUSD[0] > 0 { + prior = priorCostUSD[0] + } + return &BudgetTracker{ + Budget: budget, + startTS: startTS, + cost: prior, + now: func() float64 { return float64(time.Now().UnixMilli()) }, + } +} + +// AddCost accumulates a positive finite provider-cost delta. +func (t *BudgetTracker) AddCost(usd float64) { + if finite(usd) && usd > 0 { + t.cost += usd + } +} + +// CostUSD returns the accumulated provider cost. +func (t *BudgetTracker) CostUSD() float64 { return t.cost } + +// Exhausted checks cost first and wall time second. Omit nowMS to use the +// ambient wall clock. +func (t *BudgetTracker) Exhausted(nowMS ...float64) BudgetExhaustion { + now := t.now() + if len(nowMS) > 0 { + now = nowMS[0] + } + if t.Budget.MaxCostUSD != nil && t.cost >= *t.Budget.MaxCostUSD { + reason := "cost $" + strconv.FormatFloat(t.cost, 'f', 4, 64) + + " >= budget $" + strconv.FormatFloat(*t.Budget.MaxCostUSD, 'f', 4, 64) + return BudgetExhaustion{Yes: true, Reason: &reason} + } + if t.Budget.MaxWallMS != nil { + elapsed := now - t.startTS + if elapsed >= *t.Budget.MaxWallMS { + reason := "wall " + strconv.FormatFloat(math.Round(elapsed/1000), 'f', -1, 64) + + "s >= budget " + strconv.FormatFloat(math.Round(*t.Budget.MaxWallMS/1000), 'f', -1, 64) + "s" + return BudgetExhaustion{Yes: true, Reason: &reason} + } + } + return BudgetExhaustion{Yes: false} +} diff --git a/internal/seniordev/session/sessioncore/sessioncore.go b/internal/seniordev/session/sessioncore/sessioncore.go new file mode 100644 index 000000000..2863e9b7b --- /dev/null +++ b/internal/seniordev/session/sessioncore/sessioncore.go @@ -0,0 +1,512 @@ +//go:build !windows + +// Package sessioncore owns the durable session lifecycle: sessions, messages +// and parts are written to the flat JSON storage and every change is published +// on the bus. +package sessioncore + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "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/id" + "github.com/Agent-Field/codeaf/internal/seniordev/storage" +) + +const ( + parentTitlePrefix = "New session - " + childTitlePrefix = "Child session - " +) + +var defaultTitlePattern = regexp.MustCompile(`^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`) + +var ( + EventCreated = bus.Define("session.created", nil) + EventUpdated = bus.Define("session.updated", nil) + EventDeleted = bus.Define("session.deleted", nil) + EventDiff = bus.Define("session.diff", nil) + EventError = bus.Define("session.error", nil) + + EventMessageUpdated = bus.Define(msgmodel.EventMessageUpdated, nil) + EventMessageRemoved = bus.Define(msgmodel.EventMessageRemoved, nil) + EventMessagePartUpdated = bus.Define(msgmodel.EventMessagePartUpdated, nil) + EventMessagePartDelta = bus.Define(msgmodel.EventMessagePartDelta, nil) + EventMessagePartRemoved = bus.Define(msgmodel.EventMessagePartRemoved, nil) +) + +type Summary struct { + Additions uint64 `json:"additions"` + Deletions uint64 `json:"deletions"` + Files uint64 `json:"files"` + Diffs []msgmodel.FileDiff `json:"diffs,omitempty"` +} + +type Share struct { + URL string `json:"url"` +} + +type Revert struct { + MessageID string `json:"messageID"` + PartID *string `json:"partID,omitempty"` + Snapshot *string `json:"snapshot,omitempty"` + Diff *string `json:"diff,omitempty"` +} + +type Model struct { + ID string `json:"id"` + ProviderID string `json:"providerID"` + Variant *string `json:"variant,omitempty"` +} + +type Time struct { + Created uint64 `json:"created"` + Updated uint64 `json:"updated"` + Compacting *uint64 `json:"compacting,omitempty"` + Archived *float64 `json:"archived,omitempty"` +} + +// Info is the durable session record. +type Info struct { + ID string `json:"id"` + Slug string `json:"slug"` + ProjectID string `json:"projectID"` + WorkspaceID *string `json:"workspaceID,omitempty"` + Directory string `json:"directory"` + Path *string `json:"path,omitempty"` + ParentID *string `json:"parentID,omitempty"` + Title string `json:"title"` + Agent *string `json:"agent,omitempty"` + Model *Model `json:"model,omitempty"` + Version string `json:"version"` + Summary *Summary `json:"summary,omitempty"` + Share *Share `json:"share,omitempty"` + Revert *Revert `json:"revert,omitempty"` + Permission json.RawMessage `json:"permission,omitempty"` + Time Time `json:"time"` +} + +// Row is the storage-row projection consumed by FromRow and produced by ToRow. +type Row struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + WorkspaceID *string `json:"workspace_id,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + Slug string `json:"slug"` + Directory string `json:"directory"` + Path *string `json:"path,omitempty"` + Title string `json:"title"` + Agent *string `json:"agent,omitempty"` + Model *Model `json:"model,omitempty"` + Version string `json:"version"` + ShareURL *string `json:"share_url,omitempty"` + SummaryAdditions *uint64 `json:"summary_additions,omitempty"` + SummaryDeletions *uint64 `json:"summary_deletions,omitempty"` + SummaryFiles *uint64 `json:"summary_files,omitempty"` + SummaryDiffs []msgmodel.FileDiff `json:"summary_diffs,omitempty"` + Revert *Revert `json:"revert"` + Permission json.RawMessage `json:"permission,omitempty"` + TimeCreated uint64 `json:"time_created"` + TimeUpdated uint64 `json:"time_updated"` + TimeCompacting *uint64 `json:"time_compacting,omitempty"` + TimeArchived *float64 `json:"time_archived,omitempty"` +} + +func IsDefaultTitle(title string) bool { return defaultTitlePattern.MatchString(title) } + +func FromRow(row Row) Info { + var summary *Summary + if row.SummaryAdditions != nil || row.SummaryDeletions != nil || row.SummaryFiles != nil { + summary = &Summary{Diffs: row.SummaryDiffs} + if row.SummaryAdditions != nil { + summary.Additions = *row.SummaryAdditions + } + if row.SummaryDeletions != nil { + summary.Deletions = *row.SummaryDeletions + } + if row.SummaryFiles != nil { + summary.Files = *row.SummaryFiles + } + } + var share *Share + if row.ShareURL != nil && *row.ShareURL != "" { + share = &Share{URL: *row.ShareURL} + } + permission := row.Permission + if string(permission) == "null" { + permission = nil + } + return Info{ + ID: row.ID, Slug: row.Slug, ProjectID: row.ProjectID, + WorkspaceID: row.WorkspaceID, Directory: row.Directory, Path: row.Path, + ParentID: row.ParentID, Summary: summary, Share: share, Title: row.Title, + Agent: row.Agent, Model: row.Model, Version: row.Version, + Time: Time{Created: row.TimeCreated, Updated: row.TimeUpdated, Compacting: row.TimeCompacting, Archived: row.TimeArchived}, + Permission: permission, Revert: row.Revert, + } +} + +func ToRow(info Info) Row { + row := Row{ + ID: info.ID, ProjectID: info.ProjectID, WorkspaceID: info.WorkspaceID, + ParentID: info.ParentID, Slug: info.Slug, Directory: info.Directory, + Path: info.Path, Title: info.Title, Agent: info.Agent, Model: info.Model, + Version: info.Version, Revert: info.Revert, Permission: info.Permission, + TimeCreated: info.Time.Created, TimeUpdated: info.Time.Updated, + TimeCompacting: info.Time.Compacting, TimeArchived: info.Time.Archived, + } + if info.Share != nil { + row.ShareURL = &info.Share.URL + } + if info.Summary != nil { + row.SummaryAdditions = &info.Summary.Additions + row.SummaryDeletions = &info.Summary.Deletions + row.SummaryFiles = &info.Summary.Files + row.SummaryDiffs = info.Summary.Diffs + } + return row +} + +func GetUsage(input calc.GetUsageInput) calc.UsageResult { return calc.GetUsage(input) } + +type CreateInput struct { + ID string + ParentID string + Title string + Agent string + Model *Model + Permission json.RawMessage + WorkspaceID string + Directory string + Path string +} + +type Options struct { + Store *storage.Store + Bus *bus.Bus + ProjectID string + Worktree string + Directory string + WorkspaceID string + Version string + Now func() time.Time + Slug func() string +} + +// Service is safe for concurrent processor and observer use. +type Service struct { + store *storage.Store + bus *bus.Bus + projectID string + worktree string + directory string + workspaceID string + version string + now func() time.Time + slug func() string +} + +func New(opts Options) (*Service, error) { + if opts.Store == nil { + return nil, errors.New("sessioncore: Store is required") + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Version == "" { + opts.Version = "0.0.0" + } + if opts.Slug == nil { + opts.Slug = func() string { + value, err := id.Ascending("entry") + if err != nil { + return "" + } + return strings.TrimPrefix(value, "ent_") + } + } + s := &Service{ + store: opts.Store, bus: opts.Bus, projectID: opts.ProjectID, + worktree: opts.Worktree, directory: opts.Directory, + workspaceID: opts.WorkspaceID, version: opts.Version, now: opts.Now, + slug: opts.Slug, + } + return s, nil +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (Info, error) { + _ = ctx + sessionID, err := id.Descending("session", input.ID) + if err != nil { + return Info{}, err + } + now := uint64(s.now().UnixMilli()) + directory := input.Directory + if directory == "" { + directory = s.directory + } + path := input.Path + if path == "" && s.worktree != "" { + rel, relErr := filepath.Rel(filepath.Clean(s.worktree), directory) + if relErr == nil { + path = filepath.ToSlash(rel) + } + } + title := input.Title + if title == "" { + title = createDefaultTitle(input.ParentID != "", time.UnixMilli(int64(now)).UTC()) + } + info := Info{ + ID: sessionID, Slug: s.slug(), ProjectID: s.projectID, Directory: directory, + Title: title, Model: input.Model, Version: s.version, + Time: Time{Created: now, Updated: now}, Permission: input.Permission, + } + if path != "" { + info.Path = &path + } + if input.ParentID != "" { + info.ParentID = &input.ParentID + } + workspace := input.WorkspaceID + if workspace == "" { + workspace = s.workspaceID + } + if workspace != "" { + info.WorkspaceID = &workspace + } + if input.Agent != "" { + info.Agent = &input.Agent + } + if err := s.store.Write(sessionKey(info.ID), info); err != nil { + return Info{}, err + } + s.publish(EventCreated, createdEvent{SessionID: info.ID, Info: info}) + // A session.updated follows session.created so subscribers that only + // track updates also see the new session. + s.publish(EventUpdated, createdEvent{SessionID: info.ID, Info: info}) + return info, nil +} + +func createDefaultTitle(child bool, now time.Time) string { + prefix := parentTitlePrefix + if child { + prefix = childTitlePrefix + } + return prefix + now.UTC().Format("2006-01-02T15:04:05.000Z") +} + +func (s *Service) Get(_ context.Context, sessionID string) (Info, error) { + var info Info + if err := s.store.ReadInto(sessionKey(sessionID), &info); err != nil { + var miss *storage.NotFoundError + if errors.As(err, &miss) { + return Info{}, &storage.NotFoundError{Message: "Session not found: " + sessionID} + } + return Info{}, err + } + return info, nil +} + +func (s *Service) List(ctx context.Context) ([]Info, error) { + _ = ctx + keys, err := s.store.List([]string{"session"}) + if err != nil { + return nil, err + } + out := make([]Info, 0, len(keys)) + for _, key := range keys { + var info Info + if err := s.store.ReadInto(key, &info); err == nil && (s.projectID == "" || info.ProjectID == s.projectID) { + out = append(out, info) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Time.Updated > out[j].Time.Updated }) + return out, nil +} + +func (s *Service) Children(ctx context.Context, parentID string) ([]Info, error) { + all, err := s.List(ctx) + if err != nil { + return nil, err + } + out := []Info{} + for _, item := range all { + if item.ParentID != nil && *item.ParentID == parentID { + out = append(out, item) + } + } + return out, nil +} + +func (s *Service) Touch(ctx context.Context, sessionID string) error { + return s.patch(ctx, sessionID, func(info *Info) { info.Time.Updated = uint64(s.now().UnixMilli()) }) +} + +func (s *Service) patch(ctx context.Context, sessionID string, mutate func(*Info)) error { + _ = ctx + info, err := storage.UpdateAs(s.store, sessionKey(sessionID), func(info *Info) { + mutate(info) + }) + if err != nil { + var miss *storage.NotFoundError + if errors.As(err, &miss) { + return &storage.NotFoundError{Message: "Session not found: " + sessionID} + } + return err + } + s.publish(EventUpdated, createdEvent{SessionID: sessionID, Info: info}) + return nil +} + +func (s *Service) Remove(ctx context.Context, sessionID string) error { + info, err := s.Get(ctx, sessionID) + if err != nil { + return err + } + children, _ := s.Children(ctx, sessionID) + for _, child := range children { + _ = s.Remove(ctx, child.ID) + } + for _, prefix := range [][]string{{"message", sessionID}, {"part", sessionID}} { + keys, _ := s.store.List(prefix) + for _, key := range keys { + _ = s.store.Remove(key) + } + } + if err := s.store.Remove(sessionKey(sessionID)); err != nil { + return err + } + s.publish(EventDeleted, createdEvent{SessionID: sessionID, Info: info}) + return nil +} + +func (s *Service) UpdateMessage(_ context.Context, info msgmodel.Info) error { + sessionID := messageSessionID(info) + if err := s.store.Write(messageKey(sessionID, info.MessageID()), info); err != nil { + return err + } + s.publish(EventMessageUpdated, msgmodel.UpdatedEvent{SessionID: sessionID, Info: info}) + return nil +} + +func (s *Service) UpdatePart(_ context.Context, part msgmodel.Part) error { + base := part.Base() + if err := s.store.Write(partKey(base.SessionID, base.MessageID, base.ID), part); err != nil { + return err + } + s.publish(EventMessagePartUpdated, msgmodel.PartUpdatedEvent{ + SessionID: base.SessionID, Part: part, Time: uint64(s.now().UnixMilli()), + }) + return nil +} + +// UpdateMessageWithParts makes the message visible only after all of its parts +// are durable. The files are written part-first under one store operation, then +// projected message-first so SQLite foreign keys observe a complete turn. +func (s *Service) UpdateMessageWithParts( + _ context.Context, info msgmodel.Info, parts ...msgmodel.Part, +) error { + sessionID := messageSessionID(info) + messageID := info.MessageID() + items := make([]storage.WriteItem, 0, len(parts)+1) + for _, part := range parts { + base := part.Base() + items = append(items, storage.WriteItem{ + Key: partKey(base.SessionID, base.MessageID, base.ID), Content: part, + }) + } + items = append(items, storage.WriteItem{ + Key: messageKey(sessionID, messageID), Content: info, + }) + if err := s.store.WriteBatch(items); err != nil { + return err + } + s.publish(EventMessageUpdated, msgmodel.UpdatedEvent{SessionID: sessionID, Info: info}) + for _, part := range parts { + base := part.Base() + s.publish(EventMessagePartUpdated, msgmodel.PartUpdatedEvent{ + SessionID: base.SessionID, Part: part, Time: uint64(s.now().UnixMilli()), + }) + } + return nil +} + +func (s *Service) UpdatePartDelta(_ context.Context, input msgmodel.PartDeltaEvent) { + s.publish(EventMessagePartDelta, input) +} + +// Messages returns the session's messages oldest-first, each with its parts. +func (s *Service) Messages(ctx context.Context, sessionID string) ([]msgmodel.WithParts, error) { + _ = ctx + keys, err := s.store.List([]string{"message", sessionID}) + if err != nil { + return nil, err + } + out := make([]msgmodel.WithParts, 0, len(keys)) + for _, key := range keys { + var raw json.RawMessage + if err := s.store.ReadInto(key, &raw); err != nil { + return nil, err + } + info, err := msgmodel.UnmarshalInfo(raw) + if err != nil { + return nil, err + } + partKeys, err := s.store.List([]string{"part", sessionID, info.MessageID()}) + if err != nil { + return nil, err + } + parts := make(msgmodel.Parts, 0, len(partKeys)) + for _, partKey := range partKeys { + var partRaw json.RawMessage + if err := s.store.ReadInto(partKey, &partRaw); err != nil { + return nil, err + } + part, err := msgmodel.UnmarshalPart(partRaw) + if err != nil { + return nil, err + } + parts = append(parts, part) + } + out = append(out, msgmodel.WithParts{Info: info, Parts: parts}) + } + return out, nil +} + +func (s *Service) publish(def bus.Definition, properties any) { + if s.bus != nil { + s.bus.Publish(def, properties) + } +} + +type createdEvent struct { + SessionID string `json:"sessionID"` + Info Info `json:"info"` +} + +func sessionKey(id string) []string { return []string{"session", id} } +func messageKey(sessionID, messageID string) []string { + return []string{"message", sessionID, messageID} +} +func partKey(sessionID, messageID, partID string) []string { + return []string{"part", sessionID, messageID, partID} +} + +func messageSessionID(info msgmodel.Info) string { + switch item := info.(type) { + case msgmodel.User: + return item.SessionID + case msgmodel.Assistant: + return item.SessionID + default: + return "" + } +} diff --git a/internal/seniordev/session/sessioncore/sessioncore_test.go b/internal/seniordev/session/sessioncore/sessioncore_test.go new file mode 100644 index 000000000..9e9fe8b11 --- /dev/null +++ b/internal/seniordev/session/sessioncore/sessioncore_test.go @@ -0,0 +1,189 @@ +//go:build !windows + +package sessioncore + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "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/storage" +) + +func newTestService(t *testing.T) (*Service, *bus.Bus) { + t.Helper() + b := bus.New(bus.Context{}, bus.WithIDGenerator(func() string { return "evt_test" })) + s, err := New(Options{ + Store: storage.New(t.TempDir()), Bus: b, ProjectID: "p", Worktree: "/work", + Directory: "/work/sub", WorkspaceID: "wrk", Version: "v", + Now: func() time.Time { return time.UnixMilli(1_722_124_923_004) }, + Slug: func() string { return "slug" }, + }) + if err != nil { + t.Fatal(err) + } + return s, b +} + +func TestLifecycleAndEventOrder(t *testing.T) { + s, b := newTestService(t) + var mu sync.Mutex + var events []string + unsub := b.SubscribeAllCallback(func(p bus.Payload) { + mu.Lock() + events = append(events, p.Type) + mu.Unlock() + }) + defer unsub() + ctx := context.Background() + original, err := s.Create(ctx, CreateInput{ID: "ses_original"}) + if err != nil { + t.Fatal(err) + } + user := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "msg_1", SessionID: original.ID}, + Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder", + Model: msgmodel.UserModel{ProviderID: "p", ModelID: "m"}, + } + if err := s.UpdateMessage(ctx, user); err != nil { + t.Fatal(err) + } + if err := s.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "prt_1", SessionID: original.ID, MessageID: user.ID}, + Text: "hello", + }); err != nil { + t.Fatal(err) + } + messages, err := s.Messages(ctx, original.ID) + if err != nil || len(messages) != 1 || len(messages[0].Parts) != 1 { + t.Fatalf("messages = %#v, %v", messages, err) + } + mu.Lock() + defer mu.Unlock() + want := []string{ + EventCreated.Type, EventUpdated.Type, + msgmodel.EventMessageUpdated, msgmodel.EventMessagePartUpdated, + } + if len(events) != len(want) { + t.Fatalf("events = %v, want %v", events, want) + } + for index := range want { + if events[index] != want[index] { + t.Fatalf("events = %v, want %v", events, want) + } + } +} +func TestPartDeltaIsBusOnly(t *testing.T) { + s, b := newTestService(t) + seen := make(chan msgmodel.PartDeltaEvent, 1) + unsub := b.SubscribeCallback(EventMessagePartDelta, func(p bus.Payload) { + raw, _ := json.Marshal(p.Properties) + var event msgmodel.PartDeltaEvent + _ = json.Unmarshal(raw, &event) + seen <- event + }) + defer unsub() + s.UpdatePartDelta(context.Background(), msgmodel.PartDeltaEvent{ + SessionID: "s", MessageID: "m", PartID: "p", Field: "text", Delta: "<&", + }) + select { + case event := <-seen: + if event.Delta != "<&" { + t.Fatalf("event %#v", event) + } + case <-time.After(time.Second): + t.Fatal("missing delta") + } +} + +func TestMessageWithPartsNeverLeavesEmptyUserTurnContract(t *testing.T) { + // A crash/failure boundary may leave an orphan part, but never a visible + // user message without its part. + s, _ := newTestService(t) + blocker := filepath.Join(s.store.Dir, "message", "ses") + if err := os.MkdirAll(filepath.Dir(blocker), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(blocker, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + user := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "msg", SessionID: "ses"}, + Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder", + } + part := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "part", SessionID: "ses", MessageID: "msg"}, + Text: "prompt", + } + if err := s.UpdateMessageWithParts(context.Background(), user, part); err == nil { + t.Fatal("blocked message write succeeded") + } + if _, err := os.Stat(filepath.Join(s.store.Dir, "part", "ses", "msg", "part.json")); err != nil { + t.Fatalf("part was not written first: %v", err) + } + if _, err := os.Stat(filepath.Join(s.store.Dir, "message", "ses", "msg.json")); err == nil { + t.Fatalf("empty message became visible: %v", err) + } +} + +func TestSessionPatchAtomicAcrossServicesContract(t *testing.T) { + // Two session services sharing flat storage cannot lose independent + // fields through an unlocked read/modify/write. + root := t.TempDir() + makeService := func() *Service { + service, err := New(Options{ + Store: storage.New(root), Bus: bus.New(bus.Context{}), ProjectID: "p", + Directory: "/work", Worktree: "/work", Slug: func() string { return "slug" }, + }) + if err != nil { + t.Fatal(err) + } + return service + } + first, second := makeService(), makeService() + created, err := first.Create(context.Background(), CreateInput{ID: "ses_atomic", Title: "base"}) + if err != nil { + t.Fatal(err) + } + firstEntered, releaseFirst, firstDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) + secondEntered, releaseSecond, secondDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) + go func() { + firstDone <- first.patch(context.Background(), created.ID, func(info *Info) { + close(firstEntered) + <-releaseFirst + info.Title = "preserved title" + }) + }() + <-firstEntered + archived := 42.0 + go func() { + secondDone <- second.patch(context.Background(), created.ID, func(info *Info) { + close(secondEntered) + <-releaseSecond + info.Time.Archived = &archived + }) + }() + select { + case <-secondEntered: + case <-time.After(50 * time.Millisecond): + } + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + <-secondEntered + close(releaseSecond) + if err := <-secondDone; err != nil { + t.Fatal(err) + } + got, err := first.Get(context.Background(), created.ID) + if err != nil || got.Title != "preserved title" || got.Time.Archived == nil || *got.Time.Archived != archived { + t.Fatalf("atomic session patch = %+v, %v", got, err) + } +} diff --git a/internal/seniordev/session/system/system.go b/internal/seniordev/session/system/system.go new file mode 100644 index 000000000..c4364ea43 --- /dev/null +++ b/internal/seniordev/session/system/system.go @@ -0,0 +1,81 @@ +//go:build !windows + +// Package system builds the environment block of a turn's system prompt: +// the model in use, the working directory and the date. +package system + +import ( + "runtime" + "time" +) + +type API struct { + ID string `json:"id"` +} + +type Model struct { + ProviderID string `json:"providerID"` + API API `json:"api"` +} + +type Project struct { + VCS string `json:"vcs"` +} + +type Context struct { + Directory string `json:"directory"` + Worktree string `json:"worktree"` + Project Project `json:"project"` +} + +type Service struct { + Context Context + Now func() time.Time + Platform string +} + +func New(context Context) *Service { + return &Service{Context: context, Now: time.Now, Platform: platformName()} +} + +func (service *Service) Environment(model Model) []string { + now := service.Now + if now == nil { + now = time.Now + } + platform := service.Platform + if platform == "" { + platform = platformName() + } + return BuildEnvironment(model, service.Context, now(), platform) +} + +func BuildEnvironment( + model Model, context Context, now time.Time, platform string, +) []string { + isGit := "no" + if context.Project.VCS == "git" { + isGit = "yes" + } + return []string{ + "You are powered by the model named " + model.API.ID + + ". The exact model ID is " + model.ProviderID + "/" + model.API.ID + "\n" + + "Here is some useful information about the environment you are running in:\n" + + "\n" + + " Working directory: " + context.Directory + "\n" + + " Workspace root folder: " + context.Worktree + "\n" + + " Is directory a git repo: " + isGit + "\n" + + " Platform: " + platform + "\n" + + " Today's date: " + now.Format("Mon Jan 02 2006") + "\n" + + "", + } +} + +// platformName is the OS label shown in the prompt; Windows is reported as +// win32. +func platformName() string { + if runtime.GOOS == "windows" { + return "win32" + } + return runtime.GOOS +} diff --git a/internal/seniordev/storage/storage.go b/internal/seniordev/storage/storage.go new file mode 100644 index 000000000..881aa7759 --- /dev/null +++ b/internal/seniordev/storage/storage.go @@ -0,0 +1,524 @@ +//go:build !windows + +// Package storage is the flat-file JSON store. Per-resource locks coordinate +// goroutines while advisory file locks make read-modify-write safe across +// processes. +package storage + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" + "golang.org/x/sys/unix" +) + +// NotFoundError is the model-visible storage miss. +type NotFoundError struct { + Message string `json:"message"` +} + +func (e *NotFoundError) Error() string { return e.Message } + +// Store is a JSON store rooted at Dir. +type Store struct { + Dir string + + once sync.Once + init error + mu sync.Mutex + locks map[string]*sync.RWMutex +} + +// WriteItem is one resource in a WriteBatch operation. +type WriteItem struct { + Key []string + Content any +} + +// Option configures a Store. +type Option func(*Store) + +// New constructs a store at the exact storage directory passed by the caller. +func New(dir string, options ...Option) *Store { + s := &Store{ + Dir: dir, + locks: make(map[string]*sync.RWMutex), + } + for _, option := range options { + option(s) + } + return s +} + +// NewFromDataDir roots the store at dataDir/storage. +func NewFromDataDir(dataDir string, options ...Option) *Store { + return New(filepath.Join(dataDir, "storage"), options...) +} + +func (s *Store) initialize() error { + s.once.Do(func() { + s.init = os.MkdirAll(s.Dir, 0o755) + }) + return s.init +} + +func (s *Store) target(key []string) string { + parts := append([]string{s.Dir}, key...) + return filepath.Join(parts...) + ".json" +} + +func (s *Store) lock(target string) *sync.RWMutex { + s.mu.Lock() + defer s.mu.Unlock() + lock := s.locks[target] + if lock == nil { + lock = &sync.RWMutex{} + s.locks[target] = lock + } + return lock +} + +func (s *Store) withAdvisoryLock(exclusive bool, fn func() error) error { + return withFileLock(filepath.Join(s.Dir, ".lock"), exclusive, fn) +} + +func withFileLock(lockPath string, exclusive bool, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return err + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer file.Close() + operation := unix.LOCK_SH + if exclusive { + operation = unix.LOCK_EX + } + if err := unix.Flock(int(file.Fd()), operation); err != nil { + return err + } + defer unix.Flock(int(file.Fd()), unix.LOCK_UN) + return fn() +} + +func (s *Store) resourceLockPath(target string) string { + hash := sha256.Sum256([]byte(filepath.Clean(target))) + return filepath.Join(s.Dir, ".locks", fmt.Sprintf("%x.lock", hash)) +} + +func (s *Store) withResourceAdvisoryLock(target string, exclusive bool, fn func() error) error { + return withFileLock(s.resourceLockPath(target), exclusive, fn) +} + +func (s *Store) withResourceLocks(targets []string, fn func() error) error { + unique := make(map[string]struct{}, len(targets)) + ordered := make([]string, 0, len(targets)) + for _, target := range targets { + if _, exists := unique[target]; exists { + continue + } + unique[target] = struct{}{} + ordered = append(ordered, target) + } + sort.Strings(ordered) + for _, target := range ordered { + s.lock(target).Lock() + } + defer func() { + for index := len(ordered) - 1; index >= 0; index-- { + s.lock(ordered[index]).Unlock() + } + }() + files := make([]*os.File, 0, len(ordered)) + for _, target := range ordered { + lockPath := s.resourceLockPath(target) + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + closeResourceLocks(files) + return err + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + closeResourceLocks(files) + return err + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { + _ = file.Close() + closeResourceLocks(files) + return err + } + files = append(files, file) + } + defer closeResourceLocks(files) + return fn() +} + +func closeResourceLocks(files []*os.File) { + for index := len(files) - 1; index >= 0; index-- { + _ = unix.Flock(int(files[index].Fd()), unix.LOCK_UN) + _ = files[index].Close() + } +} + +// Remove deletes a stored resource. Missing resources are ignored. +func (s *Store) Remove(key []string) error { + if err := s.initialize(); err != nil { + return err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + return s.withResourceAdvisoryLock(target, true, func() error { + err := os.Remove(target) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + return syncDirectory(filepath.Dir(target)) + }) +} + +// Read decodes a resource into a generic JSON value (maps, slices, float64). +func (s *Store) Read(key []string) (any, error) { + if err := s.initialize(); err != nil { + return nil, err + } + target := s.target(key) + lock := s.lock(target) + lock.RLock() + defer lock.RUnlock() + var data []byte + err := s.withResourceAdvisoryLock(target, false, func() error { + var readErr error + data, readErr = os.ReadFile(target) + return readErr + }) + if errors.Is(err, fs.ErrNotExist) { + return nil, &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return nil, err + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + return value, nil +} + +// ReadInto decodes a resource into dst using encoding/json. +func (s *Store) ReadInto(key []string, dst any) error { + if err := s.initialize(); err != nil { + return err + } + target := s.target(key) + lock := s.lock(target) + lock.RLock() + defer lock.RUnlock() + var data []byte + err := s.withResourceAdvisoryLock(target, false, func() error { + var readErr error + data, readErr = os.ReadFile(target) + return readErr + }) + if errors.Is(err, fs.ErrNotExist) { + return &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} + +// ReadAs decodes a resource into a T. +func ReadAs[T any](s *Store, key []string) (T, error) { + var out T + err := s.ReadInto(key, &out) + return out, err +} + +// Update holds the resource mutex and cross-process flock across read, +// mutation, and rewrite. mutate must not call a Store method for the same key: +// the callback is deliberately non-reentrant and doing so will deadlock. +func (s *Store) Update(key []string, mutate func(any)) (any, error) { + if err := s.initialize(); err != nil { + return nil, err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + var value any + err := s.withResourceAdvisoryLock(target, true, func() error { + data, err := os.ReadFile(target) + if errors.Is(err, fs.ErrNotExist) { + return &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return err + } + if err := json.Unmarshal(data, &value); err != nil { + return err + } + mutate(value) + return writeJSON(target, value) + }) + return value, err +} + +// UpdateAs is a typed update helper. It preserves struct field ordering on the +// rewrite, while Update preserves arbitrary parsed-object ordering. mutate has +// the same non-reentrancy requirement as Update. +func UpdateAs[T any](s *Store, key []string, mutate func(*T)) (T, error) { + var zero T + if err := s.initialize(); err != nil { + return zero, err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + var value T + err := s.withResourceAdvisoryLock(target, true, func() error { + data, err := os.ReadFile(target) + if errors.Is(err, fs.ErrNotExist) { + return &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return err + } + if err := json.Unmarshal(data, &value); err != nil { + return err + } + mutate(&value) + return writeJSON(target, value) + }) + if err != nil { + return zero, err + } + return value, nil +} + +// Write persists content as two-space-indented JSON without a trailing +// newline. +func (s *Store) Write(key []string, content any) error { + if err := s.initialize(); err != nil { + return err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + return s.withResourceAdvisoryLock(target, true, func() error { return writeJSON(target, content) }) +} + +// WriteBatch serializes a related group under ordered per-resource locks. +// Callers control item order; prompt persistence writes parts before the +// message that makes the turn visible. Files are synced individually, then +// each touched directory is synced once after all renames. +func (s *Store) WriteBatch(items []WriteItem) error { + if err := s.initialize(); err != nil { + return err + } + targets := make([]string, len(items)) + for index, item := range items { + targets[index] = s.target(item.Key) + } + return s.withResourceLocks(targets, func() error { return writeJSONBatch(targets, items) }) +} + +// CreateExclusive writes a resource only when no claimant has created it. +// The store-wide advisory lock makes the check/atomic-rename indivisible +// across cooperating processes; the returned boolean reports the winner. +func (s *Store) CreateExclusive(key []string, content any) (bool, error) { + if err := s.initialize(); err != nil { + return false, err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + created := false + err := s.withResourceAdvisoryLock(target, true, func() error { + if _, err := os.Stat(target); err == nil { + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + if err := writeJSON(target, content); err != nil { + return err + } + created = true + return nil + }) + return created, err +} + +// List returns descendant resource keys in sorted order. +func (s *Store) List(prefix []string) ([][]string, error) { + if err := s.initialize(); err != nil { + return nil, err + } + cwdParts := append([]string{s.Dir}, prefix...) + cwd := filepath.Join(cwdParts...) + result := [][]string{} + err := s.withAdvisoryLock(false, func() error { + return filepath.WalkDir(cwd, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if path != cwd && (entry.Name() == ".locks" || entry.Name() == "quarantine") { + return filepath.SkipDir + } + return nil + } + if entry.Name() == ".lock" || strings.HasPrefix(entry.Name(), ".tmp-") { + return nil + } + rel, err := filepath.Rel(cwd, path) + if err != nil { + return err + } + // The key is the path minus the five-byte ".json" suffix; the + // extension is not validated, so a foreign file in the store yields + // a truncated key rather than an error. + if len(rel) >= 5 { + rel = rel[:len(rel)-5] + } else { + rel = "" + } + key := append(append([]string{}, prefix...), strings.Split(rel, string(filepath.Separator))...) + result = append(result, key) + return nil + }) + }) + if errors.Is(err, fs.ErrNotExist) { + return [][]string{}, nil + } + if err != nil { + // A walk failure lists nothing rather than failing the caller. + return [][]string{}, nil + } + sort.SliceStable(result, func(i, j int) bool { + return strings.Join(result[i], "/") < strings.Join(result[j], "/") + }) + return result, nil +} + +func writeJSON(target string, content any) error { + data, err := jsonutil.MarshalIndent(content) + if err != nil { + return err + } + return writeBytesAtomic(target, data) +} + +func writeJSONBatch(targets []string, items []WriteItem) error { + directories := map[string]struct{}{} + for index, item := range items { + data, err := jsonutil.MarshalIndent(item.Content) + if err != nil { + return joinBatchSyncError(err, directories) + } + temporary, err := prepareBytesAtomic(targets[index], data) + if err != nil { + return joinBatchSyncError(err, directories) + } + if err := os.Rename(temporary, targets[index]); err != nil { + _ = os.Remove(temporary) + return joinBatchSyncError(err, directories) + } + directories[filepath.Dir(targets[index])] = struct{}{} + } + return syncBatchDirectories(directories) +} + +func joinBatchSyncError(writeErr error, directories map[string]struct{}) error { + if syncErr := syncBatchDirectories(directories); syncErr != nil { + return errors.Join(writeErr, syncErr) + } + return writeErr +} + +func syncBatchDirectories(directories map[string]struct{}) error { + orderedDirectories := make([]string, 0, len(directories)) + for directory := range directories { + orderedDirectories = append(orderedDirectories, directory) + } + sort.Strings(orderedDirectories) + for _, directory := range orderedDirectories { + if err := syncDirectory(directory); err != nil { + return err + } + } + return nil +} + +func writeBytesAtomic(target string, data []byte) error { + temporaryPath, err := prepareBytesAtomic(target, data) + if err != nil { + return err + } + defer os.Remove(temporaryPath) + if err := os.Rename(temporaryPath, target); err != nil { + return err + } + return syncDirectory(filepath.Dir(target)) +} + +func prepareBytesAtomic(target string, data []byte) (string, error) { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", err + } + directory := filepath.Dir(target) + temporary, err := os.CreateTemp(directory, ".tmp-*.json") + if err != nil { + return "", err + } + temporaryPath := temporary.Name() + failed := true + defer func() { + if failed { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o644); err != nil { + _ = temporary.Close() + return "", err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return "", err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return "", err + } + if err := temporary.Close(); err != nil { + return "", err + } + failed = false + return temporaryPath, nil +} + +func syncDirectory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return err + } + defer file.Close() + return file.Sync() +} diff --git a/internal/seniordev/storage/storage_test.go b/internal/seniordev/storage/storage_test.go new file mode 100644 index 000000000..a98db1b88 --- /dev/null +++ b/internal/seniordev/storage/storage_test.go @@ -0,0 +1,271 @@ +//go:build !windows + +package storage + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "sync" + "testing" + "time" +) + +func TestStoreReadWriteUpdateListRemove(t *testing.T) { + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + type record struct { + Z float64 `json:"z"` + A string `json:"a"` + } + if err := store.Write([]string{"session", "s1"}, record{Z: 1, A: "before"}); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(filepath.Join(root, "session", "s1.json")) + if err != nil { + t.Fatal(err) + } + want := "{\n \"z\": 1,\n \"a\": \"before\"\n}" + if string(raw) != want { + t.Fatalf("write bytes:\n got %q\nwant %q", raw, want) + } + + updated, err := store.Update([]string{"session", "s1"}, func(value any) { + object := value.(map[string]any) + object["a"] = "after" + object["new"] = true + }) + if err != nil { + t.Fatal(err) + } + wantUpdated := map[string]any{"z": float64(1), "a": "after", "new": true} + if !reflect.DeepEqual(updated, wantUpdated) { + t.Fatalf("updated value: %#v", updated) + } + if got, err := ReadAs[record](store, []string{"session", "s1"}); err != nil || got.A != "after" { + t.Fatalf("read after update: %+v, %v", got, err) + } + + keys, err := store.List([]string{"session"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(keys, [][]string{{"session", "s1"}}) { + t.Fatalf("list: %#v", keys) + } + + if err := store.Remove([]string{"session", "s1"}); err != nil { + t.Fatal(err) + } + if err := store.Remove([]string{"session", "s1"}); err != nil { + t.Fatal(err) + } + _, err = store.Read([]string{"session", "s1"}) + var notFound *NotFoundError + if !errors.As(err, ¬Found) { + t.Fatalf("expected NotFoundError, got %v", err) + } + if notFound.Message != "Resource not found: "+filepath.Join(root, "session", "s1.json") { + t.Fatalf("message: %q", notFound.Message) + } +} + +func TestStoreUpdateSerializesConcurrentMutations(t *testing.T) { + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + type counter struct { + N int `json:"n"` + } + if err := store.Write([]string{"counter"}, counter{}); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for range 30 { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := UpdateAs(store, []string{"counter"}, func(value *counter) { + value.N++ + }); err != nil { + t.Errorf("update: %v", err) + } + }() + } + wg.Wait() + got, err := ReadAs[counter](store, []string{"counter"}) + if err != nil { + t.Fatal(err) + } + if got.N != 30 { + t.Fatalf("counter = %d, want 30", got.N) + } +} + +func TestStoreUpdatesDifferentResourcesConcurrently(t *testing.T) { + // Unrelated resources do not queue behind a store-wide exclusive lock. + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + for _, key := range []string{"first", "second"} { + if err := store.Write([]string{key}, map[string]any{"value": 0}); err != nil { + t.Fatal(err) + } + } + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"first"}, func(any) { + close(firstEntered) + <-releaseFirst + }) + firstDone <- err + }() + <-firstEntered + secondEntered := make(chan struct{}) + secondDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"second"}, func(any) { close(secondEntered) }) + secondDone <- err + }() + select { + case <-secondEntered: + case <-time.After(2 * time.Second): + close(releaseFirst) + t.Fatal("different-resource update blocked behind the first resource") + } + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } +} + +func TestStoreUpdateCallbackOwnsResourceLock(t *testing.T) { + // Update's callback runs while holding the resource lock, so same-key + // callers serialize around the non-reentrant callback. + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + if err := store.Write([]string{"shared"}, map[string]any{"value": 0}); err != nil { + t.Fatal(err) + } + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"shared"}, func(any) { + close(firstEntered) + <-releaseFirst + }) + firstDone <- err + }() + <-firstEntered + secondEntered := make(chan struct{}) + secondDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"shared"}, func(any) { close(secondEntered) }) + secondDone <- err + }() + select { + case <-secondEntered: + close(releaseFirst) + t.Fatal("same-resource callback ran without owning the resource lock") + case <-time.After(25 * time.Millisecond): + } + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + select { + case <-secondEntered: + case <-time.After(2 * time.Second): + t.Fatal("same-resource waiter did not resume after callback returned") + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } +} + +func TestStoreCrossProcessLockAndAtomicReplacement(t *testing.T) { + // Independent store instances cannot lose a read-modify-write, and each + // durable rewrite is an atomic inode replacement. + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + type counter struct { + N int `json:"n"` + } + first, second := New(root), New(root) + if err := first.Write([]string{"counter"}, counter{}); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "counter.json") + commands := make([]*exec.Cmd, 2) + for index := range commands { + commands[index] = exec.Command(os.Args[0], "-test.run=^TestStoreProcessUpdateHelper$", "-test.count=1") + commands[index].Env = append(os.Environ(), "SENIOR_DEV_STORAGE_HELPER_ROOT="+root) + if err := commands[index].Start(); err != nil { + t.Fatal(err) + } + } + for _, command := range commands { + if err := command.Wait(); err != nil { + t.Fatalf("storage helper: %v", err) + } + } + got, err := ReadAs[counter](second, []string{"counter"}) + if err != nil || got.N != 40 { + t.Fatalf("cross-store counter = %+v, %v; want 40", got, err) + } + before, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if err := first.Write([]string{"counter"}, got); err != nil { + t.Fatal(err) + } + after, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if os.SameFile(before, after) { + t.Fatal("durable rewrite mutated the JSON inode in place") + } + matches, err := filepath.Glob(filepath.Join(root, ".tmp-*.json")) + if err != nil || len(matches) != 0 { + t.Fatalf("atomic rewrite leftovers = %v, %v", matches, err) + } +} + +func TestStoreProcessUpdateHelper(t *testing.T) { + root := os.Getenv("SENIOR_DEV_STORAGE_HELPER_ROOT") + if root == "" { + t.Skip("subprocess helper") + } + type counter struct { + N int `json:"n"` + } + store := New(root) + for index := 0; index < 20; index++ { + if _, err := UpdateAs(store, []string{"counter"}, func(value *counter) { value.N++ }); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/seniordev/tool/apply_patch.go b/internal/seniordev/tool/apply_patch.go new file mode 100644 index 000000000..7a6994b5d --- /dev/null +++ b/internal/seniordev/tool/apply_patch.go @@ -0,0 +1,307 @@ +//go:build !windows + +// The apply_patch tool: a multi-file patch envelope (add, update, move, +// delete) validated up front and applied after one permission check that +// covers every file it touches. +package tool + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + patchpkg "github.com/Agent-Field/codeaf/internal/seniordev/patch" +) + +type applyPatchChange struct { + filePath string + oldContent string + newContent string + kind string + movePath string + diff string + additions int + deletions int + bom bool +} + +type applyPatchFileMetadata struct { + FilePath string `json:"filePath"` + RelativePath string `json:"relativePath"` + Type string `json:"type"` + Patch string `json:"patch"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + MovePath string `json:"movePath,omitempty"` +} + +type applyPatchMetadata struct { + Diff string `json:"diff"` + Files []applyPatchFileMetadata `json:"files"` + Diagnostics map[string]any `json:"diagnostics"` +} + +func (r *Registry) executeApplyPatch(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input applyPatchInput + if err := decodeInput(call.Input, &input, "patchText"); err != nil { + return steploop.ToolResult{}, err + } + if input.PatchText == "" { + return steploop.ToolResult{}, errors.New("patchText is required") + } + parsed, err := patchpkg.ParsePatch(input.PatchText) + if err != nil { + return steploop.ToolResult{}, fmt.Errorf("apply_patch verification failed: Error: %s", err) + } + if len(parsed.Hunks) == 0 { + normalized := strings.ReplaceAll(input.PatchText, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + normalized = strings.TrimSpace(normalized) + if normalized == "*** Begin Patch\n*** End Patch" { + return steploop.ToolResult{}, errors.New("patch rejected: empty patch") + } + return steploop.ToolResult{}, errors.New("apply_patch verification failed: no hunks found") + } + formatter, err := r.formatterService() + if err != nil { + return steploop.ToolResult{}, err + } + + changes := make([]applyPatchChange, 0, len(parsed.Hunks)) + for _, hunk := range parsed.Hunks { + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + filePath, err := r.resolvePath(hunk.Path) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, filePath, "file"); err != nil { + return steploop.ToolResult{}, err + } + switch hunk.Type { + case "add": + newContent := hunk.Contents + if newContent != "" && !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } + bom, newContent := splitBOM(newContent) + additions, deletions := lineChangeCounts("", newContent) + change := applyPatchChange{ + filePath: filePath, + oldContent: "", + newContent: newContent, + kind: "add", + diff: proposedFileDiff(filePath, "", newContent), + additions: additions, + deletions: deletions, + bom: bom, + } + changes = append(changes, change) + case "update": + info, statErr := os.Stat(filePath) + if statErr != nil || info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf( + "apply_patch verification failed: Failed to read file to update: %s", + filePath, + ) + } + source, readErr := os.ReadFile(filePath) + if readErr != nil { + return steploop.ToolResult{}, fmt.Errorf( + "apply_patch verification failed: Failed to read file to update: %s", + filePath, + ) + } + sourceBOM, oldContent := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + update, deriveErr := patchpkg.DeriveNewContentsFromChunks(filePath, hunk.Chunks) + if deriveErr != nil { + return steploop.ToolResult{}, fmt.Errorf("apply_patch verification failed: Error: %s", deriveErr) + } + movePath := "" + if hunk.MovePath != "" { + movePath, err = r.resolvePath(hunk.MovePath) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, movePath, "file"); err != nil { + return steploop.ToolResult{}, err + } + } + additions, deletions := lineChangeCounts(oldContent, update.Content) + kind := "update" + if hunk.MovePath != "" { + kind = "move" + } + change := applyPatchChange{ + filePath: filePath, + oldContent: oldContent, + newContent: update.Content, + kind: kind, + movePath: movePath, + diff: proposedFileDiff(filePath, oldContent, update.Content), + additions: additions, + deletions: deletions, + bom: sourceBOM || update.BOM, + } + changes = append(changes, change) + case "delete": + source, readErr := os.ReadFile(filePath) + if readErr != nil { + return steploop.ToolResult{}, fmt.Errorf("apply_patch verification failed: %s", readErr) + } + bom, oldContent := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + change := applyPatchChange{ + filePath: filePath, + oldContent: oldContent, + newContent: "", + kind: "delete", + diff: proposedFileDiff(filePath, oldContent, ""), + additions: 0, + deletions: len(strings.Split(oldContent, "\n")), + bom: bom, + } + changes = append(changes, change) + } + } + + permissionFiles := make([]applyPatchFileMetadata, 0, len(changes)) + mutationPaths := make([]string, 0, len(changes)*2) + permissionPaths := make([]string, 0, len(changes)) + proposedTotalDiff := "" + for _, change := range changes { + proposedTotalDiff += change.diff + "\n" + mutationPaths = append(mutationPaths, change.filePath) + if change.movePath != "" { + mutationPaths = append(mutationPaths, change.movePath) + } + permissionPath, relErr := filepath.Rel(r.worktree(), change.filePath) + if relErr != nil { + permissionPath = change.filePath + } + permissionPaths = append(permissionPaths, filepath.ToSlash(permissionPath)) + target := change.filePath + if change.movePath != "" { + target = change.movePath + } + relative, err := filepath.Rel(r.workDir, target) + if err != nil { + relative = target + } + permissionFiles = append(permissionFiles, applyPatchFileMetadata{ + FilePath: change.filePath, + RelativePath: filepath.ToSlash(relative), + Type: change.kind, + Patch: change.diff, + Additions: change.additions, + Deletions: change.deletions, + MovePath: change.movePath, + }) + } + metadata := map[string]any{ + "filepath": strings.Join(permissionPaths, ", "), + "diff": proposedTotalDiff, + "files": permissionFiles, + } + if err := r.ask(ctx, call, "edit", permissionPaths, metadata); err != nil { + return steploop.ToolResult{}, err + } + + for index := range changes { + change := &changes[index] + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + switch change.kind { + case "add", "update": + if err := os.MkdirAll(filepath.Dir(change.filePath), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(change.filePath, []byte(joinBOM(change.newContent, change.bom)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + change.newContent, err = formatMutationFile(ctx, formatter, change.filePath, change.bom) + if err != nil { + return steploop.ToolResult{}, err + } + case "move": + if err := os.MkdirAll(filepath.Dir(change.movePath), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(change.movePath, []byte(joinBOM(change.newContent, change.bom)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + if err := os.Remove(change.filePath); err != nil { + return steploop.ToolResult{}, err + } + change.newContent, err = formatMutationFile(ctx, formatter, change.movePath, change.bom) + if err != nil { + return steploop.ToolResult{}, err + } + case "delete": + if err := os.Remove(change.filePath); err != nil { + return steploop.ToolResult{}, err + } + } + } + + totalDiff := "" + files := make([]applyPatchFileMetadata, 0, len(changes)) + for index := range changes { + change := &changes[index] + target := change.filePath + if change.movePath != "" { + target = change.movePath + } + change.diff = TrimDiff(patchpkg.GenerateTwoFilesPatch(target, change.oldContent, change.newContent)) + change.additions, change.deletions = lineChangeCounts(change.oldContent, change.newContent) + totalDiff += change.diff + "\n" + relative, relErr := filepath.Rel(r.workDir, target) + if relErr != nil { + relative = target + } + files = append(files, applyPatchFileMetadata{ + FilePath: change.filePath, + RelativePath: filepath.ToSlash(relative), + Type: change.kind, + Patch: change.diff, + Additions: change.additions, + Deletions: change.deletions, + MovePath: change.movePath, + }) + } + + summary := make([]string, 0, len(changes)) + for _, change := range changes { + target := change.filePath + prefix := "M " + if change.kind == "add" { + prefix = "A " + } + if change.kind == "delete" { + prefix = "D " + } + if change.movePath != "" { + target = change.movePath + } + relative, err := filepath.Rel(r.workDir, target) + if err != nil { + relative = target + } + summary = append(summary, prefix+filepath.ToSlash(relative)) + } + output := "Success. Updated the following files:\n" + strings.Join(summary, "\n") + return steploop.ToolResult{ + Title: output, + Output: output, + Metadata: rawMetadata(applyPatchMetadata{ + Diff: totalDiff, + Files: files, + Diagnostics: map[string]any{}, + }), + }, nil +} diff --git a/internal/seniordev/tool/apply_patch.txt b/internal/seniordev/tool/apply_patch.txt new file mode 100644 index 000000000..5b2d95608 --- /dev/null +++ b/internal/seniordev/tool/apply_patch.txt @@ -0,0 +1,33 @@ +Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: + +*** Begin Patch +[ one or more file sections ] +*** End Patch + +Within that envelope, you get a sequence of file operations. +You MUST include a header to specify the action you are taking. +Each operation starts with one of three headers: + +*** Add File: - create a new file. Every following line is a + line (the initial contents). +*** Delete File: - remove an existing file. Nothing follows. +*** Update File: - patch an existing file in place (optionally with a rename). + +Example patch: + +``` +*** Begin Patch +*** Add File: hello.txt ++Hello world +*** Update File: src/app.py +*** Move to: src/main.py +@@ def greet(): +-print("Hi") ++print("Hello, world!") +*** Delete File: obsolete.txt +*** End Patch +``` + +It is important to remember: + +- You must include a header with your intended action (Add/Delete/Update) +- You must prefix new lines with `+` even when creating a new file diff --git a/internal/seniordev/tool/apply_patch_description.go b/internal/seniordev/tool/apply_patch_description.go new file mode 100644 index 000000000..8be462b85 --- /dev/null +++ b/internal/seniordev/tool/apply_patch_description.go @@ -0,0 +1,8 @@ +//go:build !windows + +package tool + +import _ "embed" + +//go:embed apply_patch.txt +var applyPatchDescription string diff --git a/internal/seniordev/tool/apply_patch_test.go b/internal/seniordev/tool/apply_patch_test.go new file mode 100644 index 000000000..17738e217 --- /dev/null +++ b/internal/seniordev/tool/apply_patch_test.go @@ -0,0 +1,101 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyPatchAddUpdateMoveDelete(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "update.txt", "one\ntwo\n") + writeTestFile(t, workDir, "move.txt", "\ufeffold\n") + writeTestFile(t, workDir, "delete.txt", "gone\n") + patchText := `*** Begin Patch +*** Add File: nested/added.txt ++added +*** Update File: update.txt +@@ +-two ++second +*** Update File: move.txt +*** Move to: moved/new.txt +@@ +-old ++new +*** Delete File: delete.txt +*** End Patch` + result, err := execute(t, New(workDir), "apply_patch", map[string]any{"patchText": patchText}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "Success. Updated the following files:\n" + + "A nested/added.txt\n" + + "M update.txt\n" + + "M moved/new.txt\n" + + "D delete.txt" + if result.Output != want || result.Title != want { + t.Fatalf("result output = %q", result.Output) + } + assertTestFile(t, workDir, "nested/added.txt", "added\n") + assertTestFile(t, workDir, "update.txt", "one\nsecond\n") + assertTestFile(t, workDir, "moved/new.txt", "\ufeffnew\n") + for _, path := range []string{"move.txt", "delete.txt"} { + if _, err := os.Stat(filepath.Join(workDir, path)); !os.IsNotExist(err) { + t.Fatalf("%s still exists, err=%v", path, err) + } + } +} + +func TestApplyPatchVerificationErrors(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + cases := []struct { + name string + patchText string + want string + }{ + {"required", "", "patchText is required"}, + { + "parse", + "bad", + "apply_patch verification failed: Error: Invalid patch format: missing Begin/End markers", + }, + { + "empty", + "*** Begin Patch\r\n*** End Patch", + "patch rejected: empty patch", + }, + { + "no hunks", + "*** Begin Patch\njunk\n*** End Patch", + "apply_patch verification failed: no hunks found", + }, + { + "missing update", + "*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch", + "apply_patch verification failed: Failed to read file to update: " + filepath.Join(workDir, "missing.txt"), + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + _, err := execute(t, registry, "apply_patch", map[string]any{"patchText": test.patchText}) + if err == nil || err.Error() != test.want { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestApplyPatchDescription(t *testing.T) { + for _, fragment := range []string{ + "*** Begin Patch", "*** End Patch", "*** Add File:", "*** Update File:", "*** Delete File:", + } { + if !strings.Contains(applyPatchDescription, fragment) { + t.Fatalf("embedded description lacks %q", fragment) + } + } +} diff --git a/internal/seniordev/tool/bash.go b/internal/seniordev/tool/bash.go new file mode 100644 index 000000000..e0ef7538c --- /dev/null +++ b/internal/seniordev/tool/bash.go @@ -0,0 +1,308 @@ +//go:build !windows + +// The bash tool: runs a command in the workspace shell, enforces its timeout, +// and turns the exit into a tool result with the output capped and spilled. +package tool + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "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/session/outputoffload" +) + +const ( + defaultBashTimeoutMS = 120000 + maxBashOutputBytes = 30000 +) + +var bashAfter = time.After +var bashOffloader = outputoffload.DefaultOffloader +var bashReadOOMCount = readShellOOMCount +var bashReadMemoryLimit = readShellMemoryLimit + +type bashOutput struct { + mu sync.Mutex + buffer bytes.Buffer + wrote bool + lastOutput time.Time +} + +func (o *bashOutput) Write(data []byte) (int, error) { + o.mu.Lock() + defer o.mu.Unlock() + o.wrote = true + o.lastOutput = time.Now() + return o.buffer.Write(data) +} + +func (o *bashOutput) bytes() []byte { + o.mu.Lock() + defer o.mu.Unlock() + return append([]byte(nil), o.buffer.Bytes()...) +} + +func (o *bashOutput) lastOutputAt() (time.Time, bool) { + o.mu.Lock() + defer o.mu.Unlock() + return o.lastOutput, o.wrote +} + +func (r *Registry) executeBash(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input bashInput + if err := decodeInput(call.Input, &input, "command"); err != nil { + return steploop.ToolResult{}, err + } + timeoutMS := defaultBashTimeoutMS + if input.TimeoutMS != nil { + timeoutMS = *input.TimeoutMS + } + if timeoutMS < 1 || timeoutMS > 600000 { + return steploop.ToolResult{}, fmt.Errorf("timeout_ms must be between 1 and 600000") + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + cwd := r.workDir + if input.Workdir != "" { + resolved, err := r.resolvePath(input.Workdir) + if err != nil { + return steploop.ToolResult{}, err + } + cwd = resolved + info, statErr := os.Stat(cwd) + if statErr != nil { + return steploop.ToolResult{}, statErr + } + if !info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf("workdir must be a directory: %s", cwd) + } + if err := r.askExternalDirectory(ctx, call, cwd, "directory"); err != nil { + return steploop.ToolResult{}, err + } + } + shell, err := r.executionShell() + if err != nil { + return steploop.ToolResult{}, err + } + scan := ScanShellPermissions(input.Command, ShellScanOptions{ + CWD: cwd, Workspace: r.worktree(), Shell: shell, + IsDir: func(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() + }, + }) + if r.hardConfineShell && len(scan.Dirs) > 0 { + return steploop.ToolResult{}, fmt.Errorf("path escapes workspace: %s", scan.Dirs[0]) + } + if len(scan.Dirs) > 0 { + globs := make([]string, 0, len(scan.Dirs)) + for _, dir := range scan.Dirs { + globs = append(globs, filepath.Join(dir, "*")) + } + if err := r.askWithAlways(ctx, call, "external_directory", globs, globs, map[string]any{}); err != nil { + return steploop.ToolResult{}, err + } + } + if len(scan.Patterns) > 0 { + if err := r.askWithAlways(ctx, call, "bash", scan.Patterns, scan.Always, map[string]any{}); err != nil { + return steploop.ToolResult{}, err + } + } + command := shellExecCommand(shell, input.Command) + command.Dir = cwd + command.Env = shellEnvironment(call.SessionID) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + var output bashOutput + command.Stdout = &output + command.Stderr = &output + envSignalsOn := os.Getenv("SENIOR_DEV_ENV_SIGNALS") != "0" + startedAt := time.Now() + var oomBefore *int64 + if envSignalsOn { + oomBefore = bashReadOOMCount() + } + if err := command.Start(); err != nil { + return steploop.ToolResult{}, fmt.Errorf("start shell command: %w", err) + } + + done := make(chan error, 1) + go func() { + done <- command.Wait() + }() + + var runErr error + expired := false + select { + case runErr = <-done: + case <-bashAfter(time.Duration(timeoutMS) * time.Millisecond): + expired = true + killProcessGroup(command.Process.Pid) + <-done + appendOutputLine(&output, fmt.Sprintf("command timed out after %dms", timeoutMS)) + case <-ctx.Done(): + killProcessGroup(command.Process.Pid) + <-done + return steploop.ToolResult{}, ctx.Err() + } + + var exitCode *int + if runErr == nil && !expired { + code := 0 + exitCode = &code + } else if !expired { + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + return steploop.ToolResult{}, fmt.Errorf("wait for shell command: %w", runErr) + } + code := normalizedExitCode(exitErr) + exitCode = &code + appendOutputLine(&output, fmt.Sprintf("exit status %d", code)) + } + + if envSignalsOn { + duration := time.Since(startedAt) + oomAfter := bashReadOOMCount() + var oomDelta *int64 + if oomBefore != nil && oomAfter != nil { + delta := *oomAfter - *oomBefore + oomDelta = &delta + } + var sinceLast *time.Duration + if lastOutput, ok := output.lastOutputAt(); ok { + quiet := time.Since(lastOutput) + sinceLast = &quiet + } + metadata := []string{} + if death := classifyShellDeath(shellDeathInput{ + ExitCode: exitCode, Expired: expired, OOMDelta: oomDelta, + MemoryLimitBytes: bashReadMemoryLimit(), SinceLastOutput: sinceLast, + Timeout: time.Duration(timeoutMS) * time.Millisecond, CommandDuration: duration, + }); death != "" { + metadata = append(metadata, death) + } + failed := exitCode == nil || *exitCode != 0 + if repeat := registerShellOutcome(call.SessionID, input.Command, duration, failed); repeat != "" { + metadata = append(metadata, repeat) + } + if len(metadata) > 0 { + appendOutputLine(&output, "\n\n"+strings.Join(metadata, "\n")+"\n") + } + } + + fullOutput := output.bytes() + code := -1 + if exitCode != nil { + code = *exitCode + } + return r.bashResult(call, input.Command, fullOutput, code, exitCode != nil), nil +} + +func (r *Registry) bashResult( + call steploop.ToolCall, command string, fullOutput []byte, exitCode int, hasExitCode bool, +) steploop.ToolResult { + inline := truncateMiddle(fullOutput, maxBashOutputBytes) + if len(fullOutput) > maxBashOutputBytes { + offloaded := bashOffloader.OffloadLargeOutput( + outputoffload.OutputOffloadInput{ + Output: string(fullOutput), Workspace: r.workDir, + ToolName: "bash", CallID: call.ID, SessionID: call.SessionID, + }, + outputoffload.OutputOffloadOptions{Force: true}, + ) + if offloaded.OffloadPath != nil { + inline += "\n\nThe tool call succeeded but the output was truncated. Full output saved to: " + *offloaded.OffloadPath + + "\nUse Grep to search the full content or Read with offset/limit to view specific sections." + } else if fallback := strings.TrimSpace(offloaded.Inline); fallback != "" { + if index := strings.LastIndex(fallback, "\n"); index >= 0 { + fallback = fallback[index+1:] + } + inline += "\n\n" + fallback + } + } + metadata := msgmodel.RawObject("{}") + if hasExitCode { + metadata = msgmodel.RawObject(fmt.Sprintf(`{"exitCode":%d}`, exitCode)) + } + return steploop.ToolResult{Title: firstRunes(command, 60), Metadata: metadata, Output: inline} +} + +func shellExecCommand(shell, command string) *exec.Cmd { + switch ShellName(shell) { + case "cmd": + return exec.Command(shell, "/c", command) + case "powershell", "pwsh": + return exec.Command(shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command) + default: + return exec.Command(shell, "-c", command) + } +} + +func normalizedExitCode(exitErr *exec.ExitError) int { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + return exitErr.ExitCode() +} + +func killProcessGroup(pid int) { + _ = syscall.Kill(-pid, syscall.SIGKILL) +} + +func appendOutputLine(output *bashOutput, line string) { + output.mu.Lock() + defer output.mu.Unlock() + if output.buffer.Len() > 0 && output.buffer.Bytes()[output.buffer.Len()-1] != '\n' { + output.buffer.WriteByte('\n') + } + output.buffer.WriteString(line) +} + +func firstRunes(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func truncateMiddle(data []byte, limit int) string { + if len(data) <= limit { + return string(data) + } + + removed := len(data) - limit + var marker string + var kept int + for { + marker = fmt.Sprintf("[... %d bytes truncated ...]", removed) + kept = limit - len(marker) + if kept < 0 { + return marker[:limit] + } + actualRemoved := len(data) - kept + if actualRemoved == removed { + break + } + removed = actualRemoved + } + + head := kept / 2 + tail := kept - head + result := make([]byte, 0, limit) + result = append(result, data[:head]...) + result = append(result, marker...) + result = append(result, data[len(data)-tail:]...) + return string(result) +} diff --git a/internal/seniordev/tool/bash_clock_test.go b/internal/seniordev/tool/bash_clock_test.go new file mode 100644 index 000000000..3868dd859 --- /dev/null +++ b/internal/seniordev/tool/bash_clock_test.go @@ -0,0 +1,32 @@ +//go:build !windows + +package tool + +import ( + "strings" + "testing" + "time" +) + +func TestBashTimeoutWithFakeClockInTempDir(t *testing.T) { + previous := bashAfter + t.Cleanup(func() { bashAfter = previous }) + bashAfter = func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Unix(0, 0) + return ch + } + + registry := New(t.TempDir()) + result, err := execute(t, registry, "bash", map[string]any{ + "command": "sleep 30", + "timeout_ms": 10_000, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "command timed out after 10000ms") || + !strings.Contains(result.Output, "[environment-signal] this command timed out") { + t.Fatalf("Output = %q", result.Output) + } +} diff --git a/internal/seniordev/tool/descriptions.go b/internal/seniordev/tool/descriptions.go new file mode 100644 index 000000000..f6f63892d --- /dev/null +++ b/internal/seniordev/tool/descriptions.go @@ -0,0 +1,38 @@ +//go:build !windows + +package tool + +const readDescription = "Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\n" + + "\n" + + "Usage:\n" + + "- The filePath parameter should be an absolute path.\n" + + "- By default, this tool returns up to 2000 lines from the start of the file.\n" + + "- The offset parameter is the line number to start from (1-indexed).\n" + + "- To read later sections, call this tool again with a larger offset.\n" + + "- Use the grep tool to find specific content in large files or files with long lines.\n" + + "- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\n" + + "- Contents are returned with each line prefixed by its line number as `: `. For example, if a file has contents \"foo\\n\", you will receive \"1: foo\\n\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\n" + + "- Any line longer than 2000 characters is truncated.\n" + + "- Call this tool in parallel when you know there are multiple files you want to read.\n" + + "- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\n" + + "- This tool can read image files and PDFs and return them as file attachments.\n" + +const writeDescription = `Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- Overwriting an existing file replaces all of its contents; read it first unless you already know exactly what it contains. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +` + +const editDescription = "Performs exact string replacements in files. \n" + + "\n" + + "Usage:\n" + + "- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.\n" + + "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n" + + "- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n" + + "- The edit will FAIL if `oldString` is not found in the file with an error \"oldString not found in content\".\n" + + "- The edit will FAIL if `oldString` is found multiple times in the file with an error \"Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.\" Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. \n" + + "- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.\n" diff --git a/internal/seniordev/tool/edit.go b/internal/seniordev/tool/edit.go new file mode 100644 index 000000000..d50b87455 --- /dev/null +++ b/internal/seniordev/tool/edit.go @@ -0,0 +1,869 @@ +//go:build !windows + +// The edit tool: exact string replacement backed by a ladder of progressively +// more lenient matchers, tried in order until exactly one match is found. +package tool + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + patchpkg "github.com/Agent-Field/codeaf/internal/seniordev/patch" + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +const ( + singleCandidateSimilarityThreshold = 0.0 + multipleCandidatesSimilarityThreshold = 0.3 +) + +var editLocks sync.Map + +type editFileDiff struct { + File string `json:"file"` + Patch string `json:"patch"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +type editMetadata struct { + Diagnostics map[string]any `json:"diagnostics"` + Diff string `json:"diff"` + FileDiff editFileDiff `json:"filediff"` +} + +type blockCandidate struct { + startLine int + endLine int +} + +type replacer func(content string, find string) []string + +func (r *Registry) executeEdit(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input editInput + if err := decodeInput(call.Input, &input, "filePath", "oldString", "newString"); err != nil { + return steploop.ToolResult{}, err + } + if input.FilePath == "" { + return steploop.ToolResult{}, errors.New("filePath is required") + } + if input.OldString == input.NewString { + return steploop.ToolResult{}, errors.New("No changes to apply: oldString and newString are identical.") + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + + resolved, err := r.resolvePath(input.FilePath) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, resolved, "file"); err != nil { + return steploop.ToolResult{}, err + } + formatter, err := r.formatterService() + if err != nil { + return steploop.ToolResult{}, err + } + mutexValue, _ := editLocks.LoadOrStore(resolved, &sync.Mutex{}) + mutex := mutexValue.(*sync.Mutex) + mutex.Lock() + defer mutex.Unlock() + + var contentOld string + var contentNew string + var desiredBOM bool + if input.OldString == "" { + source, readErr := os.ReadFile(resolved) + if readErr != nil && !os.IsNotExist(readErr) { + return steploop.ToolResult{}, readErr + } + sourceBOM, sourceText := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + nextBOM, nextText := splitBOM(input.NewString) + contentOld = sourceText + contentNew = nextText + desiredBOM = sourceBOM || nextBOM + } else { + info, statErr := os.Stat(resolved) + if os.IsNotExist(statErr) { + return steploop.ToolResult{}, fmt.Errorf("File %s not found", resolved) + } + if statErr != nil { + return steploop.ToolResult{}, statErr + } + if info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf("Path is a directory, not a file: %s", resolved) + } + source, readErr := os.ReadFile(resolved) + if readErr != nil { + return steploop.ToolResult{}, readErr + } + sourceBOM, sourceText := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + contentOld = sourceText + ending := detectLineEnding(contentOld) + oldString := convertToLineEnding(normalizeLineEndings(input.OldString), ending) + newString := convertToLineEnding(normalizeLineEndings(input.NewString), ending) + replaced, replaceErr := Replace(contentOld, oldString, newString, input.ReplaceAll) + if replaceErr != nil { + return steploop.ToolResult{}, replaceErr + } + nextBOM, nextText := splitBOM(replaced) + contentNew = nextText + desiredBOM = sourceBOM || nextBOM + } + + proposedOld, proposedNew := contentOld, contentNew + if input.OldString != "" { + proposedOld = normalizeLineEndings(proposedOld) + proposedNew = normalizeLineEndings(proposedNew) + } + proposedDiff := proposedFileDiff(resolved, proposedOld, proposedNew) + pattern, relErr := filepath.Rel(r.worktree(), resolved) + if relErr != nil { + pattern = resolved + } + metadata := map[string]any{"filepath": resolved, "diff": proposedDiff} + if err := r.ask(ctx, call, "edit", []string{filepath.ToSlash(pattern)}, metadata); err != nil { + return steploop.ToolResult{}, err + } + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(resolved, []byte(joinBOM(contentNew, desiredBOM)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + contentNew, err = formatMutationFile(ctx, formatter, resolved, desiredBOM) + if err != nil { + return steploop.ToolResult{}, err + } + + util.EagerCommit(ctx, util.EagerCommitOptions{Cwd: r.workDir, FilePath: resolved, Label: "edit"}) + + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + additions, deletions := lineChangeCounts(contentOld, contentNew) + diff := TrimDiff(patchpkg.GenerateTwoFilesPatch(resolved, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + )) + fileDiff := editFileDiff{ + File: resolved, + Patch: diff, + Additions: additions, + Deletions: deletions, + } + return steploop.ToolResult{ + Title: title, + Output: "Edit applied successfully.", + Metadata: rawMetadata(editMetadata{ + Diagnostics: map[string]any{}, + Diff: diff, + FileDiff: fileDiff, + }), + }, nil +} + +func normalizeLineEndings(text string) string { + return strings.ReplaceAll(text, "\r\n", "\n") +} + +func detectLineEnding(text string) string { + if strings.Contains(text, "\r\n") { + return "\r\n" + } + return "\n" +} + +func convertToLineEnding(text string, ending string) string { + if ending == "\n" { + return text + } + return strings.ReplaceAll(text, "\n", "\r\n") +} + +func proposedFileDiff(filePath, oldContent, newContent string) string { + return TrimDiff(patchpkg.GenerateTwoFilesPatch(filePath, oldContent, newContent)) +} + +func levenshtein(a string, b string) int { + aa := utf16.Encode([]rune(a)) + bb := utf16.Encode([]rune(b)) + if len(aa) == 0 || len(bb) == 0 { + if len(aa) > len(bb) { + return len(aa) + } + return len(bb) + } + previous := make([]int, len(bb)+1) + current := make([]int, len(bb)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(aa); i++ { + current[0] = i + for j := 1; j <= len(bb); j++ { + cost := 1 + if aa[i-1] == bb[j-1] { + cost = 0 + } + current[j] = min3(previous[j]+1, current[j-1]+1, previous[j-1]+cost) + } + previous, current = current, previous + } + return previous[len(bb)] +} + +func min3(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} + +// SimpleReplacer matches the search text exactly. +func SimpleReplacer(_ string, find string) []string { + return []string{find} +} + +// LineTrimmedReplacer matches line by line, ignoring leading and trailing +// whitespace on each line. +func LineTrimmedReplacer(content string, find string) []string { + originalLines := strings.Split(content, "\n") + searchLines := strings.Split(find, "\n") + if searchLines[len(searchLines)-1] == "" { + searchLines = searchLines[:len(searchLines)-1] + } + out := []string{} + for i := 0; i <= len(originalLines)-len(searchLines); i++ { + matches := true + for j := range searchLines { + if strings.TrimSpace(originalLines[i+j]) != strings.TrimSpace(searchLines[j]) { + matches = false + break + } + } + if matches { + out = append(out, strings.Join(originalLines[i:i+len(searchLines)], "\n")) + } + } + return out +} + +// BlockAnchorReplacer matches a block by its first and last lines and scores +// the lines between them by similarity; a lone candidate is accepted outright, +// competing candidates must clear multipleCandidatesSimilarityThreshold. +func BlockAnchorReplacer(content string, find string) []string { + originalLines := strings.Split(content, "\n") + searchLines := strings.Split(find, "\n") + if len(searchLines) < 3 { + return []string{} + } + if searchLines[len(searchLines)-1] == "" { + searchLines = searchLines[:len(searchLines)-1] + } + firstLineSearch := strings.TrimSpace(searchLines[0]) + lastLineSearch := strings.TrimSpace(searchLines[len(searchLines)-1]) + searchBlockSize := len(searchLines) + candidates := []blockCandidate{} + for i := 0; i < len(originalLines); i++ { + if strings.TrimSpace(originalLines[i]) != firstLineSearch { + continue + } + for j := i + 2; j < len(originalLines); j++ { + if strings.TrimSpace(originalLines[j]) == lastLineSearch { + candidates = append(candidates, blockCandidate{i, j}) + break + } + } + } + if len(candidates) == 0 { + return []string{} + } + if len(candidates) == 1 { + candidate := candidates[0] + actualBlockSize := candidate.endLine - candidate.startLine + 1 + similarity := 0.0 + linesToCheck := searchBlockSize - 2 + if actualBlockSize-2 < linesToCheck { + linesToCheck = actualBlockSize - 2 + } + if linesToCheck > 0 { + for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ { + originalLine := strings.TrimSpace(originalLines[candidate.startLine+j]) + searchLine := strings.TrimSpace(searchLines[j]) + maxLen := utf16Length(originalLine) + if value := utf16Length(searchLine); value > maxLen { + maxLen = value + } + if maxLen == 0 { + continue + } + distance := levenshtein(originalLine, searchLine) + similarity += (1 - float64(distance)/float64(maxLen)) / float64(linesToCheck) + if similarity >= singleCandidateSimilarityThreshold { + break + } + } + } else { + similarity = 1.0 + } + if similarity >= singleCandidateSimilarityThreshold { + return []string{strings.Join(originalLines[candidate.startLine:candidate.endLine+1], "\n")} + } + return []string{} + } + + var best *blockCandidate + maxSimilarity := -1.0 + for i := range candidates { + candidate := candidates[i] + actualBlockSize := candidate.endLine - candidate.startLine + 1 + similarity := 0.0 + linesToCheck := searchBlockSize - 2 + if actualBlockSize-2 < linesToCheck { + linesToCheck = actualBlockSize - 2 + } + if linesToCheck > 0 { + for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ { + originalLine := strings.TrimSpace(originalLines[candidate.startLine+j]) + searchLine := strings.TrimSpace(searchLines[j]) + maxLen := utf16Length(originalLine) + if value := utf16Length(searchLine); value > maxLen { + maxLen = value + } + if maxLen == 0 { + continue + } + similarity += 1 - float64(levenshtein(originalLine, searchLine))/float64(maxLen) + } + similarity /= float64(linesToCheck) + } else { + similarity = 1.0 + } + if similarity > maxSimilarity { + maxSimilarity = similarity + copy := candidate + best = © + } + } + if maxSimilarity >= multipleCandidatesSimilarityThreshold && best != nil { + return []string{strings.Join(originalLines[best.startLine:best.endLine+1], "\n")} + } + return []string{} +} + +// WhitespaceNormalizedReplacer matches after collapsing every run of +// whitespace to a single space. +func WhitespaceNormalizedReplacer(content string, find string) []string { + normalizedFind := normalizeWhitespace(find) + lines := strings.Split(content, "\n") + out := []string{} + for _, line := range lines { + if normalizeWhitespace(line) == normalizedFind { + out = append(out, line) + continue + } + normalizedLine := normalizeWhitespace(line) + if strings.Contains(normalizedLine, normalizedFind) { + words := splitWhitespace(strings.TrimSpace(find)) + if len(words) > 0 { + if match, ok := findWordsMatch(line, words); ok { + out = append(out, match) + } + } + } + } + findLines := strings.Split(find, "\n") + if len(findLines) > 1 { + for i := 0; i <= len(lines)-len(findLines); i++ { + block := strings.Join(lines[i:i+len(findLines)], "\n") + if normalizeWhitespace(block) == normalizedFind { + out = append(out, block) + } + } + } + return out +} + +// IndentationFlexibleReplacer matches after removing the common indentation +// from both the search text and the candidate block. +func IndentationFlexibleReplacer(content string, find string) []string { + normalizedFind := removeIndentation(find) + contentLines := strings.Split(content, "\n") + findLines := strings.Split(find, "\n") + out := []string{} + for i := 0; i <= len(contentLines)-len(findLines); i++ { + block := strings.Join(contentLines[i:i+len(findLines)], "\n") + if removeIndentation(block) == normalizedFind { + out = append(out, block) + } + } + return out +} + +// EscapeNormalizedReplacer matches after unescaping backslash sequences in the +// search text, for a model that sent an escaped string. +func EscapeNormalizedReplacer(content string, find string) []string { + unescapedFind := unescapeEditString(find) + out := []string{} + if strings.Contains(content, unescapedFind) { + out = append(out, unescapedFind) + } + lines := strings.Split(content, "\n") + findLines := strings.Split(unescapedFind, "\n") + for i := 0; i <= len(lines)-len(findLines); i++ { + block := strings.Join(lines[i:i+len(findLines)], "\n") + if unescapeEditString(block) == unescapedFind { + out = append(out, block) + } + } + return out +} + +// MultiOccurrenceReplacer returns one candidate per exact occurrence, which +// is what lets replaceAll act on every one. It assumes find != "": the edit +// tool rejects an empty search before the ladder runs. +func MultiOccurrenceReplacer(content string, find string) []string { + out := []string{} + start := 0 + for { + index := strings.Index(content[start:], find) + if index < 0 { + break + } + out = append(out, find) + start += index + len(find) + } + return out +} + +// TrimmedBoundaryReplacer matches the search text with its surrounding +// whitespace trimmed away. +func TrimmedBoundaryReplacer(content string, find string) []string { + trimmedFind := strings.TrimSpace(find) + if trimmedFind == find { + return []string{} + } + out := []string{} + if strings.Contains(content, trimmedFind) { + out = append(out, trimmedFind) + } + lines := strings.Split(content, "\n") + findLines := strings.Split(find, "\n") + for i := 0; i <= len(lines)-len(findLines); i++ { + block := strings.Join(lines[i:i+len(findLines)], "\n") + if strings.TrimSpace(block) == trimmedFind { + out = append(out, block) + } + } + return out +} + +// ContextAwareReplacer matches a block of the same length by its first and +// last lines when at least half of the inner lines agree. +func ContextAwareReplacer(content string, find string) []string { + findLines := strings.Split(find, "\n") + if len(findLines) < 3 { + return []string{} + } + if findLines[len(findLines)-1] == "" { + findLines = findLines[:len(findLines)-1] + } + contentLines := strings.Split(content, "\n") + firstLine := strings.TrimSpace(findLines[0]) + lastLine := strings.TrimSpace(findLines[len(findLines)-1]) + out := []string{} + for i := 0; i < len(contentLines); i++ { + if strings.TrimSpace(contentLines[i]) != firstLine { + continue + } + for j := i + 2; j < len(contentLines); j++ { + if strings.TrimSpace(contentLines[j]) != lastLine { + continue + } + blockLines := contentLines[i : j+1] + if len(blockLines) == len(findLines) { + matchingLines := 0 + totalNonEmptyLines := 0 + for k := 1; k < len(blockLines)-1; k++ { + blockLine := strings.TrimSpace(blockLines[k]) + findLine := strings.TrimSpace(findLines[k]) + if len(blockLine) > 0 || len(findLine) > 0 { + totalNonEmptyLines++ + if blockLine == findLine { + matchingLines++ + } + } + } + if totalNonEmptyLines == 0 || float64(matchingLines)/float64(totalNonEmptyLines) >= 0.5 { + out = append(out, strings.Join(blockLines, "\n")) + break + } + } + break + } + } + return out +} + +// TrimDiff removes the common leading indentation from a unified diff's +// content lines so the model-visible diff is not dominated by nesting. +func TrimDiff(diff string) string { + lines := strings.Split(diff, "\n") + contentLines := []string{} + for _, line := range lines { + if len(line) == 0 { + continue + } + if (line[0] == '+' || line[0] == '-' || line[0] == ' ') && + !strings.HasPrefix(line, "---") && + !strings.HasPrefix(line, "+++") { + contentLines = append(contentLines, line) + } + } + if len(contentLines) == 0 { + return diff + } + minIndent := int(^uint(0) >> 1) + for _, line := range contentLines { + content := line[1:] + if strings.TrimSpace(content) != "" { + indent := leadingWhitespaceUnits(content) + if indent < minIndent { + minIndent = indent + } + } + } + if minIndent == int(^uint(0)>>1) || minIndent == 0 { + return diff + } + for i, line := range lines { + if len(line) == 0 { + continue + } + if (line[0] == '+' || line[0] == '-' || line[0] == ' ') && + !strings.HasPrefix(line, "---") && + !strings.HasPrefix(line, "+++") { + lines[i] = line[:1] + sliceUTF16Units(line[1:], minIndent) + } + } + return strings.Join(lines, "\n") +} + +// Replace runs the replacer ladder in order and substitutes the first unique +// match, or every match of the first successful replacer when replaceAll is +// set. +func Replace(content string, oldString string, newString string, replaceAll bool) (string, error) { + if oldString == newString { + return "", errors.New("No changes to apply: oldString and newString are identical.") + } + notFound := true + replacers := []replacer{ + SimpleReplacer, + LineTrimmedReplacer, + BlockAnchorReplacer, + WhitespaceNormalizedReplacer, + IndentationFlexibleReplacer, + EscapeNormalizedReplacer, + TrimmedBoundaryReplacer, + ContextAwareReplacer, + MultiOccurrenceReplacer, + } + for _, candidateReplacer := range replacers { + for _, search := range candidateReplacer(content, oldString) { + index := strings.Index(content, search) + if index < 0 { + continue + } + notFound = false + if replaceAll { + return replaceAllExpanding(content, search, newString), nil + } + lastIndex := strings.LastIndex(content, search) + if index != lastIndex { + continue + } + return content[:index] + newString + content[index+len(search):], nil + } + } + if notFound { + return "", errors.New( + "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.", + ) + } + return "", errors.New( + "Found multiple matches for oldString. Provide more surrounding context to make the match unique.", + ) +} + +func utf16Length(value string) int { + return len(utf16.Encode([]rune(value))) +} + +// isWhitespaceRune is the whitespace set the lenient matchers normalize: the +// ASCII controls, space, no-break space, the Unicode space separators, the +// line and paragraph separators and the BOM. +func isWhitespaceRune(r rune) bool { + switch { + case r >= 0x0009 && r <= 0x000d: + return true + case r == 0x0020, r == 0x00a0, r == 0x1680, r == 0x2028, r == 0x2029, + r == 0x202f, r == 0x205f, r == 0x3000, r == 0xfeff: + return true + case r >= 0x2000 && r <= 0x200a: + return true + default: + return false + } +} + +func normalizeWhitespace(value string) string { + var out strings.Builder + inWhitespace := false + for _, r := range value { + if isWhitespaceRune(r) { + if !inWhitespace { + out.WriteByte(' ') + inWhitespace = true + } + continue + } + out.WriteRune(r) + inWhitespace = false + } + return strings.TrimSpace(out.String()) +} + +func splitWhitespace(value string) []string { + if value == "" { + return []string{""} + } + out := []string{} + start := 0 + for i, r := range value { + if !isWhitespaceRune(r) { + continue + } + if start < i { + out = append(out, value[start:i]) + } + start = i + len(string(r)) + } + out = append(out, value[start:]) + return out +} + +func findWordsMatch(line string, words []string) (string, bool) { + if len(words) == 1 && words[0] == "" { + return "", true + } + for start := 0; start <= len(line); { + if !strings.HasPrefix(line[start:], words[0]) { + if start == len(line) { + break + } + _, size := nextRune(line[start:]) + start += size + continue + } + pos := start + len(words[0]) + ok := true + for _, word := range words[1:] { + before := pos + for pos < len(line) { + r, size := nextRune(line[pos:]) + if !isWhitespaceRune(r) { + break + } + pos += size + } + if pos == before || !strings.HasPrefix(line[pos:], word) { + ok = false + break + } + pos += len(word) + } + if ok { + return line[start:pos], true + } + if start == len(line) { + break + } + _, size := nextRune(line[start:]) + start += size + } + return "", false +} + +func nextRune(value string) (rune, int) { + for _, r := range value { + return r, len(string(r)) + } + return 0, 0 +} + +func removeIndentation(value string) string { + lines := strings.Split(value, "\n") + minIndent := int(^uint(0) >> 1) + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + indent := leadingWhitespaceUnits(line) + if indent < minIndent { + minIndent = indent + } + } + if minIndent == int(^uint(0)>>1) { + return value + } + for i, line := range lines { + if strings.TrimSpace(line) != "" { + lines[i] = sliceUTF16Units(line, minIndent) + } + } + return strings.Join(lines, "\n") +} + +// leadingWhitespaceUnits counts a line's indentation in UTF-16 code units, +// the unit sliceUTF16Units removes it in. +func leadingWhitespaceUnits(value string) int { + count := 0 + for _, r := range value { + if !isWhitespaceRune(r) { + break + } + count += utf16Length(string(r)) + } + return count +} + +func sliceUTF16Units(value string, start int) string { + units := utf16.Encode([]rune(value)) + if start < 0 { + start = 0 + } + if start > len(units) { + start = len(units) + } + return string(utf16.Decode(units[start:])) +} + +func unescapeEditString(value string) string { + var out strings.Builder + for i := 0; i < len(value); { + if value[i] != '\\' || i+1 >= len(value) { + r, size := nextRune(value[i:]) + out.WriteRune(r) + i += size + continue + } + next := value[i+1] + switch next { + case 'n': + out.WriteByte('\n') + case 't': + out.WriteByte('\t') + case 'r': + out.WriteByte('\r') + case '\'', '"', '`', '\\', '$': + out.WriteByte(next) + case '\n': + out.WriteByte('\n') + default: + out.WriteByte('\\') + out.WriteByte(next) + i += 2 + continue + } + i += 2 + } + return out.String() +} + +// replaceAllExpanding replaces every occurrence of search, expanding the $&, +// $`, $' and $$ patterns in the replacement (the match, the text before it, the +// text after it, and a literal dollar). +func replaceAllExpanding(content string, search string, replacement string) string { + if search == "" { + return content + } + var out strings.Builder + start := 0 + for { + index := strings.Index(content[start:], search) + if index < 0 { + out.WriteString(content[start:]) + break + } + index += start + out.WriteString(content[start:index]) + out.WriteString(expandReplacement(replacement, search, content[:index], content[index+len(search):])) + start = index + len(search) + } + return out.String() +} + +func expandReplacement(replacement string, match string, before string, after string) string { + var out strings.Builder + for i := 0; i < len(replacement); i++ { + if replacement[i] != '$' || i+1 >= len(replacement) { + out.WriteByte(replacement[i]) + continue + } + switch replacement[i+1] { + case '$': + out.WriteByte('$') + i++ + case '&': + out.WriteString(match) + i++ + case '`': + out.WriteString(before) + i++ + case '\'': + out.WriteString(after) + i++ + default: + out.WriteByte('$') + } + } + return out.String() +} + +func lineChangeCounts(oldContent string, newContent string) (int, int) { + oldLines := strings.Split(oldContent, "\n") + newLines := strings.Split(newContent, "\n") + table := make([][]int, len(oldLines)+1) + for i := range table { + table[i] = make([]int, len(newLines)+1) + } + for i := len(oldLines) - 1; i >= 0; i-- { + for j := len(newLines) - 1; j >= 0; j-- { + if oldLines[i] == newLines[j] { + table[i][j] = table[i+1][j+1] + 1 + } else if table[i+1][j] >= table[i][j+1] { + table[i][j] = table[i+1][j] + } else { + table[i][j] = table[i][j+1] + } + } + } + common := table[0][0] + return len(newLines) - common, len(oldLines) - common +} diff --git a/internal/seniordev/tool/edit_test.go b/internal/seniordev/tool/edit_test.go new file mode 100644 index 000000000..7bc66129f --- /dev/null +++ b/internal/seniordev/tool/edit_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEditLineEndingAndBOM(t *testing.T) { + workDir := t.TempDir() + path := filepath.Join(workDir, "file.txt") + if err := os.WriteFile(path, []byte("\ufeffone\r\ntwo\r\n"), 0o644); err != nil { + t.Fatal(err) + } + result, err := execute(t, New(workDir), "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "one\ntwo", + "newString": "first\nsecond", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Title != "file.txt" || result.Output != "Edit applied successfully." { + t.Fatalf("result = %#v", result) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufefffirst\r\nsecond\r\n" { + t.Fatalf("content = %q", data) + } +} + +func TestEditEmptyOldStringCreatesOrOverwrites(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "nested/new.txt", + "oldString": "", + "newString": "\ufeffcreated", + }) + if err != nil { + t.Fatalf("Execute create: %v", err) + } + if result.Output != "Edit applied successfully." { + t.Fatalf("Output = %q", result.Output) + } + data, err := os.ReadFile(filepath.Join(workDir, "nested", "new.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufeffcreated" { + t.Fatalf("content = %q", data) + } +} + +func TestEditShellErrors(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": "", + "oldString": "a", + "newString": "b", + }) + if err == nil || err.Error() != "filePath is required" { + t.Fatalf("empty path error = %v", err) + } + _, err = execute(t, registry, "edit", map[string]any{ + "filePath": "missing.txt", + "oldString": "a", + "newString": "b", + }) + if err == nil || err.Error() != "File "+filepath.Join(workDir, "missing.txt")+" not found" { + t.Fatalf("missing error = %v", err) + } + if err := os.Mkdir(filepath.Join(workDir, "dir"), 0o755); err != nil { + t.Fatal(err) + } + _, err = execute(t, registry, "edit", map[string]any{ + "filePath": "dir", + "oldString": "a", + "newString": "b", + }) + if err == nil || err.Error() != "Path is a directory, not a file: "+filepath.Join(workDir, "dir") { + t.Fatalf("directory error = %v", err) + } +} diff --git a/internal/seniordev/tool/glob.go b/internal/seniordev/tool/glob.go new file mode 100644 index 000000000..98d3ae87c --- /dev/null +++ b/internal/seniordev/tool/glob.go @@ -0,0 +1,138 @@ +//go:build !windows + +// The glob tool: file-name matching through `rg --files`, newest first, capped +// at globResultLimit entries. +package tool + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +const globResultLimit = 100 + +type globFile struct { + path string + mtime int64 +} + +type globMetadata struct { + Count int `json:"count"` + Truncated bool `json:"truncated"` +} + +func (r *Registry) executeGlob(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input globInput + if err := decodeInput(call.Input, &input, "pattern"); err != nil { + return steploop.ToolResult{}, err + } + if err := r.ask(ctx, call, "glob", []string{input.Pattern}, map[string]any{ + "pattern": input.Pattern, "path": input.Path, + }); err != nil { + return steploop.ToolResult{}, err + } + search := r.workDir + if input.Path != nil { + search = *input.Path + if !filepath.IsAbs(search) { + search = filepath.Join(r.workDir, search) + } + search = filepath.Clean(search) + } + resolved, err := r.resolvePath(search) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, resolved, "directory"); err != nil { + return steploop.ToolResult{}, err + } + if info, statErr := os.Stat(resolved); statErr == nil && !info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf("glob path must be a directory: %s", resolved) + } + + args := []string{ + "--no-config", + "--files", + "--glob=!.git/*", + "--hidden", + "--glob=" + input.Pattern, + ".", + } + result, err := r.rg.Run(ctx, resolved, args) + if err != nil { + return steploop.ToolResult{}, err + } + if result.code != 0 && result.code != 1 { + return steploop.ToolResult{}, ripgrepError(result) + } + + files := make([]globFile, 0, globResultLimit+1) + scanner := bufio.NewScanner(strings.NewReader(string(result.stdout))) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + if scanner.Text() == "" { + continue + } + file := cleanRipgrepPath(scanner.Text()) + full := filepath.Clean(filepath.Join(resolved, file)) + mtime := int64(0) + if info, statErr := os.Stat(full); statErr == nil { + mtime = info.ModTime().UnixMilli() + } + files = append(files, globFile{path: full, mtime: mtime}) + if len(files) == globResultLimit+1 { + break + } + } + if err := scanner.Err(); err != nil { + return steploop.ToolResult{}, err + } + truncated := len(files) > globResultLimit + if truncated { + files = files[:globResultLimit] + } + sort.SliceStable(files, func(i, j int) bool { + return files[i].mtime > files[j].mtime + }) + + output := []string{} + if len(files) == 0 { + output = append(output, "No files found") + } else { + for _, file := range files { + output = append(output, file.path) + } + if truncated { + output = append(output, "") + output = append(output, + "(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)", + ) + } + } + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + return steploop.ToolResult{ + Title: title, + Output: strings.Join(output, "\n"), + Metadata: rawMetadata(globMetadata{ + Count: len(files), + Truncated: truncated, + }), + }, nil +} + +func cleanRipgrepPath(path string) string { + if strings.HasPrefix(path, "./") || strings.HasPrefix(path, `.\`) { + path = path[2:] + } + return filepath.Clean(path) +} diff --git a/internal/seniordev/tool/glob_description.go b/internal/seniordev/tool/glob_description.go new file mode 100644 index 000000000..543c992fb --- /dev/null +++ b/internal/seniordev/tool/glob_description.go @@ -0,0 +1,10 @@ +//go:build !windows + +package tool + +const globDescription = `- Fast file pattern matching tool that works with any codebase size +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths sorted by modification time +- Use this tool when you need to find files by name patterns +- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. +` diff --git a/internal/seniordev/tool/glob_test.go b/internal/seniordev/tool/glob_test.go new file mode 100644 index 000000000..87c20bdeb --- /dev/null +++ b/internal/seniordev/tool/glob_test.go @@ -0,0 +1,125 @@ +//go:build !windows + +package tool + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type recordingRipgrep struct { + cwd string + args []string + result ripgrepResult + err error +} + +func (r *recordingRipgrep) Run(_ context.Context, cwd string, args []string) (ripgrepResult, error) { + r.cwd = cwd + r.args = append([]string(nil), args...) + return r.result, r.err +} + +func TestGlobInvocationSortAndOutput(t *testing.T) { + workDir := t.TempDir() + for _, name := range []string{"older.go", "newer.go"} { + writeTestFile(t, workDir, name, name) + } + old := time.Unix(100, 0) + newer := time.Unix(200, 0) + if err := os.Chtimes(filepath.Join(workDir, "older.go"), old, old); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(workDir, "newer.go"), newer, newer); err != nil { + t.Fatal(err) + } + runner := &recordingRipgrep{result: ripgrepResult{ + stdout: []byte("./older.go\n./newer.go\n"), + code: 0, + }} + registry := New(workDir) + registry.rg = runner + + result, err := execute(t, registry, "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + wantArgs := []string{ + "--no-config", + "--files", + "--glob=!.git/*", + "--hidden", + "--glob=*.go", + ".", + } + if runner.cwd != workDir || !reflect.DeepEqual(runner.args, wantArgs) { + t.Fatalf("invocation cwd=%q args=%q", runner.cwd, runner.args) + } + want := filepath.Join(workDir, "newer.go") + "\n" + filepath.Join(workDir, "older.go") + if result.Output != want || result.Title != "." { + t.Fatalf("result = %#v, want output %q", result, want) + } + if string(result.Metadata) != `{"count":2,"truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestGlobTruncatesBeforeMtimeSort(t *testing.T) { + workDir := t.TempDir() + var stdout strings.Builder + for i := 0; i < 101; i++ { + name := "file-" + itoa(i) + ".txt" + writeTestFile(t, workDir, name, "") + stdout.WriteString(name) + stdout.WriteByte('\n') + } + // The 101st item is newest but must be discarded before sorting. + newest := filepath.Join(workDir, "file-100.txt") + when := time.Unix(500, 0) + if err := os.Chtimes(newest, when, when); err != nil { + t.Fatal(err) + } + runner := &recordingRipgrep{result: ripgrepResult{stdout: []byte(stdout.String()), code: 0}} + registry := New(workDir) + registry.rg = runner + result, err := execute(t, registry, "glob", map[string]any{"pattern": "*.txt"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(result.Output, newest) { + t.Fatalf("101st item survived pre-sort truncation") + } + if !strings.HasSuffix(result.Output, "(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)") { + t.Fatalf("Output suffix = %q", result.Output[len(result.Output)-140:]) + } + if string(result.Metadata) != `{"count":100,"truncated":true}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestGlobRealRipgrep(t *testing.T) { + // This exercises the real rg invocation and skips when rg is not installed; + // the built-in searcher is covered by ripgrep_fallback_test.go. + if _, err := exec.LookPath("rg"); err != nil { + t.Skip("rg not on PATH") + } + workDir := t.TempDir() + writeTestFile(t, workDir, "visible.go", "package visible") + writeTestFile(t, workDir, ".hidden.go", "package hidden") + writeTestFile(t, workDir, "ignored.txt", "text") + result, err := execute(t, New(workDir), "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for _, name := range []string{"visible.go", ".hidden.go"} { + if !strings.Contains(result.Output, filepath.Join(workDir, name)) { + t.Fatalf("%s missing from %q", name, result.Output) + } + } +} diff --git a/internal/seniordev/tool/grep.go b/internal/seniordev/tool/grep.go new file mode 100644 index 000000000..7d7dbb412 --- /dev/null +++ b/internal/seniordev/tool/grep.go @@ -0,0 +1,219 @@ +//go:build !windows + +// The grep tool: content search through `rg --json`, grouped by file and +// ordered newest first. +package tool + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +const maxGrepLineLength = 2000 + +type ripgrepJSONLine struct { + Type string `json:"type"` + Data struct { + Path struct { + Text string `json:"text"` + } `json:"path"` + Lines struct { + Text string `json:"text"` + } `json:"lines"` + LineNumber int `json:"line_number"` + } `json:"data"` +} + +type grepInput struct { + Pattern string `json:"pattern"` + Path *string `json:"path,omitempty"` + Include *string `json:"include,omitempty"` +} + +type grepMatch struct { + path string + line int + text string + mtime int64 +} + +type grepMetadata struct { + Matches int `json:"matches"` + Truncated bool `json:"truncated"` +} + +func (r *Registry) executeGrep(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input grepInput + if err := decodeInput(call.Input, &input, "pattern"); err != nil { + return steploop.ToolResult{}, err + } + if input.Pattern == "" { + return steploop.ToolResult{}, errors.New("pattern is required") + } + if err := r.ask(ctx, call, "grep", []string{input.Pattern}, map[string]any{ + "pattern": input.Pattern, "path": input.Path, "include": input.Include, + }); err != nil { + return steploop.ToolResult{}, err + } + empty := func() steploop.ToolResult { + return steploop.ToolResult{ + Title: input.Pattern, + Output: "No files found", + Metadata: rawMetadata(grepMetadata{ + Matches: 0, + Truncated: false, + }), + } + } + + search := r.workDir + if input.Path != nil { + search = *input.Path + if !filepath.IsAbs(search) { + search = filepath.Join(r.workDir, search) + } + } + search = filepath.Clean(search) + resolved, err := r.resolvePath(search) + if err != nil { + return steploop.ToolResult{}, err + } + info, statErr := os.Stat(resolved) + isDirectory := statErr == nil && info.IsDir() + kind := "file" + if isDirectory { + kind = "directory" + } + if err := r.askExternalDirectory(ctx, call, resolved, kind); err != nil { + return steploop.ToolResult{}, err + } + cwd := filepath.Dir(resolved) + files := []string{filepath.Base(resolved)} + if isDirectory { + cwd = resolved + files = []string{"."} + } + + args := []string{"--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"} + if input.Include != nil { + args = append(args, "--glob="+*input.Include) + } + args = append(args, "--", input.Pattern) + args = append(args, files...) + result, err := r.rg.Run(ctx, cwd, args) + if err != nil { + return steploop.ToolResult{}, err + } + if result.code != 0 && result.code != 1 && result.code != 2 { + return steploop.ToolResult{}, ripgrepError(result) + } + if result.code == 1 { + return empty(), nil + } + + rows := []grepMatch{} + scanner := bufio.NewScanner(strings.NewReader(string(result.stdout))) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for scanner.Scan() { + if scanner.Text() == "" { + continue + } + var event ripgrepJSONLine + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + return steploop.ToolResult{}, errors.New("invalid ripgrep output") + } + if event.Type != "match" { + continue + } + matchPath := cleanRipgrepPath(event.Data.Path.Text) + if !filepath.IsAbs(matchPath) { + matchPath = filepath.Join(cwd, matchPath) + } + matchPath = filepath.Clean(matchPath) + info, statErr := os.Stat(matchPath) + if statErr != nil || info.IsDir() { + continue + } + rows = append(rows, grepMatch{ + path: matchPath, + line: event.Data.LineNumber, + text: event.Data.Lines.Text, + mtime: info.ModTime().UnixMilli(), + }) + } + if err := scanner.Err(); err != nil { + return steploop.ToolResult{}, err + } + if len(rows) == 0 { + return empty(), nil + } + sort.SliceStable(rows, func(i, j int) bool { + return rows[i].mtime > rows[j].mtime + }) + + const limit = 100 + total := len(rows) + truncated := total > limit + final := rows + if truncated { + final = rows[:limit] + } + output := []string{"Found " + itoa(total) + " matches"} + if truncated { + output[0] += " (showing first 100)" + } + current := "" + for _, match := range final { + if current != match.path { + if current != "" { + output = append(output, "") + } + current = match.path + output = append(output, match.path+":") + } + text := match.text + if len(utf16.Encode([]rune(text))) > maxGrepLineLength { + text = truncateUTF16Units(text, maxGrepLineLength) + "..." + } + output = append(output, " Line "+itoa(match.line)+": "+text) + } + if truncated { + output = append(output, "") + output = append( + output, + "(Results truncated: showing 100 of "+itoa(total)+" matches ("+itoa(total-limit)+ + " hidden). Consider using a more specific path or pattern.)", + ) + } + if result.code == 2 { + output = append(output, "") + output = append(output, "(Some paths were inaccessible and skipped)") + } + return steploop.ToolResult{ + Title: input.Pattern, + Output: strings.Join(output, "\n"), + Metadata: rawMetadata(grepMetadata{ + Matches: total, + Truncated: truncated, + }), + }, nil +} + +// truncateUTF16Units cuts a line at limit UTF-16 code units, the unit +// maxGrepLineLength is expressed in. +func truncateUTF16Units(value string, limit int) string { + units := utf16.Encode([]rune(value)) + if len(units) <= limit { + return value + } + return string(utf16.Decode(units[:limit])) +} diff --git a/internal/seniordev/tool/grep_description.go b/internal/seniordev/tool/grep_description.go new file mode 100644 index 000000000..9c7191ea0 --- /dev/null +++ b/internal/seniordev/tool/grep_description.go @@ -0,0 +1,11 @@ +//go:build !windows + +package tool + +const grepDescription = "- Fast content search tool that works with any codebase size\n" + + "- Searches file contents using regular expressions\n" + + "- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n" + + "- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n" + + "- Returns file paths and line numbers with at least one match sorted by modification time\n" + + "- Use this tool when you need to find files containing specific patterns\n" + + "- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\n" diff --git a/internal/seniordev/tool/grep_test.go b/internal/seniordev/tool/grep_test.go new file mode 100644 index 000000000..43a8787fb --- /dev/null +++ b/internal/seniordev/tool/grep_test.go @@ -0,0 +1,130 @@ +//go:build !windows + +package tool + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func grepEvent(t *testing.T, path string, line int, text string) string { + t.Helper() + value := map[string]any{ + "type": "match", + "data": map[string]any{ + "path": map[string]any{"text": path}, + "lines": map[string]any{"text": text}, + "line_number": line, + }, + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestGrepInvocationGroupingAndPartial(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "old.go", "needle") + writeTestFile(t, workDir, "new.go", "needle") + old := time.Unix(100, 0) + newer := time.Unix(200, 0) + if err := os.Chtimes(filepath.Join(workDir, "old.go"), old, old); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(workDir, "new.go"), newer, newer); err != nil { + t.Fatal(err) + } + stdout := strings.Join([]string{ + grepEvent(t, "./old.go", 1, "old needle\n"), + grepEvent(t, "./new.go", 2, "new needle\n"), + }, "\n") + "\n" + runner := &recordingRipgrep{result: ripgrepResult{stdout: []byte(stdout), code: 2}} + registry := New(workDir) + registry.rg = runner + + result, err := execute(t, registry, "grep", map[string]any{ + "pattern": "needle", + "include": "*.go", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + wantArgs := []string{ + "--no-config", + "--json", + "--hidden", + "--glob=!.git/*", + "--no-messages", + "--glob=*.go", + "--", + "needle", + ".", + } + if runner.cwd != workDir || !reflect.DeepEqual(runner.args, wantArgs) { + t.Fatalf("invocation cwd=%q args=%q", runner.cwd, runner.args) + } + want := "Found 2 matches\n" + + filepath.Join(workDir, "new.go") + ":\n" + + " Line 2: new needle\n\n" + + "\n" + + filepath.Join(workDir, "old.go") + ":\n" + + " Line 1: old needle\n\n" + + "\n(Some paths were inaccessible and skipped)" + if result.Output != want { + t.Fatalf("Output:\n%q\nwant:\n%q", result.Output, want) + } + if string(result.Metadata) != `{"matches":2,"truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestGrepFilePathInvocationAndLongLine(t *testing.T) { + workDir := t.TempDir() + long := strings.Repeat("界", maxGrepLineLength+1) + "\n" + writeTestFile(t, workDir, "one.txt", long) + runner := &recordingRipgrep{result: ripgrepResult{ + stdout: []byte(grepEvent(t, "one.txt", 1, long) + "\n"), + code: 0, + }} + registry := New(workDir) + registry.rg = runner + result, err := execute(t, registry, "grep", map[string]any{ + "pattern": "界+", + "path": "one.txt", + }) + if err != nil { + t.Fatal(err) + } + if runner.cwd != workDir || !reflect.DeepEqual(runner.args[len(runner.args)-3:], []string{"--", "界+", "one.txt"}) { + t.Fatalf("invocation cwd=%q args=%q", runner.cwd, runner.args) + } + wantText := strings.Repeat("界", maxGrepLineLength) + "..." + if !strings.Contains(result.Output, " Line 1: "+wantText) { + t.Fatalf("long output missing: %q", result.Output[len(result.Output)-100:]) + } +} + +func TestGrepEmptyAndNoMatches(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + _, err := execute(t, registry, "grep", map[string]any{"pattern": ""}) + if err == nil || err.Error() != "pattern is required" { + t.Fatalf("empty pattern error = %v", err) + } + runner := &recordingRipgrep{result: ripgrepResult{code: 1}} + registry.rg = runner + result, err := execute(t, registry, "grep", map[string]any{"pattern": "missing"}) + if err != nil { + t.Fatal(err) + } + if result.Output != "No files found" || string(result.Metadata) != `{"matches":0,"truncated":false}` { + t.Fatalf("result = %#v", result) + } +} diff --git a/internal/seniordev/tool/instance_context_test.go b/internal/seniordev/tool/instance_context_test.go new file mode 100644 index 000000000..f3966432e --- /dev/null +++ b/internal/seniordev/tool/instance_context_test.go @@ -0,0 +1,124 @@ +//go:build !windows + +// Per-leaf cwd behaviour: a project instance in context redirects tool paths +// and the shell's working directory into its own directory. +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/project" +) + +func TestConcurrentLeafContextsResolveTheirOwnToolCWD(t *testing.T) { + root := t.TempDir() + leafA := filepath.Join(root, ".worktrees", "wt-a") + leafB := filepath.Join(root, ".worktrees", "wt-b") + for _, directory := range []string{leafA, leafB} { + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + } + registry := New(root) + type leaf struct { + id string + directory string + } + leaves := []leaf{{id: "a", directory: leafA}, {id: "b", directory: leafB}} + var wait sync.WaitGroup + errors := make(chan error, len(leaves)) + for _, item := range leaves { + item := item + wait.Add(1) + go func() { + defer wait.Done() + ctx := project.WithContext(context.Background(), project.InstanceContext{ + Directory: item.directory, + Worktree: root, + Project: project.Info{ID: "p", Worktree: root, Sandboxes: []string{item.directory}}, + }) + raw, _ := json.Marshal(map[string]any{ + // An absolute root path must be transparently redirected into + // this leaf, not written into the shared checkout. + "filePath": filepath.Join(root, "owned.txt"), + "content": item.id, + }) + if _, err := registry.Execute(ctx, steploop.ToolCall{Name: "write", Input: raw}); err != nil { + errors <- err + return + } + bashRaw, _ := json.Marshal(map[string]any{"command": "pwd"}) + result, err := registry.Execute(ctx, steploop.ToolCall{Name: "bash", Input: bashRaw}) + if err != nil { + errors <- err + return + } + if strings.TrimSpace(result.Output) != item.directory { + errors <- &cwdError{got: strings.TrimSpace(result.Output), want: item.directory} + } + }() + } + wait.Wait() + close(errors) + for err := range errors { + t.Error(err) + } + if _, err := os.Stat(filepath.Join(root, "owned.txt")); !os.IsNotExist(err) { + t.Fatalf("shared root was modified: %v", err) + } + for _, item := range leaves { + data, err := os.ReadFile(filepath.Join(item.directory, "owned.txt")) + if err != nil { + t.Fatalf("read %s leaf: %v", item.id, err) + } + if string(data) != item.id { + t.Fatalf("%s leaf content = %q", item.id, data) + } + } +} + +func TestRelativeTraversalIntoMainWorktreeRedirectsToLeafContract(t *testing.T) { + // A sandboxed instance resolves relative paths before redirecting paths + // that land in the main checkout into its own directory. + mainWorktree := t.TempDir() + leafWorktree := filepath.Join(mainWorktree, ".worktrees", "wt-task") + if err := os.MkdirAll(leafWorktree, 0o755); err != nil { + t.Fatal(err) + } + registry := New(mainWorktree) + ctx := project.WithContext(context.Background(), project.InstanceContext{ + Directory: leafWorktree, + Worktree: mainWorktree, + Project: project.Info{ID: "p", Worktree: mainWorktree, Sandboxes: []string{leafWorktree}}, + }) + target := filepath.Join(mainWorktree, "relative-owned.txt") + relative, err := filepath.Rel(leafWorktree, target) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]any{"filePath": relative, "content": "leaf"}) + if _, err := registry.Execute(ctx, steploop.ToolCall{Name: "write", Input: raw}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("relative traversal modified main checkout: %v", err) + } + data, err := os.ReadFile(filepath.Join(leafWorktree, "relative-owned.txt")) + if err != nil || string(data) != "leaf" { + t.Fatalf("redirected leaf file = %q, %v", data, err) + } +} + +type cwdError struct { + got string + want string +} + +func (e *cwdError) Error() string { return "tool cwd = " + e.got + ", want " + e.want } diff --git a/internal/seniordev/tool/mutation_feedback_test.go b/internal/seniordev/tool/mutation_feedback_test.go new file mode 100644 index 000000000..0948b9abb --- /dev/null +++ b/internal/seniordev/tool/mutation_feedback_test.go @@ -0,0 +1,82 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + formatpkg "github.com/Agent-Field/codeaf/internal/seniordev/format" +) + +func TestEditDiffReflectsPostFormatContent(t *testing.T) { + // edit reports a unified diff of the actual post-format change. + workDir := t.TempDir() + path := filepath.Join(workDir, "file.txt") + if err := os.WriteFile(path, []byte("before\n"), 0o644); err != nil { + t.Fatal(err) + } + registry := New(workDir) + extensions := []string{".txt"} + command := []string{"test-formatter", "$FILE"} + registry.formatters.services[workDir+"\x00"+workDir] = formatpkg.NewService( + formatpkg.Context{Directory: workDir, Worktree: workDir}, + formatpkg.Configuration{Enabled: true, Overrides: []formatpkg.FormatterOverride{{ + Key: "test", Extensions: &extensions, Command: &command, + }}}, + formatpkg.Dependencies{}, + func(_ context.Context, command []string, _ string, _ map[string]string) (int, error) { + return 0, os.WriteFile(command[1], []byte("formatted\n"), 0o644) + }, + ) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", "oldString": "before", "newString": "raw", + }) + if err != nil { + t.Fatal(err) + } + var metadata editMetadata + if err := json.Unmarshal(result.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if metadata.Diff == "" || metadata.FileDiff.Patch != metadata.Diff { + t.Fatalf("metadata = %#v", metadata) + } + if !strings.Contains(metadata.Diff, "+formatted") || strings.Contains(metadata.Diff, "+raw") { + t.Fatalf("diff does not reflect formatter output: %q", metadata.Diff) + } + assertTestFile(t, workDir, "file.txt", "formatted\n") +} + +func TestApplyPatchReportsPerFileAndAggregateDiffs(t *testing.T) { + // apply_patch reports unified diffs for every actual change. + workDir := t.TempDir() + writeTestFile(t, workDir, "update.txt", "old\n") + result, err := execute(t, New(workDir), "apply_patch", map[string]any{"patchText": `*** Begin Patch +*** Add File: added.txt ++new +*** Update File: update.txt +@@ +-old ++updated +*** End Patch`}) + if err != nil { + t.Fatal(err) + } + var metadata applyPatchMetadata + if err := json.Unmarshal(result.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if metadata.Diff == "" || len(metadata.Files) != 2 { + t.Fatalf("metadata = %#v", metadata) + } + for _, file := range metadata.Files { + if file.Patch == "" || !strings.Contains(metadata.Diff, file.Patch) { + t.Fatalf("file diff missing from aggregate: %#v", file) + } + } +} diff --git a/internal/seniordev/tool/netpolicy_gate_test.go b/internal/seniordev/tool/netpolicy_gate_test.go new file mode 100644 index 000000000..755807f2c --- /dev/null +++ b/internal/seniordev/tool/netpolicy_gate_test.go @@ -0,0 +1,109 @@ +//go:build !windows + +package tool + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +func TestWebFetchBlockedWhenNetOff(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + dialed := false + client := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + dialed = true + return nil, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + _, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": "https://example.com/doc", + }) + if err == nil || !strings.Contains(err.Error(), "[network-policy]") { + t.Fatalf("want [network-policy] error, got %v", err) + } + if !strings.Contains(err.Error(), "example.com") { + t.Fatalf("policy error should name the blocked host: %v", err) + } + if dialed { + t.Fatal("request reached the transport despite SENIOR_DEV_NET=off") + } +} + +// TestWebClientTransportWrapEnforcesPolicy pins the policy wrap inside +// webClient itself, past the tools' pre-execute checks: any HTTP issued +// through the shared client while the policy is restricted must be refused at +// the transport with the [network-policy] no-retry framing rather than +// dialing (or collapsing into a retryable-looking transport error). If the +// wrap is ever dropped from webClient, the request reaches the base +// transport and this test fails. +func TestWebClientTransportWrapEnforcesPolicy(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + dialed := false + injected := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + dialed = true + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + })} + ctx := WithWebHTTPClient(context.Background(), injected) + + _, err := webClient(ctx).Get("https://example.com/doc") + var blocked *netpolicy.BlockedError + if !errors.As(err, &blocked) { + t.Fatalf("want *netpolicy.BlockedError, got %v", err) + } + if dialed { + t.Fatal("request reached the base transport despite SENIOR_DEV_NET=off") + } + if injected.Transport == nil { + t.Fatal("webClient mutated the injected client instead of copying it") + } + if _, ok := injected.Transport.(webRoundTripFunc); !ok { + t.Fatalf("webClient replaced the injected client's own transport: %T", injected.Transport) + } +} + +func TestWebSearchEndpointBlockedWhenNetOff(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + _, err := callMCPWebSearch( + context.Background(), defaultExaWebSearchURL, "web_search_exa", + map[string]any{"query": "q"}, nil, + ) + if err == nil || !strings.Contains(err.Error(), "[network-policy]") { + t.Fatalf("want [network-policy] error, got %v", err) + } +} + +func TestShellEnvironmentInjectsBlackholeProxyWhenNetOff(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + // The shared-cache early return must not skip the network gate. + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "1") + environment := shellEnvironment("ses-netpolicy") + var proxy, noProxy string + for _, entry := range environment { + if value, ok := strings.CutPrefix(entry, "HTTPS_PROXY="); ok { + proxy = value + } + if value, ok := strings.CutPrefix(entry, "NO_PROXY="); ok { + noProxy = value + } + } + if !strings.HasPrefix(proxy, "http://127.0.0.1:") { + t.Fatalf("HTTPS_PROXY = %q, want local black-hole", proxy) + } + if noProxy != "localhost,127.0.0.1,::1" { + t.Fatalf("NO_PROXY = %q", noProxy) + } +} + +func TestShellEnvironmentUntouchedWhenNetAllow(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "allow") + for _, entry := range shellEnvironment("") { + if strings.HasPrefix(entry, "HTTP_PROXY=http://127.0.0.1:") { + t.Fatalf("allow mode injected proxy entry %q", entry) + } + } +} diff --git a/internal/seniordev/tool/path.go b/internal/seniordev/tool/path.go new file mode 100644 index 000000000..78c33bd11 --- /dev/null +++ b/internal/seniordev/tool/path.go @@ -0,0 +1,71 @@ +//go:build !windows + +// Path confinement: every tool path resolves inside the workspace unless the +// registry allows external directories, in which case it asks first. +package tool + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/project" +) + +func (r *Registry) resolvePath(path string) (string, error) { + candidate := path + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(r.workDir, candidate) + } + absolute, err := filepath.Abs(candidate) + if err != nil { + return "", fmt.Errorf("resolve path %q: %w", path, err) + } + absolute = filepath.Clean(absolute) + if r.instance != nil { + absolute = project.RedirectIntoDirectory(absolute, *r.instance) + } + + relative, err := filepath.Rel(r.workDir, absolute) + if !r.allowExternal && (err != nil || outsidePath(relative)) { + return "", fmt.Errorf("path escapes workspace: %s", path) + } + return absolute, nil +} + +func outsidePath(relative string) bool { + return relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) +} + +func (r *Registry) askExternalDirectory( + ctx context.Context, + call steploop.ToolCall, + target string, + kind string, +) error { + if !r.allowExternal || target == "" { + return nil + } + inside := func(root string) bool { + if root == "" { + return false + } + relative, err := filepath.Rel(root, target) + return err == nil && !outsidePath(relative) + } + worktree := r.worktree() + if inside(r.workDir) || (worktree != string(filepath.Separator) && inside(worktree)) { + return nil + } + directory := filepath.Dir(target) + if kind == "directory" { + directory = target + } + glob := filepath.ToSlash(filepath.Join(directory, "*")) + return r.askWithAlways(ctx, call, "external_directory", []string{glob}, []string{glob}, map[string]any{ + "filepath": target, + "parentDir": directory, + }) +} diff --git a/internal/seniordev/tool/question.go b/internal/seniordev/tool/question.go new file mode 100644 index 000000000..1cbbd6d3a --- /dev/null +++ b/internal/seniordev/tool/question.go @@ -0,0 +1,161 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "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/jsonutil" + "github.com/Agent-Field/codeaf/internal/seniordev/question" +) + +const questionSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "ref": "QuestionPrompt", + "type": "object", + "properties": { + "question": {"description": "Complete question", "type": "string"}, + "header": {"description": "Very short label (max 30 chars)", "type": "string"}, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "ref": "QuestionOption", + "type": "object", + "properties": { + "label": {"description": "Display text (1-5 words, concise)", "type": "string"}, + "description": {"description": "Explanation of choice", "type": "string"} + }, + "required": ["label", "description"] + } + }, + "multiple": {"description": "Allow selecting multiple choices", "type": "boolean"} + }, + "required": ["question", "header", "options"] + } + } + }, + "required": ["questions"] +}` + +type questionInput struct { + Questions []question.Prompt `json:"questions"` +} + +func validateQuestion(raw json.RawMessage) error { + _, err := decodeQuestionInput(raw) + return err +} + +func decodeQuestionInput(raw json.RawMessage) (questionInput, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return questionInput{}, fmt.Errorf("input must be a JSON object: %w", err) + } + if fields == nil { + return questionInput{}, fmt.Errorf("input must be a JSON object") + } + questionsField, ok := fields["questions"] + if !ok { + return questionInput{}, fmt.Errorf("missing required field %q", "questions") + } + questionsRaw := bytes.TrimSpace(questionsField) + if len(questionsRaw) == 0 || questionsRaw[0] != '[' { + return questionInput{}, fmt.Errorf("field %q must be an array", "questions") + } + var prompts []json.RawMessage + if err := json.Unmarshal(questionsRaw, &prompts); err != nil { + return questionInput{}, fmt.Errorf("invalid questions: %w", err) + } + for index, prompt := range prompts { + if !question.SchemaAccepts("prompt", "basic", prompt) { + return questionInput{}, fmt.Errorf("invalid question at index %d", index) + } + } + var input questionInput + if err := json.Unmarshal(raw, &input); err != nil { + return questionInput{}, fmt.Errorf("invalid input: %w", err) + } + return input, nil +} + +func (r *Registry) executeQuestion(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + input, err := decodeQuestionInput(call.Input) + if err != nil { + return steploop.ToolResult{}, err + } + questions := make([]question.Info, len(input.Questions)) + for index, prompt := range input.Questions { + questions[index] = question.Info{ + Question: prompt.Question, + Header: prompt.Header, + Options: append([]question.Option(nil), prompt.Options...), + Multiple: prompt.Multiple, + } + } + var origin *question.Tool + if call.ID != "" { + origin = &question.Tool{MessageID: call.MessageID, CallID: call.ID} + } + + // Ask has no timeout of its own: it publishes the question and blocks until + // it is answered, rejected, or the run context is cancelled. An unattended + // runtime rejects through this service; after three consecutive rejections + // the registry returns the bounded, model-visible result below instead of + // another error. + answers, err := r.question.Ask(ctx, question.AskInput{ + SessionID: call.SessionID, + Questions: questions, + Tool: origin, + }) + if err != nil { + var rejected *question.RejectedError + if errors.As(err, &rejected) && r.recordQuestionRejection(call.SessionID) >= 3 { + return steploop.ToolResult{ + Title: "Questions unavailable", + Output: "Questions are unavailable for this run. Your current answers are final; proceed with your best judgment.", + Metadata: msgmodel.RawObject("{}"), + }, nil + } + return steploop.ToolResult{}, err + } + r.resetQuestionRejections(call.SessionID) + + formatted := make([]string, len(input.Questions)) + for index, prompt := range input.Questions { + answer := "Unanswered" + if index < len(answers) && len(answers[index]) > 0 { + answer = strings.Join(answers[index], ", ") + } + formatted[index] = `"` + prompt.Question + `"="` + answer + `"` + } + title := fmt.Sprintf("Asked %d question", len(input.Questions)) + if len(input.Questions) > 1 { + title += "s" + } + metadata, marshalErr := jsonutil.Marshal(struct { + Answers []question.Answer `json:"answers"` + }{Answers: answers}) + if marshalErr != nil { + return steploop.ToolResult{}, marshalErr + } + return steploop.ToolResult{ + Title: title, + Output: "User has answered your questions: " + strings.Join(formatted, ", ") + + ". You can now continue with the user's answers in mind.", + Metadata: msgmodel.RawObject(metadata), + }, nil +} diff --git a/internal/seniordev/tool/question.txt b/internal/seniordev/tool/question.txt new file mode 100644 index 000000000..03cd496d6 --- /dev/null +++ b/internal/seniordev/tool/question.txt @@ -0,0 +1,10 @@ +Use this tool when you need to ask the user questions during execution. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take. + +Usage notes: +- When `custom` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options +- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one +- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label diff --git a/internal/seniordev/tool/question_description.go b/internal/seniordev/tool/question_description.go new file mode 100644 index 000000000..18d45f2da --- /dev/null +++ b/internal/seniordev/tool/question_description.go @@ -0,0 +1,8 @@ +//go:build !windows + +package tool + +import _ "embed" + +//go:embed question.txt +var questionDescription string diff --git a/internal/seniordev/tool/question_test.go b/internal/seniordev/tool/question_test.go new file mode 100644 index 000000000..562908f2c --- /dev/null +++ b/internal/seniordev/tool/question_test.go @@ -0,0 +1,264 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/question" +) + +type questionPublication struct { + definition bus.Definition + properties any +} + +type questionPublisher struct { + events chan questionPublication +} + +func (p *questionPublisher) Publish(definition bus.Definition, properties any, _ ...bus.PublishOptions) { + p.events <- questionPublication{definition: definition, properties: properties} +} + +func questionDefinition(t *testing.T, registry *Registry) steploop.ToolDefinition { + t.Helper() + for _, item := range registry.Definitions() { + if item.Provider.Name == "question" { + return item + } + } + t.Fatal("question definition not registered") + return steploop.ToolDefinition{} +} + +func newQuestionRegistry(t *testing.T, service *question.Service) *Registry { + t.Helper() + t.Setenv("SENIOR_DEV_ENABLE_QUESTION_TOOL", "0") + return NewWithOptions(t.TempDir(), RegistryOptions{ + ClientIdentity: "cli", + Question: service, + }) +} + +func TestQuestionRegistrationDescriptionSchemaAndFilters(t *testing.T) { + registry := newQuestionRegistry(t, question.NewService(nil, nil)) + definition := questionDefinition(t, registry) + if !definition.WaitForResult { + t.Fatal("question definition does not keep the provider stream open") + } + if definition.Provider.Description != questionDescription { + t.Fatal("registered question description does not use embedded asset") + } + + valid := json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[{"label":"Yes","description":"Continue","ignored":1}],"multiple":true,"custom":false}],"ignored":true}`) + if err := definition.Validate(valid); err != nil { + t.Fatalf("valid parameters rejected: %v", err) + } + for _, invalid := range []json.RawMessage{ + json.RawMessage(`{"questions":[{"header":"Missing question","options":[]}]}`), + json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":null}]}`), + } { + if err := definition.Validate(invalid); err == nil { + t.Fatalf("invalid parameters accepted: %s", invalid) + } + } + + coder := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "anthropic/claude-opus-4-6", + })) + if want := []string{"question", "bash", "read", "glob", "grep", "edit", "write", "webfetch"}; !reflect.DeepEqual(coder, want) { + t.Fatalf("coder definitions = %v, want %v", coder, want) + } +} + +func TestQuestionForceFlagEnablesNonClientRegistry(t *testing.T) { + t.Setenv("SENIOR_DEV_ENABLE_QUESTION_TOOL", "1") + registry := NewWithOptions(t.TempDir(), RegistryOptions{ClientIdentity: "server"}) + _ = questionDefinition(t, registry) +} + +func TestHeadlessQuestionWaitsUntilRunCancellation(t *testing.T) { + publisher := &questionPublisher{events: make(chan questionPublication, 1)} + service := question.NewService(publisher, func() (question.QuestionID, error) { + return "que_headless", nil + }) + registry := newQuestionRegistry(t, service) + ctx, cancel := context.WithCancel(context.Background()) + type execution struct { + result steploop.ToolResult + err error + } + done := make(chan execution, 1) + go func() { + result, err := registry.Execute(ctx, steploop.ToolCall{ + ID: "call_1", Name: "question", SessionID: "ses_1", MessageID: "msg_1", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + }) + done <- execution{result: result, err: err} + }() + + published := <-publisher.events + request, ok := published.properties.(question.Request) + if published.definition.Type != question.Event.Asked.Type || !ok || request.ID != "que_headless" || + request.Tool == nil || request.Tool.MessageID != "msg_1" || request.Tool.CallID != "call_1" { + t.Fatalf("asked publication = %#v", published) + } + select { + case completed := <-done: + t.Fatalf("headless question completed without an answer: %#v", completed) + default: + } + if pending := service.List(); len(pending) != 1 || pending[0].ID != "que_headless" { + t.Fatalf("pending questions = %#v", pending) + } + + cancel() + completed := <-done + if !errors.Is(completed.err, context.Canceled) { + t.Fatalf("question cancellation error = %v", completed.err) + } + if pending := service.List(); len(pending) != 0 { + t.Fatalf("pending after cancellation = %#v", pending) + } +} + +func TestQuestionReplyFormatsToolResult(t *testing.T) { + publisher := &questionPublisher{events: make(chan questionPublication, 1)} + service := question.NewService(publisher, func() (question.QuestionID, error) { + return "que_reply", nil + }) + registry := newQuestionRegistry(t, service) + done := make(chan struct { + result steploop.ToolResult + err error + }, 1) + go func() { + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call_1", Name: "question", SessionID: "ses_1", MessageID: "msg_1", + Input: json.RawMessage(`{"questions":[{"question":"Color?","header":"Color","options":[{"label":"Blue","description":"Use blue"}]},{"question":"Size?","header":"Size","options":[]}]}`), + }) + done <- struct { + result steploop.ToolResult + err error + }{result: result, err: err} + }() + published := <-publisher.events + request := published.properties.(question.Request) + service.Reply(question.ReplyInput{ + RequestID: request.ID, + Answers: []question.Answer{{"Blue"}, {}}, + }) + + completed := <-done + if completed.err != nil { + t.Fatal(completed.err) + } + if completed.result.Title != "Asked 2 questions" { + t.Fatalf("title = %q", completed.result.Title) + } + wantOutput := `User has answered your questions: "Color?"="Blue", "Size?"="Unanswered". You can now continue with the user's answers in mind.` + if completed.result.Output != wantOutput { + t.Fatalf("output = %q, want %q", completed.result.Output, wantOutput) + } + if string(completed.result.Metadata) != `{"answers":[["Blue"],[]]}` { + t.Fatalf("metadata = %s", completed.result.Metadata) + } +} + +func TestQuestionRejectionsBecomeTerminalResultAfterThree(t *testing.T) { + instanceBus := bus.New(bus.Context{}) + service := question.NewService(instanceBus, nil) + instanceBus.SubscribeCallback(question.Event.Asked, func(payload bus.Payload) { + service.Reject(payload.Properties.(question.Request).ID) + }) + registry := newQuestionRegistry(t, service) + call := steploop.ToolCall{ + Name: "question", SessionID: "ses-bounded", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + } + for attempt := 1; attempt <= 3; attempt++ { + result, err := registry.Execute(context.Background(), call) + if attempt < 3 { + var rejected *question.RejectedError + if !errors.As(err, &rejected) { + t.Fatalf("attempt %d = (%#v, %v), want rejection", attempt, result, err) + } + continue + } + if err != nil || result.Title != "Questions unavailable" || + !strings.Contains(result.Output, "answers are final") || + !strings.Contains(result.Output, "best judgment") { + t.Fatalf("terminal attempt = (%#v, %v)", result, err) + } + } +} + +func TestQuestionRejectionCounterResetsAfterSuccessfulTool(t *testing.T) { + instanceBus := bus.New(bus.Context{}) + service := question.NewService(instanceBus, nil) + instanceBus.SubscribeCallback(question.Event.Asked, func(payload bus.Payload) { + service.Reject(payload.Properties.(question.Request).ID) + }) + registry := newQuestionRegistry(t, service) + questionCall := steploop.ToolCall{ + Name: "question", SessionID: "ses-reset", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + } + for range 2 { + if _, err := registry.Execute(context.Background(), questionCall); err == nil { + t.Fatal("question unexpectedly succeeded before reset") + } + } + resetFile := filepath.Join(registry.workDir, "reset-marker.txt") + if err := os.WriteFile(resetFile, []byte("marker\n"), 0o644); err != nil { + t.Fatal(err) + } + readInput, err := json.Marshal(map[string]any{"filePath": resetFile}) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Execute(context.Background(), steploop.ToolCall{ + Name: "read", SessionID: "ses-reset", Input: readInput, + }); err != nil { + t.Fatalf("interleaved read: %v", err) + } + for attempt := 1; attempt <= 2; attempt++ { + if result, err := registry.Execute(context.Background(), questionCall); err == nil { + t.Fatalf("post-reset attempt %d unexpectedly terminal: %#v", attempt, result) + } + } + result, err := registry.Execute(context.Background(), questionCall) + if err != nil || result.Title != "Questions unavailable" { + t.Fatalf("post-reset third attempt = (%#v, %v)", result, err) + } +} + +func TestAnsweredQuestionsNeverHitRejectionBound(t *testing.T) { + instanceBus := bus.New(bus.Context{}) + service := question.NewService(instanceBus, nil) + instanceBus.SubscribeCallback(question.Event.Asked, func(payload bus.Payload) { + request := payload.Properties.(question.Request) + service.Reply(question.ReplyInput{RequestID: request.ID, Answers: []question.Answer{{"Yes"}}}) + }) + registry := newQuestionRegistry(t, service) + call := steploop.ToolCall{ + Name: "question", SessionID: "ses-embedded", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + } + for attempt := 1; attempt <= 5; attempt++ { + result, err := registry.Execute(context.Background(), call) + if err != nil || result.Title != "Asked 1 question" || strings.Contains(result.Output, "unavailable") { + t.Fatalf("answered attempt %d = (%#v, %v)", attempt, result, err) + } + } +} diff --git a/internal/seniordev/tool/read.go b/internal/seniordev/tool/read.go new file mode 100644 index 000000000..b9d1cb873 --- /dev/null +++ b/internal/seniordev/tool/read.go @@ -0,0 +1,493 @@ +//go:build !windows + +// The read tool: files with line-number prefixes, directories as entry lists, +// images and PDFs as attachments, plus nested instruction reminders. +package tool + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf16" + "unicode/utf8" + + "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/jsonutil" +) + +const ( + defaultReadLimit = 2000 + maxLineLength = 2000 + maxReadBytes = 50 * 1024 + sampleBytes = 4096 +) + +const maxLineSuffix = "... (line truncated to 2000 chars)" + +var supportedImageMIMEs = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, + "image/gif": {}, + "image/webp": {}, +} + +var binaryExtensions = map[string]struct{}{ + ".zip": {}, ".tar": {}, ".gz": {}, ".exe": {}, ".dll": {}, ".so": {}, + ".class": {}, ".jar": {}, ".war": {}, ".7z": {}, ".doc": {}, ".docx": {}, + ".xls": {}, ".xlsx": {}, ".ppt": {}, ".pptx": {}, ".odt": {}, ".ods": {}, + ".odp": {}, ".bin": {}, ".dat": {}, ".obj": {}, ".o": {}, ".a": {}, + ".lib": {}, ".wasm": {}, ".pyc": {}, ".pyo": {}, +} + +type readLinesResult struct { + raw []string + count int + cut bool + more bool + offset int +} + +type readMetadata struct { + Preview string `json:"preview"` + Truncated bool `json:"truncated"` + Loaded []string `json:"loaded"` +} + +func (r *Registry) executeRead(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input readInput + if err := decodeInput(call.Input, &input, "filePath"); err != nil { + return steploop.ToolResult{}, err + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + + offset := 1 + if input.Offset != nil && *input.Offset != 0 { + offset = *input.Offset + } + limit := defaultReadLimit + if input.Limit != nil { + limit = *input.Limit + } + + resolved, err := r.resolvePath(input.FilePath) + if err != nil { + return steploop.ToolResult{}, err + } + info, err := os.Stat(resolved) + kind := "file" + if err == nil && info.IsDir() { + kind = "directory" + } + if askErr := r.askExternalDirectory(ctx, call, resolved, kind); askErr != nil { + return steploop.ToolResult{}, askErr + } + if askErr := r.ask(ctx, call, "read", []string{resolved}, map[string]any{}); askErr != nil { + return steploop.ToolResult{}, askErr + } + if errors.Is(err, os.ErrNotExist) { + return steploop.ToolResult{}, readMissError(resolved) + } + if err != nil { + return steploop.ToolResult{}, err + } + + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + if info.IsDir() { + return readDirectory(ctx, resolved, title, offset, limit) + } + + loaded := r.instructionService().Resolve( + steploop.ToolMessagesFromContext(ctx), resolved, call.MessageID, + ) + loadedPaths := make([]string, 0, len(loaded)) + for _, item := range loaded { + loadedPaths = append(loadedPaths, item.Filepath) + } + + sample, err := readSample(resolved, info.Size(), sampleBytes) + if err != nil { + return steploop.ToolResult{}, err + } + mimeType := sniffAttachmentMIME(sample, attachmentMIME(resolved)) + if _, ok := supportedImageMIMEs[mimeType]; ok || mimeType == "application/pdf" { + data, err := os.ReadFile(resolved) + if err != nil { + return steploop.ToolResult{}, err + } + message := "Image read successfully" + if mimeType == "application/pdf" { + message = "PDF read successfully" + } + attachments := []msgmodel.FilePart{{ + Type: "file", + Mime: mimeType, + URL: "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), + }} + return steploop.ToolResult{ + Title: title, + Output: message, + Attachments: &attachments, + Metadata: rawMetadata(readMetadata{ + Preview: message, + Truncated: false, + Loaded: loadedPaths, + }), + }, nil + } + + if isBinaryFile(resolved, sample) { + return steploop.ToolResult{}, fmt.Errorf("Cannot read binary file: %s", resolved) + } + + file, err := readLines(ctx, resolved, limit, offset) + if err != nil { + return steploop.ToolResult{}, err + } + if file.count < file.offset && !(file.count == 0 && file.offset == 1) { + return steploop.ToolResult{}, fmt.Errorf( + "Offset %d is out of range for this file (%d lines)", + file.offset, + file.count, + ) + } + + var output strings.Builder + output.WriteString("") + output.WriteString(resolved) + output.WriteString("\nfile\n\n") + for i, line := range file.raw { + if i > 0 { + output.WriteByte('\n') + } + fmt.Fprintf(&output, "%d: %s", i+file.offset, line) + } + + last := file.offset + len(file.raw) - 1 + next := last + 1 + truncated := file.more || file.cut + switch { + case file.cut: + fmt.Fprintf( + &output, + "\n\n(Output capped at 50 KB. Showing lines %d-%d. Use offset=%d to continue.)", + file.offset, + last, + next, + ) + case file.more: + fmt.Fprintf( + &output, + "\n\n(Showing lines %d-%d of %d. Use offset=%d to continue.)", + file.offset, + last, + file.count, + next, + ) + default: + fmt.Fprintf(&output, "\n\n(End of file - total %d lines)", file.count) + } + output.WriteString("\n") + if len(loaded) > 0 { + output.WriteString("\n\n\n") + for index, item := range loaded { + if index > 0 { + output.WriteString("\n\n") + } + output.WriteString(item.Content) + } + output.WriteString("\n") + } + + previewLimit := len(file.raw) + if previewLimit > 20 { + previewLimit = 20 + } + return steploop.ToolResult{ + Title: title, + Output: output.String(), + Metadata: rawMetadata(readMetadata{ + Preview: strings.Join(file.raw[:previewLimit], "\n"), + Truncated: truncated, + Loaded: loadedPaths, + }), + }, nil +} + +func readMissError(path string) error { + dir := filepath.Dir(path) + base := filepath.Base(path) + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("File not found: %s", path) + } + baseLower := strings.ToLower(base) + items := make([]string, 0, 3) + for _, entry := range entries { + nameLower := strings.ToLower(entry.Name()) + if strings.Contains(nameLower, baseLower) || strings.Contains(baseLower, nameLower) { + items = append(items, filepath.Join(dir, entry.Name())) + if len(items) == 3 { + break + } + } + } + if len(items) > 0 { + return fmt.Errorf("File not found: %s\n\nDid you mean one of these?\n%s", path, strings.Join(items, "\n")) + } + return fmt.Errorf("File not found: %s", path) +} + +func readDirectory( + ctx context.Context, + path string, + title string, + offset int, + limit int, +) (steploop.ToolResult, error) { + entries, err := os.ReadDir(path) + if err != nil { + return steploop.ToolResult{}, err + } + items := make([]string, 0, len(entries)) + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + name := entry.Name() + isDir := entry.IsDir() + if entry.Type()&os.ModeSymlink != 0 { + if target, statErr := os.Stat(filepath.Join(path, name)); statErr == nil { + isDir = target.IsDir() + } + } + if isDir { + name += "/" + } + items = append(items, name) + } + sort.SliceStable(items, func(i, j int) bool { + return items[i] < items[j] + }) + + start := offset - 1 + if start < 0 { + start = 0 + } + if start > len(items) { + start = len(items) + } + end := start + limit + if end > len(items) { + end = len(items) + } + sliced := items[start:end] + truncated := start+len(sliced) < len(items) + + var output strings.Builder + output.WriteString("") + output.WriteString(path) + output.WriteString("\ndirectory\n\n") + output.WriteString(strings.Join(sliced, "\n")) + if truncated { + fmt.Fprintf( + &output, + "\n\n(Showing %d of %d entries. Use 'offset' parameter to read beyond entry %d)", + len(sliced), + len(items), + offset+len(sliced), + ) + } else { + fmt.Fprintf(&output, "\n\n(%d entries)", len(items)) + } + output.WriteString("\n") + + previewLimit := len(sliced) + if previewLimit > 20 { + previewLimit = 20 + } + return steploop.ToolResult{ + Title: title, + Output: output.String(), + Metadata: rawMetadata(readMetadata{ + Preview: strings.Join(sliced[:previewLimit], "\n"), + Truncated: truncated, + Loaded: []string{}, + }), + }, nil +} + +func rawMetadata(value any) msgmodel.RawObject { + data, err := jsonutil.Marshal(value) + if err != nil { + panic(err) + } + return msgmodel.RawObject(data) +} + +func readSample(path string, fileSize int64, size int) ([]byte, error) { + if fileSize == 0 { + return []byte{}, nil + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + if fileSize < int64(size) { + size = int(fileSize) + } + out := make([]byte, size) + n, err := io.ReadFull(file, out) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) { + return nil, err + } + return out[:n], nil +} + +func isBinaryFile(path string, sample []byte) bool { + if _, ok := binaryExtensions[strings.ToLower(filepath.Ext(path))]; ok { + return true + } + if len(sample) == 0 { + return false + } + nonPrintable := 0 + for _, value := range sample { + if value == 0 { + return true + } + if value < 9 || (value > 13 && value < 32) { + nonPrintable++ + } + } + return float64(nonPrintable)/float64(len(sample)) > 0.3 +} + +func sniffAttachmentMIME(data []byte, fallback string) string { + switch { + case bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}): + return "image/png" + case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}): + return "image/jpeg" + case bytes.HasPrefix(data, []byte{0x47, 0x49, 0x46, 0x38}): + return "image/gif" + case bytes.HasPrefix(data, []byte{0x42, 0x4d}): + return "image/bmp" + case bytes.HasPrefix(data, []byte{0x25, 0x50, 0x44, 0x46, 0x2d}): + return "application/pdf" + case len(data) >= 12 && + bytes.Equal(data[:4], []byte{0x52, 0x49, 0x46, 0x46}) && + bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}): + return "image/webp" + default: + return fallback + } +} + +func attachmentMIME(path string) string { + value := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))) + if semi := strings.IndexByte(value, ';'); semi >= 0 { + value = value[:semi] + } + if value == "" { + return "application/octet-stream" + } + return value +} + +func readLines(ctx context.Context, path string, limit int, offset int) (readLinesResult, error) { + file, err := os.Open(path) + if err != nil { + return readLinesResult{}, err + } + defer file.Close() + + result := readLinesResult{raw: []string{}, offset: offset} + start := offset - 1 + reader := bufio.NewReader(file) + for { + if err := ctx.Err(); err != nil { + return readLinesResult{}, err + } + data, readErr := reader.ReadBytes('\n') + if len(data) == 0 && errors.Is(readErr, io.EOF) { + break + } + if len(data) > 0 && data[len(data)-1] == '\n' { + data = data[:len(data)-1] + if len(data) > 0 && data[len(data)-1] == '\r' { + data = data[:len(data)-1] + } + } + text := strings.ToValidUTF8(string(data), "\uFFFD") + result.count++ + if result.count > start { + if len(result.raw) >= limit { + result.more = true + } else { + line := truncateLineUTF16(text, maxLineLength) + size := len([]byte(line)) + if len(result.raw) > 0 { + size++ + } + if readBytesLength(result.raw)+size > maxReadBytes { + result.cut = true + result.more = true + break + } + result.raw = append(result.raw, line) + } + } + if readErr != nil { + if !errors.Is(readErr, io.EOF) { + return readLinesResult{}, readErr + } + break + } + } + return result, nil +} + +func readBytesLength(lines []string) int { + total := 0 + for i, line := range lines { + total += len([]byte(line)) + if i > 0 { + total++ + } + } + return total +} + +// truncateLineUTF16 cuts a line at limit UTF-16 code units and appends +// maxLineSuffix. +func truncateLineUTF16(value string, limit int) string { + units := utf16.Encode([]rune(value)) + if len(units) <= limit { + return value + } + units = units[:limit] + runes := utf16.Decode(units) + out := string(runes) + if len(runes) > 0 && runes[len(runes)-1] == utf8.RuneError && units[len(units)-1] >= 0xd800 && units[len(units)-1] <= 0xdbff { + // Cutting at a UTF-16 boundary can split a surrogate pair; the dangling + // high surrogate becomes U+FFFD so the output stays valid UTF-8. + out = string(runes[:len(runes)-1]) + "\uFFFD" + } + return out + maxLineSuffix +} diff --git a/internal/seniordev/tool/read_test.go b/internal/seniordev/tool/read_test.go new file mode 100644 index 000000000..d4d96c46a --- /dev/null +++ b/internal/seniordev/tool/read_test.go @@ -0,0 +1,302 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func TestReadInjectsNestedInstructionsOncePerSession(t *testing.T) { + // A nearby AGENTS.md is appended as a system-reminder block and its path is + // recorded; persisted read metadata suppresses it on later reads. + workDir := t.TempDir() + nested := filepath.Join(workDir, "src", "pkg") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + rules := filepath.Join(workDir, "src", "AGENTS.md") + target := filepath.Join(nested, "main.go") + if err := os.WriteFile(rules, []byte("keep the nested contract"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("package pkg\n"), 0o644); err != nil { + t.Fatal(err) + } + + registry := New(workDir) + input := json.RawMessage(`{"filePath":"src/pkg/main.go"}`) + first, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call_1", Name: "read", Input: input, + SessionID: "ses_1", MessageID: "msg_1", + }) + if err != nil { + t.Fatal(err) + } + wantReminder := "\n\n\nInstructions from: " + rules + + "\nkeep the nested contract\n" + if !strings.HasSuffix(first.Output, wantReminder) { + t.Fatalf("first output missing reminder:\n%s", first.Output) + } + var metadata readMetadata + if err := json.Unmarshal(first.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(metadata.Loaded, []string{rules}) { + t.Fatalf("loaded = %#v, want %q", metadata.Loaded, rules) + } + + history := []msgmodel.WithParts{{Parts: msgmodel.Parts{msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "part_1", SessionID: "ses_1", MessageID: "msg_1"}, + CallID: "call_1", + Tool: "read", + State: msgmodel.CompletedToolState( + msgmodel.RawObject(input), first.Output, first.Title, first.Metadata, 1, 2, nil, + ), + }}}} + secondCtx := steploop.WithToolMessages(context.Background(), history) + second, err := registry.Execute(secondCtx, steploop.ToolCall{ + ID: "call_2", Name: "read", Input: input, + SessionID: "ses_1", MessageID: "msg_2", + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(second.Output, "") { + t.Fatalf("second output duplicated reminder:\n%s", second.Output) + } + if err := json.Unmarshal(second.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if len(metadata.Loaded) != 0 { + t.Fatalf("second loaded = %#v, want empty", metadata.Loaded) + } +} + +func TestReadInstructionClaimsClearAfterAssistantTurn(t *testing.T) { + // An in-flight claim suppresses duplicate reads within one assistant turn, + // but clearing that turn permits a retry when no completed read metadata + // recorded the path. + workDir := t.TempDir() + nested := filepath.Join(workDir, "src") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, nested, "AGENTS.md", "retry this rule") + writeTestFile(t, nested, "main.go", "package main") + registry := New(workDir) + call := steploop.ToolCall{ + ID: "call", Name: "read", Input: json.RawMessage(`{"filePath":"src/main.go"}`), + SessionID: "session", MessageID: "assistant", + } + first, err := registry.Execute(context.Background(), call) + if err != nil || !strings.Contains(first.Output, "retry this rule") { + t.Fatalf("first read = (%q, %v)", first.Output, err) + } + second, err := registry.Execute(context.Background(), call) + if err != nil || strings.Contains(second.Output, "retry this rule") { + t.Fatalf("same-turn read = (%q, %v)", second.Output, err) + } + registry.ClearInstructionClaims(context.Background(), call.MessageID) + third, err := registry.Execute(context.Background(), call) + if err != nil || !strings.Contains(third.Output, "retry this rule") { + t.Fatalf("post-clear retry = (%q, %v)", third.Output, err) + } +} + +func TestReadNestedInstructionPrecedenceAndNoMatch(t *testing.T) { + // Nested lookup prefers AGENTS.md, then CLAUDE.md, then the deprecated + // CONTEXT.md; a file with no nearby instructions is unchanged. + tests := []struct { + name string + files map[string]string + want string + content string + }{ + { + name: "agents", want: "AGENTS.md", content: "agents wins", + files: map[string]string{ + "AGENTS.md": "agents wins", "CLAUDE.md": "claude loses", "CONTEXT.md": "context loses", + }, + }, + { + name: "claude", want: "CLAUDE.md", content: "claude wins", + files: map[string]string{"CLAUDE.md": "claude wins", "CONTEXT.md": "context loses"}, + }, + { + name: "context", want: "CONTEXT.md", content: "context remains", + files: map[string]string{"CONTEXT.md": "context remains"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + workDir := t.TempDir() + nested := filepath.Join(workDir, "nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + for name, content := range test.files { + if err := os.WriteFile(filepath.Join(nested, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(nested, "target.txt"), []byte("target"), 0o644); err != nil { + t.Fatal(err) + } + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "nested/target.txt"}) + if err != nil { + t.Fatal(err) + } + want := "Instructions from: " + filepath.Join(nested, test.want) + "\n" + test.content + if !strings.Contains(result.Output, want) { + t.Fatalf("output missing %q:\n%s", want, result.Output) + } + for name := range test.files { + if name != test.want && strings.Contains(result.Output, filepath.Join(nested, name)) { + t.Fatalf("output included lower-precedence %s:\n%s", name, result.Output) + } + } + }) + } + + workDir := t.TempDir() + writeTestFile(t, workDir, "plain.txt", "plain") + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "plain.txt"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(result.Output, "") || !strings.HasSuffix(result.Output, "\n") { + t.Fatalf("no-match output changed:\n%s", result.Output) + } +} + +func TestReadDirectory(t *testing.T) { + workDir := t.TempDir() + if err := os.Mkdir(filepath.Join(workDir, "beta"), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, "alpha.txt", "alpha") + if err := os.Symlink("beta", filepath.Join(workDir, "linked")); err != nil { + t.Fatal(err) + } + + result, err := execute(t, New(workDir), "read", map[string]any{ + "filePath": ".", + "limit": 2, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "" + workDir + "\ndirectory\n\n" + + "alpha.txt\nbeta/\n\n" + + "(Showing 2 of 3 entries. Use 'offset' parameter to read beyond entry 3)\n" + if result.Output != want { + t.Fatalf("Output:\n%s\nwant:\n%s", result.Output, want) + } + if result.Title != "." { + t.Fatalf("Title = %q", result.Title) + } + if got := string(result.Metadata); got != `{"preview":"alpha.txt\nbeta/","truncated":true,"loaded":[]}` { + t.Fatalf("Metadata = %s", got) + } +} + +func TestReadLongLineAndByteCap(t *testing.T) { + workDir := t.TempDir() + long := strings.Repeat("界", maxLineLength+1) + writeTestFile(t, workDir, "long.txt", long+"\nend\n") + + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "long.txt"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + wantLine := strings.Repeat("界", maxLineLength) + maxLineSuffix + if !strings.Contains(result.Output, "1: "+wantLine+"\n2: end") { + t.Fatalf("long line was not truncated by UTF-16 length") + } + + lines := make([]string, 40) + for i := range lines { + lines[i] = strings.Repeat("x", maxLineLength) + } + writeTestFile(t, workDir, "cap.txt", strings.Join(lines, "\n")) + result, err = execute(t, New(workDir), "read", map[string]any{"filePath": "cap.txt"}) + if err != nil { + t.Fatalf("Execute capped: %v", err) + } + if !strings.Contains(result.Output, "(Output capped at 50 KB. Showing lines 1-25. Use offset=26 to continue.)") { + t.Fatalf("cap message missing from %q", result.Output[len(result.Output)-160:]) + } +} + +func TestReadImagePDFAndBinary(t *testing.T) { + workDir := t.TempDir() + png := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, []byte("payload")...) + if err := os.WriteFile(filepath.Join(workDir, "misnamed.dat"), png, 0o644); err != nil { + t.Fatal(err) + } + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "misnamed.dat"}) + if err != nil { + t.Fatalf("read image: %v", err) + } + if result.Output != "Image read successfully" || result.Attachments == nil || len(*result.Attachments) != 1 { + t.Fatalf("image result = %#v", result) + } + attachment := (*result.Attachments)[0] + wantURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) + if attachment.Mime != "image/png" || attachment.URL != wantURL { + t.Fatalf("attachment = %#v", attachment) + } + + if err := os.WriteFile(filepath.Join(workDir, "binary.bin"), []byte("plain text"), 0o644); err != nil { + t.Fatal(err) + } + _, err = execute(t, New(workDir), "read", map[string]any{"filePath": "binary.bin"}) + if err == nil || err.Error() != "Cannot read binary file: "+filepath.Join(workDir, "binary.bin") { + t.Fatalf("binary error = %v", err) + } +} + +func TestReadOffsetAndZeroLimit(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "sample.txt", "one\ntwo\n") + registry := New(workDir) + + result, err := execute(t, registry, "read", map[string]any{ + "filePath": "sample.txt", + "offset": 0, + "limit": 0, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "(Showing lines 1-0 of 2. Use offset=1 to continue.)") { + t.Fatalf("zero-limit output = %q", result.Output) + } + + _, err = execute(t, registry, "read", map[string]any{ + "filePath": "sample.txt", + "offset": 3, + }) + if err == nil || err.Error() != "Offset 3 is out of range for this file (2 lines)" { + t.Fatalf("offset error = %v", err) + } +} + +func TestReadDescription(t *testing.T) { + if !strings.HasPrefix(readDescription, "Read a file or directory from the local filesystem.") { + t.Fatalf("read description changed: %q", readDescription) + } + if !strings.HasSuffix(readDescription, "return them as file attachments.\n") { + t.Fatalf("read description changed: %q", readDescription) + } +} diff --git a/internal/seniordev/tool/registry.go b/internal/seniordev/tool/registry.go new file mode 100644 index 000000000..6f71a94d7 --- /dev/null +++ b/internal/seniordev/tool/registry.go @@ -0,0 +1,609 @@ +//go:build !windows + +// Package tool is the workspace-bound tool registry: the tools the model can +// call, each confined to a single workspace and gated by the permission rules. +package tool + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/config" + "github.com/Agent-Field/codeaf/internal/seniordev/core" + "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/project" + "github.com/Agent-Field/codeaf/internal/seniordev/question" + "github.com/Agent-Field/codeaf/internal/seniordev/session/instruction" +) + +const ( + bashSchema = `{ + "type": "object", + "properties": { + "command": {"type": "string"}, + "workdir": {"type": "string"}, + "timeout_ms": {"type": "integer", "minimum": 1, "maximum": 600000} + }, + "required": ["command"], + "additionalProperties": false + }` + readSchema = `{ + "type": "object", + "properties": { + "filePath": {"type": "string", "description": "The absolute path to the file or directory to read"}, + "offset": {"type": "integer", "minimum": 0, "description": "The line number to start reading from (1-indexed)"}, + "limit": {"type": "integer", "minimum": 0, "description": "The maximum number of lines to read (defaults to 2000)"} + }, + "required": ["filePath"], + "additionalProperties": false + }` + writeSchema = `{ + "type": "object", + "properties": { + "content": {"type": "string", "description": "The content to write to the file"}, + "filePath": {"type": "string", "description": "The absolute path to the file to write (must be absolute, not relative)"} + }, + "required": ["content", "filePath"], + "additionalProperties": false + }` + editSchema = `{ + "type": "object", + "properties": { + "filePath": {"type": "string", "description": "The absolute path to the file to modify"}, + "oldString": {"type": "string", "description": "The text to replace"}, + "newString": {"type": "string", "description": "The text to replace it with (must be different from oldString)"}, + "replaceAll": {"type": "boolean", "description": "Replace all occurrences of oldString (default false)"} + }, + "required": ["filePath", "oldString", "newString"], + "additionalProperties": false + }` + globSchema = `{ + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "The glob pattern to match files against"}, + "path": {"type": "string", "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided."} + }, + "required": ["pattern"], + "additionalProperties": false + }` + grepSchema = `{ + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "The regex pattern to search for in file contents"}, + "path": {"type": "string", "description": "The directory to search in. Defaults to the current working directory."}, + "include": {"type": "string", "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"} + }, + "required": ["pattern"], + "additionalProperties": false + }` + applyPatchSchema = `{ + "type": "object", + "properties": { + "patchText": {"type": "string", "description": "The full patch text that describes all changes to be made"} + }, + "required": ["patchText"], + "additionalProperties": false + }` +) + +type bashInput struct { + Command string `json:"command"` + Workdir string `json:"workdir,omitempty"` + TimeoutMS *int `json:"timeout_ms,omitempty"` +} + +type readInput struct { + FilePath string `json:"filePath"` + Offset *int `json:"offset,omitempty"` + Limit *int `json:"limit,omitempty"` +} + +type writeInput struct { + Content string `json:"content"` + FilePath string `json:"filePath"` +} + +type editInput struct { + FilePath string `json:"filePath"` + OldString string `json:"oldString"` + NewString string `json:"newString"` + ReplaceAll bool `json:"replaceAll,omitempty"` +} + +type globInput struct { + Pattern string `json:"pattern"` + Path *string `json:"path,omitempty"` +} + +type applyPatchInput struct { + PatchText string `json:"patchText"` +} + +// Registry is a collection of tools whose file operations are confined to a +// single workspace. +type Registry struct { + workDir string + instance *project.InstanceContext + rg ripgrepRunner + instructions *instructionRegistry + permission PermissionEvaluator + rules PermissionRules + config *config.Service + formatters *formatterServices + npm *core.Npm + instructionConfig instruction.Config + allowExternal bool + hardConfineShell bool + question *question.Service + questionEnabled bool + questionRejects *questionRejectionState + // submitFreeze captures the candidate when the model calls submit. Nil in + // embedders that do not run a submission protocol; the tool is then not + // advertised at all rather than advertised and refused. + submitFreeze SubmitFreezer +} + +type questionRejectionState struct { + mu sync.Mutex + counts map[string]int +} + +type instructionRegistry struct { + mu sync.Mutex + services map[string]*instruction.Service +} + +// PermissionRules resolves the agent and instance rules for one tool call. +type PermissionRules func(context.Context, steploop.ToolCall) permission.Ruleset + +// PermissionEvaluator decides one permission request; mutation requests carry +// the proposed diff in their metadata. +type PermissionEvaluator interface { + Evaluate(permission.AskInput) error +} + +// RegistryOptions supplies the host services used by live tool execution. +type RegistryOptions struct { + Permission PermissionEvaluator + PermissionRules PermissionRules + Instructions []string + Config *config.Service + AllowExternalDirectories bool + // HardConfineShellPaths rejects parsed external shell operands instead of + // asking permission, for an embedder that must not prompt. senior-dev leaves it + // disabled and asks. + HardConfineShellPaths bool + // ClientIdentity names the kind of client driving the registry (app, cli, + // desktop) and decides whether the question tool is advertised. The senior-dev + // binary supplies "cli" unless SENIOR_DEV_CLIENT overrides it; embedders that + // omit it are not assumed to have an interactive client. + ClientIdentity string + Question *question.Service + // SubmitFreeze installs the submit tool and receives the candidate at the + // moment the model submits. See submit.go. + SubmitFreeze SubmitFreezer +} + +var bakedPermissionCache sync.Map + +func bakedPermissionRules(_ context.Context, call steploop.ToolCall) permission.Ruleset { + if call.Agent == "" { + return nil + } + if cached, ok := bakedPermissionCache.Load(call.Agent); ok { + return cached.(permission.Ruleset) + } + markdown, ok := baked.GetBakedAgentMarkdown(call.Agent) + if !ok { + return nil + } + rules, err := permission.RulesetFromFrontmatter(markdown) + if err != nil { + return nil + } + bakedPermissionCache.Store(call.Agent, rules) + return rules +} + +// WebSearchFlags are the two feature flags consulted by webSearchEnabled. +type WebSearchFlags struct { + Exa bool + Parallel bool +} + +// FilterInput carries the provider, model and search flags that decide which +// tools are advertised for a turn. +type FilterInput struct { + ProviderID string + ModelID string + Flags WebSearchFlags +} + +// New returns a registry bound to workDir. +func New(workDir string) *Registry { + return NewWithOptions(workDir, RegistryOptions{}) +} + +// NewWithOptions returns a configured registry bound to workDir. +func NewWithOptions(workDir string, options RegistryOptions) *Registry { + absolute, err := filepath.Abs(workDir) + if err != nil { + absolute = workDir + } + service := options.Permission + if service == nil { + service = &permission.Service{} + } + rules := options.PermissionRules + if rules == nil { + rules = bakedPermissionRules + } + cacheDir, _ := os.UserCacheDir() + configService := options.Config + if configService == nil { + configService = config.NewService(config.Loader{Env: config.NewEnv(nil)}) + } + env := config.NewEnv(nil) + clientIdentity := options.ClientIdentity + if clientIdentity == "" { + if configured, ok := env.Get("SENIOR_DEV_CLIENT"); ok { + clientIdentity = configured + } + } + questionService := options.Question + if questionService == nil { + questionService = question.Default + } + return &Registry{ + workDir: filepath.Clean(absolute), + rg: pickRipgrepRunner(), + permission: service, + rules: rules, + config: configService, + allowExternal: options.AllowExternalDirectories, + hardConfineShell: options.HardConfineShellPaths, + question: questionService, + questionEnabled: clientIdentity == "app" || clientIdentity == "cli" || clientIdentity == "desktop" || env.Enabled("SENIOR_DEV_ENABLE_QUESTION_TOOL"), + questionRejects: &questionRejectionState{counts: map[string]int{}}, + submitFreeze: options.SubmitFreeze, + formatters: newFormatterServices(), + npm: core.NewNpm(filepath.Join(cacheDir, "senior-dev"), nil), + instructionConfig: instruction.Config{ + Instructions: append([]string(nil), options.Instructions...), + }, + instructions: &instructionRegistry{ + services: map[string]*instruction.Service{}, + }, + } +} + +// Definitions returns the provider declarations for all workspace tools. +func (r *Registry) Definitions() []steploop.ToolDefinition { + definitions := make([]steploop.ToolDefinition, 0, 10) + // The question tool is advertised for interactive client identities (app, + // cli, desktop) or when SENIOR_DEV_ENABLE_QUESTION_TOOL forces it on for an + // embedder. + if r.questionEnabled { + question := definition("question", questionDescription, questionSchema, validateQuestion) + // A question blocks until it is answered or rejected, so the result stream + // is held open for it rather than settled on the usual abort timer. + question.WaitForResult = true + definitions = append(definitions, question) + } + if r.submitFreeze != nil { + definitions = append(definitions, + definition("submit", submitDescription, submitSchema, validateSubmit)) + } + return append(definitions, + definition("bash", "Run a Bash command in the workspace. Output is capped at 30000 bytes and execution at 600000ms.", bashSchema, validateBash), + definition("read", readDescription, readSchema, validateRead), + definition("glob", globDescription, globSchema, validateGlob), + definition("grep", grepDescription, grepSchema, validateGrep), + definition("edit", editDescription, editSchema, validateEdit), + definition("write", writeDescription, writeSchema, validateWrite), + definition("webfetch", webFetchDescription, webFetchSchema, validateWebFetch), + definition("websearch", webSearchDescription(), webSearchSchema, validateWebSearch), + definition("apply_patch", applyPatchDescription, applyPatchSchema, validateApplyPatch), + ) +} + +// IDs returns builtin tool IDs in registry insertion order. +func (r *Registry) IDs() []string { + definitions := r.Definitions() + out := make([]string, 0, len(definitions)) + for _, item := range definitions { + out = append(out, item.Provider.Name) + } + return out +} + +// DefinitionsFor applies the registry's provider and model-family visibility +// rules. +func (r *Registry) DefinitionsFor(input FilterInput) []steploop.ToolDefinition { + return FilterDefinitions(r.Definitions(), input) +} + +// WebSearchEnabled reports whether a search backend is available: the senior-dev +// provider, or an Exa or Parallel flag. +func WebSearchEnabled(providerID string, flags WebSearchFlags) bool { + return providerID == "senior-dev" || flags.Exa || flags.Parallel +} + +// FilterDefinitions narrows the advertised tool list to what the provider and +// model can use: websearch needs a search backend, and GPT-family models get +// apply_patch in place of edit/write. It is separate from Registry so +// plugin/custom definitions can pass through the same seam. +func FilterDefinitions( + definitions []steploop.ToolDefinition, + input FilterInput, +) []steploop.ToolDefinition { + usePatch := strings.Contains(input.ModelID, "gpt-") && + !strings.Contains(input.ModelID, "oss") && + !strings.Contains(input.ModelID, "gpt-4") + out := make([]steploop.ToolDefinition, 0, len(definitions)) + for _, item := range definitions { + id := item.Provider.Name + if id == "websearch" && !WebSearchEnabled(input.ProviderID, input.Flags) { + continue + } + if id == "apply_patch" && !usePatch { + continue + } + if (id == "edit" || id == "write") && usePatch { + continue + } + out = append(out, item) + } + return out +} + +func definition(name, description, schema string, validate func(json.RawMessage) error) steploop.ToolDefinition { + return steploop.ToolDefinition{ + Provider: orclient.Tool{ + Type: "function", + Name: name, + Description: description, + InputSchema: json.RawMessage(schema), + }, + Validate: validate, + } +} + +// Execute dispatches an already-validated call to its named tool. +func (r *Registry) Execute(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + r = r.forContext(ctx) + var result steploop.ToolResult + var err error + switch call.Name { + case "question": + return r.executeQuestion(ctx, call) + case "submit": + result, err = r.executeSubmit(ctx, call) + case "bash": + result, err = r.executeBash(ctx, call) + case "read": + result, err = r.executeRead(ctx, call) + case "glob": + result, err = r.executeGlob(ctx, call) + case "grep": + result, err = r.executeGrep(ctx, call) + case "write": + result, err = r.executeWrite(ctx, call) + case "edit": + result, err = r.executeEdit(ctx, call) + case "apply_patch": + result, err = r.executeApplyPatch(ctx, call) + case "webfetch": + result, err = r.executeWebFetch(ctx, call) + case "websearch": + result, err = r.executeWebSearch(ctx, call) + default: + return steploop.ToolResult{}, fmt.Errorf("unknown tool: %s", call.Name) + } + if err == nil { + r.resetQuestionRejections(call.SessionID) + } + return result, err +} + +func (r *Registry) resetQuestionRejections(sessionID string) { + if r.questionRejects == nil { + return + } + r.questionRejects.mu.Lock() + delete(r.questionRejects.counts, sessionID) + r.questionRejects.mu.Unlock() +} + +func (r *Registry) recordQuestionRejection(sessionID string) int { + if r.questionRejects == nil { + return 1 + } + r.questionRejects.mu.Lock() + defer r.questionRejects.mu.Unlock() + r.questionRejects.counts[sessionID]++ + return r.questionRejects.counts[sessionID] +} + +func (r *Registry) instructionService() *instruction.Service { + worktree := r.workDir + if r.instance != nil && r.instance.Worktree != "" { + worktree = filepath.Clean(r.instance.Worktree) + } + key := r.workDir + "\x00" + worktree + r.instructions.mu.Lock() + defer r.instructions.mu.Unlock() + if service := r.instructions.services[key]; service != nil { + return service + } + home, _ := os.UserHomeDir() + config, _ := os.UserConfigDir() + service := instruction.New(instruction.Options{ + Config: r.instructionConfig, + Global: instruction.Global{Config: filepath.Join(config, "senior-dev"), Home: home}, + Instance: instruction.Instance{ + Directory: r.workDir, + Worktree: worktree, + }, + }) + r.instructions.services[key] = service + return service +} + +func (r *Registry) ask( + ctx context.Context, + call steploop.ToolCall, + name string, + patterns []string, + metadata map[string]any, +) error { + return r.askWithAlways(ctx, call, name, patterns, []string{"*"}, metadata) +} + +func (r *Registry) askWithAlways( + ctx context.Context, + call steploop.ToolCall, + name string, + patterns []string, + always []string, + metadata map[string]any, +) error { + rules := permission.Ruleset(nil) + if r.rules != nil { + rules = r.rules(ctx, call) + } + return r.permission.Evaluate(permission.AskInput{ + Request: permission.Request{ + SessionID: call.SessionID, Permission: name, Patterns: patterns, + Metadata: metadata, Always: always, + }, + Ruleset: rules, + }) +} + +// SystemInstructions returns the root/global instruction blocks used by the +// engine system prompt for the same workspace-bound service as read tools. +func (r *Registry) SystemInstructions(ctx context.Context) []string { + return r.forContext(ctx).instructionService().System(ctx) +} + +// ClearInstructionClaims releases one assistant turn's in-flight nested-path +// claims. Persisted read metadata remains the cross-turn loaded-path memory. +func (r *Registry) ClearInstructionClaims(ctx context.Context, messageID string) { + r.forContext(ctx).instructionService().Clear(messageID) +} + +// forContext resolves the per-leaf cwd at call time. A shallow clone makes a +// single registry safe for concurrent leaf contexts. +func (r *Registry) forContext(ctx context.Context) *Registry { + instance, ok := project.FromContext(ctx) + if !ok || instance.Directory == "" { + return r + } + copy := *r + copy.workDir = filepath.Clean(instance.Directory) + copy.instance = &instance + return © +} + +func validateBash(raw json.RawMessage) error { + var input bashInput + if err := decodeInput(raw, &input, "command"); err != nil { + return err + } + if input.TimeoutMS != nil && (*input.TimeoutMS < 1 || *input.TimeoutMS > 600000) { + return fmt.Errorf("timeout_ms must be between 1 and 600000") + } + return nil +} + +func validateRead(raw json.RawMessage) error { + var input readInput + if err := decodeInput(raw, &input, "filePath"); err != nil { + return err + } + if input.Offset != nil && *input.Offset < 0 { + return fmt.Errorf("offset must be at least 0") + } + if input.Limit != nil && *input.Limit < 0 { + return fmt.Errorf("limit must be at least 0") + } + return nil +} + +func validateWrite(raw json.RawMessage) error { + var input writeInput + return decodeInput(raw, &input, "content", "filePath") +} + +func validateGlob(raw json.RawMessage) error { + var input globInput + return decodeInput(raw, &input, "pattern") +} + +func validateGrep(raw json.RawMessage) error { + var input grepInput + return decodeInput(raw, &input, "pattern") +} + +func validateEdit(raw json.RawMessage) error { + var input editInput + return decodeInput(raw, &input, "filePath", "oldString", "newString") +} + +func validateApplyPatch(raw json.RawMessage) error { + var input applyPatchInput + return decodeInput(raw, &input, "patchText") +} + +func decodeInput(raw json.RawMessage, destination any, required ...string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return fmt.Errorf("input must be a JSON object: %w", err) + } + if fields == nil { + return fmt.Errorf("input must be a JSON object") + } + for _, name := range required { + if _, ok := fields[name]; !ok { + return fmt.Errorf("missing required field %q", name) + } + } + for name, value := range fields { + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("field %q must not be null", name) + } + } + + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return fmt.Errorf("invalid input: %w", err) + } + if err := ensureJSONEnd(decoder); err != nil { + return err + } + return nil +} + +func ensureJSONEnd(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("invalid input: %w", err) + } + return fmt.Errorf("input must contain one JSON object") +} diff --git a/internal/seniordev/tool/registry_policy_test.go b/internal/seniordev/tool/registry_policy_test.go new file mode 100644 index 000000000..ab0d33947 --- /dev/null +++ b/internal/seniordev/tool/registry_policy_test.go @@ -0,0 +1,207 @@ +//go:build !windows + +package tool + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/permission" +) + +type permissionEvaluatorFunc func(permission.AskInput) error + +func (fn permissionEvaluatorFunc) Evaluate(input permission.AskInput) error { return fn(input) } + +func TestRegistryPermissionDenyBlocksAndAskAutoApproves(t *testing.T) { + workspace := t.TempDir() + readTarget := filepath.Join(workspace, "secret.txt") + if err := os.WriteFile(readTarget, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + rules := permission.Ruleset{ + {Permission: "read", Pattern: readTarget, Action: permission.ActionDeny}, + {Permission: "bash", Pattern: "git status", Action: permission.ActionDeny}, + {Permission: "edit", Pattern: "blocked.txt", Action: permission.ActionDeny}, + {Permission: "edit", Pattern: "asked.txt", Action: permission.ActionAsk}, + } + registry := NewWithOptions(workspace, RegistryOptions{ + PermissionRules: func(context.Context, steploop.ToolCall) permission.Ruleset { + return rules + }, + }) + + // A deny rule rejects before any mutation happens. + _, err := execute(t, registry, "write", map[string]any{ + "filePath": filepath.Join(workspace, "blocked.txt"), "content": "blocked", + }) + var denied permission.DeniedError + if !errors.As(err, &denied) { + t.Fatalf("write error = %T %v, want permission.DeniedError", err, err) + } + if !strings.HasPrefix(err.Error(), "The user has specified a rule which prevents you from using this specific tool call.") { + t.Fatalf("denial text = %q", err) + } + if _, statErr := os.Stat(filepath.Join(workspace, "blocked.txt")); !os.IsNotExist(statErr) { + t.Fatalf("denied write changed filesystem: %v", statErr) + } + if _, err := execute(t, registry, "read", map[string]any{"filePath": readTarget}); !errors.As(err, &denied) { + t.Fatalf("configured read error = %T %v, want permission.DeniedError", err, err) + } + if _, err := execute(t, registry, "bash", map[string]any{ + "command": "git status && echo should-not-run", + }); !errors.As(err, &denied) { + t.Fatalf("configured parsed bash error = %T %v, want permission.DeniedError", err, err) + } + + // An unattended literal ask is auto-approved. + if _, err := execute(t, registry, "write", map[string]any{ + "filePath": filepath.Join(workspace, "asked.txt"), "content": "approved", + }); err != nil { + t.Fatalf("ask policy blocked unattended write: %v", err) + } +} + +func TestRegistryMutationPermissionReceivesProposedDiffBeforeWrite(t *testing.T) { + workspace := t.TempDir() + target := filepath.Join(workspace, "proposed.txt") + var request permission.AskInput + registry := NewWithOptions(workspace, RegistryOptions{ + Permission: permissionEvaluatorFunc(func(input permission.AskInput) error { + request = input + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("permission evaluated after mutation: %v", err) + } + return nil + }), + }) + + // Mutation asks include the proposed diff before any I/O. + if _, err := execute(t, registry, "write", map[string]any{ + "filePath": target, "content": "new content", + }); err != nil { + t.Fatal(err) + } + diff, _ := request.Metadata["diff"].(string) + if request.Permission != "edit" || request.Patterns[0] != "proposed.txt" || + !strings.Contains(diff, "+new content") { + t.Fatalf("permission request = %+v", request) + } +} + +func TestEditPermissionDiffNormalizesCRLFContract(t *testing.T) { + // The proposed diff handed to the permission evaluator is CRLF-normalized. + workspace := t.TempDir() + target := filepath.Join(workspace, "windows.txt") + if err := os.WriteFile(target, []byte("old\r\nkeep\r\n"), 0o644); err != nil { + t.Fatal(err) + } + var request permission.AskInput + registry := NewWithOptions(workspace, RegistryOptions{ + Permission: permissionEvaluatorFunc(func(input permission.AskInput) error { + request = input + return nil + }), + }) + if _, err := execute(t, registry, "edit", map[string]any{ + "filePath": target, "oldString": "old", "newString": "new", + }); err != nil { + t.Fatal(err) + } + diff, _ := request.Metadata["diff"].(string) + if strings.Contains(diff, "\r") || !strings.Contains(diff, "-old\n+new\n") { + t.Fatalf("CRLF proposed diff = %q", diff) + } +} + +func TestRegistryExternalDirectoryPermissionFlowContract(t *testing.T) { + // senior-dev resolves external targets, asks for their parent glob, then + // continues through each tool's ordinary permission. + workspace := t.TempDir() + external := t.TempDir() + target := filepath.Join(external, "outside.txt") + if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + requests := []permission.Request{} + registry := NewWithOptions(workspace, RegistryOptions{ + AllowExternalDirectories: true, + Permission: permissionEvaluatorFunc(func(input permission.AskInput) error { + requests = append(requests, input.Request) + return nil + }), + }) + assertPair := func(t *testing.T, ordinary string, run func() error) { + t.Helper() + requests = nil + if err := run(); err != nil { + t.Fatal(err) + } + if len(requests) < 2 || requests[0].Permission != "external_directory" || + requests[len(requests)-1].Permission != ordinary || + requests[0].Patterns[0] != filepath.ToSlash(filepath.Join(external, "*")) { + t.Fatalf("%s permission flow = %#v", ordinary, requests) + } + } + assertPair(t, "read", func() error { + _, err := execute(t, registry, "read", map[string]any{"filePath": target}) + return err + }) + assertPair(t, "edit", func() error { + _, err := execute(t, registry, "write", map[string]any{"filePath": target, "content": "write\n"}) + return err + }) + assertPair(t, "edit", func() error { + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": target, "oldString": "write", "newString": "edited", + }) + return err + }) + assertPair(t, "edit", func() error { + _, err := execute(t, registry, "apply_patch", map[string]any{ + "patchText": "*** Begin Patch\n*** Update File: " + target + "\n@@\n-edited\n+patched\n*** End Patch", + }) + return err + }) + assertPair(t, "bash", func() error { + _, err := execute(t, registry, "bash", map[string]any{ + "command": "pwd", "workdir": external, + }) + return err + }) + + confined := New(workspace) + if _, err := execute(t, confined, "read", map[string]any{"filePath": target}); err == nil || !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("default confinement error = %v", err) + } +} + +func TestRegistryHardShellConfinementContract(t *testing.T) { + // HardConfineShellPaths rejects parsed shell paths outside the workspace + // before the permission evaluator is consulted. + workspace := t.TempDir() + inside := filepath.Join(workspace, "inside.txt") + if err := os.WriteFile(inside, []byte("inside"), 0o644); err != nil { + t.Fatal(err) + } + external := t.TempDir() + copyTarget := filepath.Join(external, "copied.txt") + registry := NewWithOptions(workspace, RegistryOptions{HardConfineShellPaths: true}) + for _, command := range []string{ + "cat /etc/passwd", + "cp " + inside + " " + copyTarget, + } { + if _, err := execute(t, registry, "bash", map[string]any{"command": command}); err == nil || + !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("hard-confined command %q error = %v", command, err) + } + } + if _, err := os.Stat(copyTarget); !os.IsNotExist(err) { + t.Fatalf("hard-confined cp wrote outside workspace: %v", err) + } +} diff --git a/internal/seniordev/tool/registry_worktree.go b/internal/seniordev/tool/registry_worktree.go new file mode 100644 index 000000000..19a0f196f --- /dev/null +++ b/internal/seniordev/tool/registry_worktree.go @@ -0,0 +1,16 @@ +//go:build !windows + +package tool + +import "path/filepath" + +// worktree is the directory tool paths are reported relative to. It is the +// registry's workspace unless a project instance in context names a different +// worktree, which is the case only when an embedder runs the registry against +// a checkout other than the one it was constructed for. +func (r *Registry) worktree() string { + if r.instance != nil && r.instance.Worktree != "" { + return filepath.Clean(r.instance.Worktree) + } + return r.workDir +} diff --git a/internal/seniordev/tool/ripgrep.go b/internal/seniordev/tool/ripgrep.go new file mode 100644 index 000000000..a32fd105b --- /dev/null +++ b/internal/seniordev/tool/ripgrep.go @@ -0,0 +1,92 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "strings" +) + +type ripgrepResult struct { + stdout []byte + stderr []byte + code int +} + +type ripgrepRunner interface { + Run(ctx context.Context, cwd string, args []string) (ripgrepResult, error) +} + +type execRipgrepRunner struct{} + +func (execRipgrepRunner) Run(ctx context.Context, cwd string, args []string) (ripgrepResult, error) { + command := exec.CommandContext(ctx, "rg", args...) + command.Dir = cwd + command.Env = withoutEnv(os.Environ(), "RIPGREP_CONFIG_PATH") + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + if err == nil { + return ripgrepResult{stdout: stdout.Bytes(), stderr: stderr.Bytes(), code: 0}, nil + } + if ctx.Err() != nil { + return ripgrepResult{}, ctx.Err() + } + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return ripgrepResult{}, err + } + return ripgrepResult{ + stdout: stdout.Bytes(), + stderr: stderr.Bytes(), + code: exitError.ExitCode(), + }, nil +} + +func withoutEnv(environment []string, name string) []string { + prefix := name + "=" + out := make([]string, 0, len(environment)) + for _, entry := range environment { + if strings.HasPrefix(entry, prefix) { + continue + } + out = append(out, entry) + } + return out +} + +func ripgrepError(result ripgrepResult) error { + message := strings.TrimSpace(string(result.stderr)) + if message == "" { + message = "ripgrep failed with code " + itoa(result.code) + } + return errors.New(message) +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + negative := value < 0 + if negative { + value = -value + } + var digits [20]byte + index := len(digits) + for value > 0 { + index-- + digits[index] = byte('0' + value%10) + value /= 10 + } + if negative { + index-- + digits[index] = '-' + } + return string(digits[index:]) +} diff --git a/internal/seniordev/tool/ripgrep_fallback.go b/internal/seniordev/tool/ripgrep_fallback.go new file mode 100644 index 000000000..c6515402e --- /dev/null +++ b/internal/seniordev/tool/ripgrep_fallback.go @@ -0,0 +1,485 @@ +//go:build !windows + +// Ripgrep is the one external binary the search tools depend on, and it is +// never fetched at run time: a sealed or offline deployment could not download +// it anyway. +// +// A missing rg is not a cosmetic loss. grep and glob are how the agent reads a +// codebase, so without them a run does not degrade gracefully — it fails tool +// call after tool call and never gets to the work. This file keeps the +// existing ripgrepRunner seam and answers in process instead, so the engine is +// self-contained. rg stays authoritative whenever it is installed: the +// fallback is selected only when exec.LookPath("rg") misses, which means an +// existing deployment's behaviour is untouched. +// +// Fidelity notes (the deliberate gaps, so nobody has to rediscover them): +// - Inside a git work tree the file list comes from `git ls-files --cached +// --others --exclude-standard`, which reproduces rg's default .gitignore +// behaviour exactly. Outside one, every regular file is walked: rg would +// also honour .ignore/.rgignore files there, and this does not. +// - Patterns are compiled with Go's regexp (RE2), the same family as rg's +// default engine, so ordinary patterns behave the same. Anything relying on +// Rust-regex-only syntax will not compile here. +// - Results are emitted in lexicographic order. rg emits in traversal order; +// both are arbitrary as far as the callers are concerned, and a stable +// order makes the 100-result cap deterministic instead of filesystem +// dependent. +package tool + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io/fs" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" +) + +// binarySniffBytes mirrors rg's habit of skipping binary files: a NUL byte in +// the first chunk is the signal. +const binarySniffBytes = 8192 + +var warnMissingRipgrep sync.Once + +// pickRipgrepRunner returns the real rg whenever it is on PATH and the +// in-process searcher otherwise. Resolution happens per Registry rather than +// once per process so a deployment that installs rg mid-flight picks it up. +func pickRipgrepRunner() ripgrepRunner { + if _, err := exec.LookPath("rg"); err == nil { + return execRipgrepRunner{} + } + warnMissingRipgrep.Do(func() { + log.Printf("search: ripgrep (rg) is not on PATH — using the built-in searcher. " + + "Install ripgrep for faster search and exact ignore-file semantics.") + }) + return builtinSearchRunner{} +} + +// builtinSearchRunner answers the two invocation shapes the tools construct: +// `--files` (glob.go) and `--json` (grep.go). +type builtinSearchRunner struct{} + +type searchRequest struct { + listFiles bool + includes []string + excludes []string + pattern string + roots []string +} + +func (builtinSearchRunner) Run(ctx context.Context, cwd string, args []string) (ripgrepResult, error) { + request := parseSearchArgs(args) + filter, err := newGlobFilter(request.includes, request.excludes) + if err != nil { + return ripgrepResult{}, err + } + files, degraded := collectSearchFiles(ctx, cwd, request.roots, filter) + if ctx.Err() != nil { + return ripgrepResult{}, ctx.Err() + } + if request.listFiles { + return listFilesOutput(files), nil + } + return grepOutput(ctx, cwd, request.pattern, files, degraded) +} + +// parseSearchArgs reads the flags the callers actually pass. Unmodelled flags +// are ignored rather than rejected: every flag in play (--no-config, --hidden, +// --no-messages) only widens or quiets the search, so ignoring one can never +// turn a narrow search into a broad one. +func parseSearchArgs(args []string) searchRequest { + var request searchRequest + positional := []string{} + index := 0 + for ; index < len(args); index++ { + argument := args[index] + if argument == "--" { + index++ + break + } + switch { + case argument == "--files": + request.listFiles = true + case strings.HasPrefix(argument, "--glob="): + glob := strings.TrimPrefix(argument, "--glob=") + if strings.HasPrefix(glob, "!") { + request.excludes = append(request.excludes, strings.TrimPrefix(glob, "!")) + continue + } + request.includes = append(request.includes, glob) + case strings.HasPrefix(argument, "-"): + default: + positional = append(positional, argument) + } + } + positional = append(positional, args[index:]...) + if !request.listFiles && len(positional) > 0 { + request.pattern = positional[0] + positional = positional[1:] + } + request.roots = positional + if len(request.roots) == 0 { + request.roots = []string{"."} + } + return request +} + +// collectSearchFiles resolves every root to a de-duplicated, filtered, sorted +// list of paths relative to cwd. degraded reports whether anything was skipped +// because it could not be read — the caller turns that into rg's exit code 2. +func collectSearchFiles( + ctx context.Context, + cwd string, + roots []string, + filter *globFilter, +) (files []string, degraded bool) { + seen := map[string]bool{} + add := func(relative string) { + relative = path.Clean(filepath.ToSlash(relative)) + if relative == "." || relative == "" || relative == "/" || seen[relative] { + return + } + // .git is skipped unconditionally, not just via the caller's + // `--glob=!.git/*`: leaking object files into a code search is never + // what the caller wanted, whatever globs they passed. + if hasGitSegment(relative) || !filter.match(relative) { + return + } + seen[relative] = true + files = append(files, relative) + } + + for _, root := range roots { + if ctx.Err() != nil { + break + } + cleaned := filepath.Clean(root) + absolute := cleaned + if !filepath.IsAbs(absolute) { + absolute = filepath.Join(cwd, cleaned) + } + info, err := os.Stat(absolute) + if err != nil { + degraded = true + continue + } + if !info.IsDir() { + add(cleaned) + continue + } + listed, ok := gitTrackedFiles(ctx, absolute) + if !ok { + var walkFailed bool + listed, walkFailed = walkRegularFiles(ctx, absolute) + degraded = degraded || walkFailed + } + for _, relative := range listed { + joined := relative + if cleaned != "." { + joined = path.Join(filepath.ToSlash(cleaned), relative) + } + add(joined) + } + } + sort.Strings(files) + return files, degraded +} + +// gitTrackedFiles asks git for the working-tree file list, which is what makes +// the fallback honour .gitignore for free. ok is false whenever dir is not a +// work tree (or git is unavailable), leaving the caller to walk instead. +func gitTrackedFiles(ctx context.Context, dir string) (files []string, ok bool) { + command := exec.CommandContext( + ctx, "git", "-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard", + ) + var stdout bytes.Buffer + command.Stdout = &stdout + command.Stderr = nil + if err := command.Run(); err != nil { + return nil, false + } + for _, entry := range strings.Split(stdout.String(), "\x00") { + if entry == "" { + continue + } + // `--cached` also reports files deleted from the working tree, and it + // reports symlinks that rg would not follow. Lstat screens out both: + // only a regular file that is actually present is searchable. + if info, err := os.Lstat(filepath.Join(dir, filepath.FromSlash(entry))); err != nil || + !info.Mode().IsRegular() { + continue + } + files = append(files, entry) + } + return files, true +} + +// walkRegularFiles is the non-git path. Symlinks are left out to match rg, +// which does not follow them by default, and the walk aborts on cancellation +// so a huge tree cannot outlive the request that asked for it. +func walkRegularFiles(ctx context.Context, dir string) (files []string, failed bool) { + _ = filepath.WalkDir(dir, func(current string, entry fs.DirEntry, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + failed = true + if entry != nil && entry.IsDir() { + return fs.SkipDir + } + return nil + } + if entry.IsDir() { + if entry.Name() == ".git" { + return fs.SkipDir + } + return nil + } + if !entry.Type().IsRegular() { + return nil + } + relative, relErr := filepath.Rel(dir, current) + if relErr != nil { + failed = true + return nil + } + files = append(files, filepath.ToSlash(relative)) + return nil + }) + return files, failed +} + +func hasGitSegment(relative string) bool { + for _, segment := range strings.Split(relative, "/") { + if segment == ".git" { + return true + } + } + return false +} + +// listFilesOutput reproduces `rg --files`: one path per line, exit 1 when +// nothing matched. It never reports exit 2 — glob.go treats anything other +// than 0 or 1 as a hard error. +func listFilesOutput(files []string) ripgrepResult { + if len(files) == 0 { + return ripgrepResult{code: 1} + } + var stdout bytes.Buffer + for _, file := range files { + stdout.WriteString(file) + stdout.WriteByte('\n') + } + return ripgrepResult{stdout: stdout.Bytes(), code: 0} +} + +// grepOutput reproduces `rg --json`, emitting only the "match" events grep.go +// consumes. Exit codes follow rg: 0 matched, 1 matched nothing, 2 finished but +// skipped something unreadable. +func grepOutput( + ctx context.Context, + cwd string, + pattern string, + files []string, + degraded bool, +) (ripgrepResult, error) { + expression, err := regexp.Compile(pattern) + if err != nil { + return ripgrepResult{}, err + } + var stdout bytes.Buffer + encoder := json.NewEncoder(&stdout) + matched := false + for _, relative := range files { + if ctx.Err() != nil { + return ripgrepResult{}, ctx.Err() + } + fileMatched, readable := grepFile( + filepath.Join(cwd, filepath.FromSlash(relative)), relative, expression, encoder, + ) + if !readable { + degraded = true + continue + } + matched = matched || fileMatched + } + code := 1 + switch { + case degraded: + code = 2 + case matched: + code = 0 + } + return ripgrepResult{stdout: stdout.Bytes(), code: code}, nil +} + +func grepFile( + absolute string, + relative string, + expression *regexp.Regexp, + encoder *json.Encoder, +) (matched bool, readable bool) { + file, err := os.Open(absolute) + if err != nil { + return false, false + } + defer func() { _ = file.Close() }() + + reader := bufio.NewReaderSize(file, 64*1024) + if head, _ := reader.Peek(binarySniffBytes); bytes.IndexByte(head, 0) >= 0 { + // Binary: readable, just not searched — same as rg, and not a reason + // to report the run as degraded. + return false, true + } + + for number := 1; ; number++ { + line, readErr := reader.ReadString('\n') + if line == "" && readErr != nil { + return matched, true + } + if expression.MatchString(strings.TrimSuffix(line, "\n")) { + matched = true + event := ripgrepJSONLine{Type: "match"} + event.Data.Path.Text = relative + event.Data.Lines.Text = line + event.Data.LineNumber = number + if encodeErr := encoder.Encode(event); encodeErr != nil { + return matched, true + } + } + if readErr != nil { + return matched, true + } + } +} + +// globFilter applies rg's --glob rules: an exclude wins outright, and when any +// include is present a path must match at least one of them. +type globFilter struct { + includes []compiledGlob + excludes []compiledGlob +} + +type compiledGlob struct { + expression *regexp.Regexp + baseOnly bool +} + +func newGlobFilter(includes, excludes []string) (*globFilter, error) { + filter := &globFilter{} + for _, pattern := range includes { + compiled, err := compileGlob(pattern) + if err != nil { + return nil, err + } + filter.includes = append(filter.includes, compiled) + } + for _, pattern := range excludes { + compiled, err := compileGlob(pattern) + if err != nil { + return nil, err + } + filter.excludes = append(filter.excludes, compiled) + } + return filter, nil +} + +func (f *globFilter) match(relative string) bool { + for _, exclude := range f.excludes { + if exclude.matches(relative) { + return false + } + } + if len(f.includes) == 0 { + return true + } + for _, include := range f.includes { + if include.matches(relative) { + return true + } + } + return false +} + +func (g compiledGlob) matches(relative string) bool { + if g.baseOnly { + return g.expression.MatchString(path.Base(relative)) + } + if g.expression.MatchString(relative) { + return true + } + // A path-bearing pattern also covers everything beneath a directory it + // matches, which is what makes `!.git/*` exclude .git/refs/heads/main and + // not merely .git/config. + for parent := path.Dir(relative); parent != "." && parent != "/" && parent != ""; parent = path.Dir(parent) { + if g.expression.MatchString(parent) { + return true + } + } + return false +} + +// compileGlob translates a gitignore-style glob into a regexp. A pattern with +// no separator matches the basename at any depth ("*.go"); one with a +// separator is anchored at the search root (".git/*"). +func compileGlob(pattern string) (compiledGlob, error) { + trimmed := strings.TrimSuffix(pattern, "/") + expression, err := globToRegexp(trimmed) + if err != nil { + return compiledGlob{}, err + } + return compiledGlob{expression: expression, baseOnly: !strings.Contains(trimmed, "/")}, nil +} + +func globToRegexp(pattern string) (*regexp.Regexp, error) { + runes := []rune(pattern) + var builder strings.Builder + builder.WriteString(`\A`) + for index := 0; index < len(runes); index++ { + switch character := runes[index]; character { + case '*': + if index+1 < len(runes) && runes[index+1] == '*' { + index++ + if index+1 < len(runes) && runes[index+1] == '/' { + index++ + builder.WriteString(`(?:[^/]*/)*`) + continue + } + builder.WriteString(`.*`) + continue + } + builder.WriteString(`[^/]*`) + case '?': + builder.WriteString(`[^/]`) + case '[': + closing := indexRune(runes[index:], ']') + if closing < 0 { + builder.WriteString(regexp.QuoteMeta("[")) + continue + } + builder.WriteString(string(runes[index : index+closing+1])) + index += closing + default: + builder.WriteString(regexp.QuoteMeta(string(character))) + } + } + builder.WriteString(`\z`) + return regexp.Compile(builder.String()) +} + +func indexRune(runes []rune, target rune) int { + for index, current := range runes { + if current == target { + return index + } + } + return -1 +} diff --git a/internal/seniordev/tool/ripgrep_fallback_equivalence_test.go b/internal/seniordev/tool/ripgrep_fallback_equivalence_test.go new file mode 100644 index 000000000..571d0546b --- /dev/null +++ b/internal/seniordev/tool/ripgrep_fallback_equivalence_test.go @@ -0,0 +1,176 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" +) + +// The fallback only earns its keep if it answers the way ripgrep does. These +// tests run the same invocation through both runners against one real tree and +// compare the answers, so a divergence shows up here rather than as the agent +// quietly reading the wrong files. They skip when rg is absent — which is the +// very situation the fallback exists for. + +func fallbackFixture(t *testing.T) string { + t.Helper() + workDir := t.TempDir() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + } { + command := exec.Command("git", append([]string{"-C", workDir}, args...)...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, output) + } + } + for _, directory := range []string{"src", filepath.Join("src", "inner"), "build"} { + if err := os.MkdirAll(filepath.Join(workDir, directory), 0o755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + ".gitignore": "build/\n*.log\n", + "top.go": "package top\n// needle at top\n", + ".hidden.go": "package hidden\n// needle hidden\n", + "notes.txt": "needle in text\n", + "debug.log": "needle in an ignored log\n", + filepath.Join("src", "a.go"): "package a\nfunc A() {} // needle\n", + filepath.Join("src", "inner", "b.go"): "package b\n// needle deeper\n", + filepath.Join("build", "generated.go"): "package generated\n// needle generated\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(workDir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + // rg does not follow symlinks by default. Including one keeps the comparison + // honest about that; a platform without symlink support just + // exercises one dimension less. + if err := os.Symlink( + filepath.Join(workDir, "top.go"), filepath.Join(workDir, "link.go"), + ); err != nil { + t.Logf("symlink unsupported on this platform: %v", err) + } + command := exec.Command("git", "-C", workDir, "add", "-A") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git add: %v (%s)", err, output) + } + return workDir +} + +func requireRipgrep(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("rg"); err != nil { + t.Skip("rg not on PATH") + } +} + +// globPaths runs a --files invocation through a runner and returns the sorted +// path set, which is what glob.go consumes. +func globPaths(t *testing.T, runner ripgrepRunner, workDir string, args []string) []string { + t.Helper() + result, err := runner.Run(context.Background(), workDir, args) + if err != nil { + t.Fatalf("%T: %v", runner, err) + } + if result.code != 0 && result.code != 1 { + t.Fatalf("%T: unexpected code %d (%s)", runner, result.code, result.stderr) + } + paths := []string{} + for _, line := range strings.Split(string(result.stdout), "\n") { + if line == "" { + continue + } + paths = append(paths, cleanRipgrepPath(line)) + } + sort.Strings(paths) + return paths +} + +// grepHits runs a --json invocation and returns sorted "path:line:text" keys. +func grepHits(t *testing.T, runner ripgrepRunner, workDir string, args []string) []string { + t.Helper() + result, err := runner.Run(context.Background(), workDir, args) + if err != nil { + t.Fatalf("%T: %v", runner, err) + } + if result.code != 0 && result.code != 1 && result.code != 2 { + t.Fatalf("%T: unexpected code %d (%s)", runner, result.code, result.stderr) + } + hits := []string{} + for _, line := range strings.Split(string(result.stdout), "\n") { + if line == "" { + continue + } + var event ripgrepJSONLine + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatalf("%T: invalid event %q", runner, line) + } + if event.Type != "match" { + continue + } + hits = append(hits, cleanRipgrepPath(event.Data.Path.Text)+ + ":"+itoa(event.Data.LineNumber)+ + ":"+strings.TrimSuffix(event.Data.Lines.Text, "\n")) + } + sort.Strings(hits) + return hits +} + +func TestFallbackGlobMatchesRipgrep(t *testing.T) { + requireRipgrep(t) + workDir := fallbackFixture(t) + + for _, pattern := range []string{"*.go", "**/*.go", "src/*.go", "*.txt", "*.rs"} { + t.Run(pattern, func(t *testing.T) { + args := []string{ + "--no-config", "--files", "--glob=!.git/*", "--hidden", "--glob=" + pattern, ".", + } + want := globPaths(t, execRipgrepRunner{}, workDir, args) + got := globPaths(t, builtinSearchRunner{}, workDir, args) + if strings.Join(want, "|") != strings.Join(got, "|") { + t.Fatalf("glob %q\n rg: %v\nfallback: %v", pattern, want, got) + } + }) + } +} + +func TestFallbackGrepMatchesRipgrep(t *testing.T) { + requireRipgrep(t) + workDir := fallbackFixture(t) + + cases := []struct { + name string + pattern string + include string + }{ + {name: "literal", pattern: "needle"}, + {name: "anchored", pattern: "^package"}, + {name: "charclass", pattern: "func [A-Z]"}, + {name: "include-go", pattern: "needle", include: "*.go"}, + {name: "no-match", pattern: "absolutely-not-present"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + args := []string{"--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"} + if testCase.include != "" { + args = append(args, "--glob="+testCase.include) + } + args = append(args, "--", testCase.pattern, ".") + want := grepHits(t, execRipgrepRunner{}, workDir, args) + got := grepHits(t, builtinSearchRunner{}, workDir, args) + if strings.Join(want, "|") != strings.Join(got, "|") { + t.Fatalf("grep %q\n rg: %v\nfallback: %v", testCase.pattern, want, got) + } + }) + } +} diff --git a/internal/seniordev/tool/ripgrep_fallback_test.go b/internal/seniordev/tool/ripgrep_fallback_test.go new file mode 100644 index 000000000..cbf9ea824 --- /dev/null +++ b/internal/seniordev/tool/ripgrep_fallback_test.go @@ -0,0 +1,307 @@ +//go:build !windows + +package tool + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// fallbackRegistry builds a Registry pinned to the in-process searcher, so +// these tests exercise the no-ripgrep path regardless of what is on PATH. +func fallbackRegistry(t *testing.T, workDir string) *Registry { + t.Helper() + registry := New(workDir) + registry.rg = builtinSearchRunner{} + return registry +} + +func initTestRepo(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + } { + command := exec.Command("git", append([]string{"-C", dir}, args...)...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, output) + } + } +} + +// Contract 1: rg stays authoritative when it is installed, and only a genuine +// absence selects the fallback. +func TestPickRipgrepRunnerPrefersRealRipgrep(t *testing.T) { + stub := t.TempDir() + name := "rg" + if os.PathListSeparator == ';' { + name = "rg.exe" + } + if err := os.WriteFile(filepath.Join(stub, name), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", stub) + if _, ok := pickRipgrepRunner().(execRipgrepRunner); !ok { + t.Fatalf("rg on PATH must select the real runner, got %T", pickRipgrepRunner()) + } + + t.Setenv("PATH", t.TempDir()) + if _, ok := pickRipgrepRunner().(builtinSearchRunner); !ok { + t.Fatalf("rg absent must select the fallback, got %T", pickRipgrepRunner()) + } +} + +// Contract 2: glob still finds files, hidden ones included, non-matching +// extensions excluded. +func TestFallbackGlobFindsHiddenAndFiltersByPattern(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "visible.go", "package visible") + writeTestFile(t, workDir, ".hidden.go", "package hidden") + writeTestFile(t, workDir, "ignored.txt", "text") + + result, err := execute(t, fallbackRegistry(t, workDir), "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for _, name := range []string{"visible.go", ".hidden.go"} { + if !strings.Contains(result.Output, filepath.Join(workDir, name)) { + t.Fatalf("%s missing from %q", name, result.Output) + } + } + if strings.Contains(result.Output, "ignored.txt") { + t.Fatalf("non-matching file leaked into %q", result.Output) + } +} + +// Contract 2 (cont.): a pattern with a separator is anchored at the root and +// matches nested paths rather than bare basenames. +func TestFallbackGlobNestedPattern(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "src", "inner"), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, filepath.Join("src", "a.go"), "package a") + writeTestFile(t, workDir, filepath.Join("src", "inner", "b.go"), "package b") + writeTestFile(t, workDir, "top.go", "package top") + + result, err := execute(t, fallbackRegistry(t, workDir), "glob", map[string]any{"pattern": "src/**/*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for _, name := range []string{filepath.Join("src", "a.go"), filepath.Join("src", "inner", "b.go")} { + if !strings.Contains(result.Output, filepath.Join(workDir, name)) { + t.Fatalf("%s missing from %q", name, result.Output) + } + } + if strings.Contains(result.Output, filepath.Join(workDir, "top.go")) { + t.Fatalf("unanchored match leaked into %q", result.Output) + } +} + +// Contract 3: grep reports path, 1-based line number and the line text. +func TestFallbackGrepReportsPathLineAndText(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.go", "first\nneedle here\nthird\n") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "Found 1 matches") { + t.Fatalf("missing count in %q", result.Output) + } + if !strings.Contains(result.Output, filepath.Join(workDir, "one.go")+":") { + t.Fatalf("missing path in %q", result.Output) + } + if !strings.Contains(result.Output, " Line 2: needle here") { + t.Fatalf("missing line 2 in %q", result.Output) + } +} + +// Contract 4: .git contents are never searched, whatever the pattern. +func TestFallbackSkipsGitDirectory(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, ".git", "refs", "heads"), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, filepath.Join(".git", "config"), "needle") + writeTestFile(t, workDir, filepath.Join(".git", "refs", "heads", "main"), "needle") + writeTestFile(t, workDir, "kept.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.Contains(result.Output, ".git") { + t.Fatalf(".git leaked into %q", result.Output) + } + if !strings.Contains(result.Output, "kept.txt") { + t.Fatalf("kept.txt missing from %q", result.Output) + } +} + +// Contract 5: inside a work tree .gitignore is honoured, and untracked files +// that are not ignored are still searched. +func TestFallbackHonoursGitignoreButKeepsUntracked(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + workDir := t.TempDir() + initTestRepo(t, workDir) + writeTestFile(t, workDir, ".gitignore", "ignored.go\n") + writeTestFile(t, workDir, "ignored.go", "needle") + writeTestFile(t, workDir, "untracked.go", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.Contains(result.Output, "ignored.go") { + t.Fatalf("gitignored file searched: %q", result.Output) + } + if !strings.Contains(result.Output, "untracked.go") { + t.Fatalf("untracked file missing from %q", result.Output) + } +} + +// Contract 6: the include filter narrows grep to matching files. +func TestFallbackGrepHonoursInclude(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "code.go", "needle") + writeTestFile(t, workDir, "notes.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{ + "pattern": "needle", "include": "*.go", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "code.go") || strings.Contains(result.Output, "notes.txt") { + t.Fatalf("include not applied: %q", result.Output) + } +} + +// Contract 6 (cont.): a path pointing at one file searches only that file. +func TestFallbackGrepSingleFilePath(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.txt", "needle") + writeTestFile(t, workDir, "two.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{ + "pattern": "needle", "path": "one.txt", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "one.txt") || strings.Contains(result.Output, "two.txt") { + t.Fatalf("path not honoured: %q", result.Output) + } +} + +// Contract 7: an empty result is "No files found", not an error, for both tools. +func TestFallbackEmptyResults(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.go", "content") + registry := fallbackRegistry(t, workDir) + + grepResult, err := execute(t, registry, "grep", map[string]any{"pattern": "absent"}) + if err != nil { + t.Fatalf("grep: %v", err) + } + if grepResult.Output != "No files found" { + t.Fatalf("grep output = %q", grepResult.Output) + } + globResult, err := execute(t, registry, "glob", map[string]any{"pattern": "*.rs"}) + if err != nil { + t.Fatalf("glob: %v", err) + } + if globResult.Output != "No files found" { + t.Fatalf("glob output = %q", globResult.Output) + } +} + +// Contract 8: binary files are skipped rather than dumped into the output. +func TestFallbackSkipsBinaryFiles(t *testing.T) { + workDir := t.TempDir() + if err := os.WriteFile( + filepath.Join(workDir, "blob.bin"), []byte("needle\x00needle"), 0o644, + ); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, "text.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.Contains(result.Output, "blob.bin") { + t.Fatalf("binary file searched: %q", result.Output) + } + if !strings.Contains(result.Output, "text.txt") { + t.Fatalf("text file missing from %q", result.Output) + } +} + +// Contract 9: an invalid pattern surfaces as an error, not a panic. +func TestFallbackInvalidPatternErrors(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.go", "content") + + if _, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{ + "pattern": "([unclosed", + }); err == nil { + t.Fatal("invalid pattern must error") + } +} + +// Contract 10: the fallback plugs in below the shared caps, so the 100-result +// limit and its notice still apply. +func TestFallbackGlobTruncatesAtLimit(t *testing.T) { + workDir := t.TempDir() + for index := 0; index < globResultLimit+10; index++ { + writeTestFile(t, workDir, "file"+itoa(index)+".go", "package p") + } + + result, err := execute(t, fallbackRegistry(t, workDir), "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "Results are truncated") { + t.Fatalf("missing truncation notice in %q", result.Output) + } + if string(result.Metadata) != `{"count":100,"truncated":true}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +// The exit-code contract the two callers depend on: glob rejects anything but +// 0/1, so an unreadable root must never surface as rg's exit 2 there. +func TestFallbackMissingRootExitCodes(t *testing.T) { + workDir := t.TempDir() + runner := builtinSearchRunner{} + + globResult, err := runner.Run(t.Context(), workDir, []string{ + "--no-config", "--files", "--glob=!.git/*", "--hidden", "--glob=*.go", "absent", + }) + if err != nil { + t.Fatalf("glob run: %v", err) + } + if globResult.code != 1 { + t.Fatalf("glob code = %d, want 1", globResult.code) + } + + grepResult, err := runner.Run(t.Context(), workDir, []string{ + "--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages", "--", "needle", "absent", + }) + if err != nil { + t.Fatalf("grep run: %v", err) + } + if grepResult.code != 2 { + t.Fatalf("grep code = %d, want 2", grepResult.code) + } +} diff --git a/internal/seniordev/tool/settings.go b/internal/seniordev/tool/settings.go new file mode 100644 index 000000000..8e5b23b9b --- /dev/null +++ b/internal/seniordev/tool/settings.go @@ -0,0 +1,148 @@ +//go:build !windows + +package tool + +import ( + "context" + "os" + "os/exec" + "sort" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/config" + formatpkg "github.com/Agent-Field/codeaf/internal/seniordev/format" +) + +type formatterServices struct { + mu sync.Mutex + services map[string]*formatpkg.Service +} + +func newFormatterServices() *formatterServices { + return &formatterServices{services: map[string]*formatpkg.Service{}} +} + +func (r *Registry) settings() (config.Info, error) { + worktree := r.workDir + if r.instance != nil && r.instance.Worktree != "" { + worktree = r.instance.Worktree + } + return r.config.Get(r.workDir, worktree) +} + +func (r *Registry) formatterService() (*formatpkg.Service, error) { + worktree := r.workDir + if r.instance != nil && r.instance.Worktree != "" { + worktree = r.instance.Worktree + } + key := r.workDir + "\x00" + worktree + r.formatters.mu.Lock() + defer r.formatters.mu.Unlock() + if service := r.formatters.services[key]; service != nil { + return service, nil + } + settings, err := r.settings() + if err != nil { + return nil, err + } + configuration := formatterConfiguration(settings["formatter"]) + service := formatpkg.NewService( + formatpkg.Context{Directory: r.workDir, Worktree: worktree}, + configuration, + formatpkg.Dependencies{ + Which: func(command string) (string, bool) { + match, err := exec.LookPath(command) + return match, err == nil + }, + NpmWhich: func(ctx context.Context, name string) (string, bool) { + return r.npm.Which(ctx, name) + }, + ExperimentalOxfmt: config.ParseBoolean( + config.Truthy, + environmentValue("SENIOR_DEV_EXPERIMENTAL_OXFMT"), + ), + }, + nil, + ) + r.formatters.services[key] = service + return service, nil +} + +func environmentValue(name string) *string { + value, ok := os.LookupEnv(name) + if !ok { + return nil + } + return &value +} + +func formatterConfiguration(value any) formatpkg.Configuration { + configuration := formatpkg.Configuration{} + switch value := value.(type) { + case bool: + configuration.Enabled = value + case map[string]any: + configuration.Enabled = true + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + entry, _ := value[key].(map[string]any) + override := formatpkg.FormatterOverride{Key: key} + override.Disabled, _ = entry["disabled"].(bool) + override.Extensions = stringSliceSetting(entry["extensions"]) + override.Command = stringSliceSetting(entry["command"]) + if environment, ok := entry["environment"].(map[string]any); ok { + override.Environment = make(map[string]string, len(environment)) + for name, raw := range environment { + if item, ok := raw.(string); ok { + override.Environment[name] = item + } + } + } + configuration.Overrides = append(configuration.Overrides, override) + } + } + return configuration +} + +func stringSliceSetting(value any) *[]string { + raw, ok := value.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, value := range raw { + if item, ok := value.(string); ok { + out = append(out, item) + } + } + return &out +} + +func formatMutationFile(ctx context.Context, service *formatpkg.Service, path string, bom bool) (string, error) { + formatted, err := service.File(ctx, path) + if err != nil { + return "", err + } + if !formatted { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + _, content := splitBOM(strings.ToValidUTF8(string(data), "\uFFFD")) + return content, nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + _, content := splitBOM(strings.ToValidUTF8(string(data), "\uFFFD")) + if err := os.WriteFile(path, []byte(joinBOM(content, bom)), 0o644); err != nil { + return "", err + } + return content, nil +} diff --git a/internal/seniordev/tool/shell_env_signal.go b/internal/seniordev/tool/shell_env_signal.go new file mode 100644 index 000000000..c32caccb7 --- /dev/null +++ b/internal/seniordev/tool/shell_env_signal.go @@ -0,0 +1,158 @@ +//go:build !windows + +package tool + +import ( + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/loopguard" +) + +const ( + shellStallWindow = 30 * time.Second + shellHeavyWindow = 30 * time.Second +) + +type shellDeathInput struct { + ExitCode *int + Expired bool + Aborted bool + OOMDelta *int64 + MemoryLimitBytes *int64 + SinceLastOutput *time.Duration + Timeout time.Duration + CommandDuration time.Duration +} + +func readCgroupInt(path, key string) *int64 { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + text := strings.TrimSpace(string(data)) + if key == "" { + if text == "" || text == "max" { + return nil + } + value, err := strconv.ParseInt(text, 10, 64) + if err != nil || value <= 0 { + return nil + } + return &value + } + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) != 2 || fields[0] != key { + continue + } + value, err := strconv.ParseInt(fields[1], 10, 64) + if err == nil { + return &value + } + } + return nil +} + +func readShellOOMCount() *int64 { + return readCgroupInt("/sys/fs/cgroup/memory.events", "oom_kill") +} + +func readShellMemoryLimit() *int64 { + return readCgroupInt("/sys/fs/cgroup/memory.max", "") +} + +func shellGB(bytes *int64) string { + if bytes == nil || *bytes <= 0 { + return "unknown" + } + return strconv.FormatFloat(float64(*bytes)/(1024*1024*1024), 'f', 1, 64) + "GB" +} + +func shellSeconds(duration time.Duration) string { + return strconv.FormatInt(int64(duration.Round(time.Second)/time.Second), 10) + "s" +} + +func classifyShellDeath(input shellDeathInput) string { + if input.Aborted { + return "" + } + if input.OOMDelta != nil && *input.OOMDelta > 0 { + return "[environment-signal] " + strconv.FormatInt(*input.OOMDelta, 10) + + " process(es) were OOM-killed by the kernel during this command (cgroup memory limit ≈ " + + shellGB(input.MemoryLimitBytes) + "). This is an environment resource limit, not a code bug. " + + "Do not rerun the same command unchanged — reduce its memory footprint (fewer parallel workers, " + + "narrower scope) or verify with a cheaper command (e.g. a targeted test instead of a full build)." + } + if input.ExitCode != nil && *input.ExitCode == 137 && (input.OOMDelta == nil || *input.OOMDelta == 0) { + return "[environment-signal] this command was killed by the system (SIGKILL / exit 137), likely memory pressure " + + "or an external kill rather than a code bug. Do not blindly retry the same command — reduce its memory " + + "footprint (fewer parallel workers, narrower scope) or verify with a cheaper command." + } + if input.Expired && input.SinceLastOutput != nil && *input.SinceLastOutput > shellStallWindow { + return "[environment-signal] this command timed out after " + shellSeconds(input.Timeout) + + " AND produced no output for the final " + shellSeconds(*input.SinceLastOutput) + + " — it was stalled (hung, waiting on I/O, or resource-starved), so a larger timeout alone is unlikely " + + "to help. Diagnose the hang or run a cheaper check instead of rerunning." + } + if input.Expired { + return "[environment-signal] this command timed out while still producing output — it may simply need more time; " + + "raise timeout only if this command is genuinely required, otherwise prefer a cheaper verification." + } + if input.ExitCode != nil && *input.ExitCode == 143 { + return "[environment-signal] this command was terminated by SIGTERM (exit 143) — an external stop signal rather " + + "than a normal exit. Check whether an orchestrator or timeout ended it before assuming a code failure." + } + return "" +} + +var shellRepeatGuards = struct { + sync.Mutex + bySession map[string]loopguard.LoopGuard +}{bySession: map[string]loopguard.LoopGuard{}} + +var shellCommandPlumbing = []*regexp.Regexp{ + regexp.MustCompile(`\s*2>&1\s*$`), + regexp.MustCompile(`\s*\|\s*tail\b[^|]*$`), + regexp.MustCompile(`\s*\|\s*head\b[^|]*$`), +} +var shellCommandWhitespace = regexp.MustCompile(`\s+`) + +func normalizeShellCommand(command string) string { + out := strings.TrimSpace(command) + for { + before := out + for _, pattern := range shellCommandPlumbing { + out = pattern.ReplaceAllString(out, "") + } + out = strings.TrimRight(out, " \t\r\n") + if out == before { + break + } + } + return strings.TrimSpace(shellCommandWhitespace.ReplaceAllString(out, " ")) +} + +func registerShellOutcome(sessionID, command string, duration time.Duration, failed bool) string { + if duration <= shellHeavyWindow && !failed { + return "" + } + shellRepeatGuards.Lock() + guard := shellRepeatGuards.bySession[sessionID] + if guard == nil { + cap := float64(3) + guard = loopguard.CreateLoopGuard(loopguard.LoopGuardOptions{RepeatCap: &cap}) + shellRepeatGuards.bySession[sessionID] = guard + } + verdict := guard.Observe(loopguard.LoopAction{Tool: "shell", ArgsKey: normalizeShellCommand(command)}) + shellRepeatGuards.Unlock() + if verdict.Status == loopguard.LoopStatusWarn || verdict.Status == loopguard.LoopStatusStop { + return "[environment-signal] this is a repeated attempt of a failing/expensive command — repeating it unchanged " + + "is unlikely to succeed; change the approach (narrower test, different diagnosis) instead." + } + return "" +} diff --git a/internal/seniordev/tool/shell_feedback_test.go b/internal/seniordev/tool/shell_feedback_test.go new file mode 100644 index 000000000..d65f28ec7 --- /dev/null +++ b/internal/seniordev/tool/shell_feedback_test.go @@ -0,0 +1,229 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func TestShellSessionBuildCachesAreIsolated(t *testing.T) { + // Concurrent leaves receive distinct build-cache namespaces. + for _, name := range []string{ + "CARGO_TARGET_DIR", "GOCACHE", "GOMODCACHE", "npm_config_cache", "PIP_CACHE_DIR", + } { + unsetEnvironmentForTest(t, name) + } + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + first := environmentMap(shellEnvironment("ses_first")) + second := environmentMap(shellEnvironment("ses_second")) + for name, suffix := range map[string]string{ + "CARGO_TARGET_DIR": "cargo", "GOCACHE": "go-build", + "npm_config_cache": "npm", "PIP_CACHE_DIR": "pip", + } { + wantFirst := filepath.Join(root, "ses_first", suffix) + wantSecond := filepath.Join(root, "ses_second", suffix) + if first[name] != wantFirst || second[name] != wantSecond || first[name] == second[name] { + t.Fatalf("%s paths = %q, %q", name, first[name], second[name]) + } + } +} + +func TestShellScratchLeavesGOMODCACHEAlone(t *testing.T) { + // GOMODCACHE is a source of truth, not a derived cache: the module sources + // live in it, and an offline environment may have pre-populated it. + // Redirecting it to an empty per-session dir while the network is + // blackholed leaves Go unable to build. It must be inherited, never + // rewritten. + for _, name := range []string{"GOMODCACHE", "GOCACHE"} { + unsetEnvironmentForTest(t, name) + } + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + environment := environmentMap(shellEnvironment("ses_gomod")) + if value, set := environment["GOMODCACHE"]; set { + t.Fatalf("GOMODCACHE was redirected to %q; it must be inherited untouched", value) + } + // The derived cache next to it still is redirected, proving the isolation + // mechanism is intact and only the source-of-truth entry was removed. + if want := filepath.Join(root, "ses_gomod", "go-build"); environment["GOCACHE"] != want { + t.Fatalf("GOCACHE = %q, want %q", environment["GOCACHE"], want) + } +} + +func TestShellScratchHonoursInheritedGOMODCACHE(t *testing.T) { + // A GOMODCACHE the operator set must survive untouched. + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + t.Setenv("GOMODCACHE", "/root/go/pkg/mod") + if got := environmentMap(shellEnvironment("ses_inherit"))["GOMODCACHE"]; got != "/root/go/pkg/mod" { + t.Fatalf("GOMODCACHE = %q, want the inherited /root/go/pkg/mod", got) + } +} + +func TestShellScratchTeardownAtLeafEndContract(t *testing.T) { + // A completed leaf reclaims its private caches. + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + _ = shellEnvironment("ses_finished") + cache := filepath.Join(root, "ses_finished", "go-build") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + TeardownShellScratch("ses_finished") + if _, err := os.Stat(filepath.Join(root, "ses_finished")); !os.IsNotExist(err) { + t.Fatalf("completed leaf scratch remains: %v", err) + } +} + +func TestShellScratchTeardownWaitsForLastSessionUserContract(t *testing.T) { + // Two concurrent leaves sharing a claimed session cannot delete one + // another's live build caches. + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + firstRelease := AcquireShellScratch("ses_shared") + secondRelease := AcquireShellScratch("ses_shared") + cache := filepath.Join(root, "ses_shared", "go-build") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + firstRelease() + if _, err := os.Stat(cache); err != nil { + t.Fatalf("first user removed shared scratch: %v", err) + } + secondRelease() + if _, err := os.Stat(filepath.Join(root, "ses_shared")); !os.IsNotExist(err) { + t.Fatalf("last user did not remove shared scratch: %v", err) + } +} + +func TestBashWorkdirRunsThereAndRejectsEscapes(t *testing.T) { + // workdir runs inside the workspace and rejects escapes. + workDir := t.TempDir() + nested := filepath.Join(workDir, "nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + registry := New(workDir) + result, err := execute(t, registry, "bash", map[string]any{"command": "pwd", "workdir": "nested"}) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(result.Output) != nested { + t.Fatalf("pwd output = %q, want %q", result.Output, nested) + } + _, err = execute(t, registry, "bash", map[string]any{"command": "pwd", "workdir": "../outside"}) + if err == nil || err.Error() != "path escapes workspace: ../outside" { + t.Fatalf("escape error = %v", err) + } +} + +func TestBashHonorsConfiguredShell(t *testing.T) { + // Shell execution honors the configured acceptable shell. + workDir := t.TempDir() + shell := filepath.Join(workDir, "configured-sh") + if err := os.WriteFile(shell, []byte("#!/bin/sh\nprintf 'configured-shell\\n'\nexec /bin/sh \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + settings, _ := json.Marshal(map[string]any{"shell": shell}) + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", string(settings)) + result, err := execute(t, New(workDir), "bash", map[string]any{"command": "printf command-body"}) + if err != nil { + t.Fatal(err) + } + if result.Output != "configured-shell\ncommand-body" { + t.Fatalf("output = %q", result.Output) + } +} + +func TestBashReportsOOMKill(t *testing.T) { + // A cgroup OOM kill is diagnosed instead of appearing as a plain exit status. + previousOOM := bashReadOOMCount + previousLimit := bashReadMemoryLimit + t.Cleanup(func() { + bashReadOOMCount = previousOOM + bashReadMemoryLimit = previousLimit + }) + reads := 0 + bashReadOOMCount = func() *int64 { + reads++ + value := int64(8) + if reads > 1 { + value = 9 + } + return &value + } + bashReadMemoryLimit = func() *int64 { + value := int64(2 * 1024 * 1024 * 1024) + return &value + } + t.Setenv("SENIOR_DEV_ENV_SIGNALS", "1") + input := json.RawMessage(`{"command":"exit 137"}`) + result, err := New(t.TempDir()).Execute(context.Background(), steploop.ToolCall{ + ID: "call_oom", Name: "bash", Input: input, SessionID: "ses_oom", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Output, "were OOM-killed by the kernel") || + !strings.Contains(result.Output, "environment resource limit, not a code bug") { + t.Fatalf("output = %q", result.Output) + } +} + +func TestShellClassifiesStallAndRepeatedFailure(t *testing.T) { + // Quiet timeouts and repeated heavy/failing commands are actionable. + exit := 1 + quiet := 45 * time.Second + stall := classifyShellDeath(shellDeathInput{ + ExitCode: &exit, Expired: true, SinceLastOutput: &quiet, Timeout: time.Minute, + }) + if !strings.Contains(stall, "it was stalled") || !strings.Contains(stall, "larger timeout alone") { + t.Fatalf("stall diagnostic = %q", stall) + } + session := "ses_repeat_test" + for attempt := 1; attempt <= 3; attempt++ { + warning := registerShellOutcome(session, "go test ./... 2>&1 | tail -20", time.Second, true) + if attempt < 3 && warning != "" { + t.Fatalf("attempt %d warned early: %q", attempt, warning) + } + if attempt == 3 && !strings.Contains(warning, "repeated attempt") { + t.Fatalf("third attempt warning = %q", warning) + } + } +} + +func environmentMap(values []string) map[string]string { + out := make(map[string]string, len(values)) + for _, value := range values { + name, item, _ := strings.Cut(value, "=") + out[name] = item + } + return out +} + +func unsetEnvironmentForTest(t *testing.T, name string) { + t.Helper() + previous, existed := os.LookupEnv(name) + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if existed { + _ = os.Setenv(name, previous) + } else { + _ = os.Unsetenv(name) + } + }) +} diff --git a/internal/seniordev/tool/shell_scan.go b/internal/seniordev/tool/shell_scan.go new file mode 100644 index 000000000..ed5fae229 --- /dev/null +++ b/internal/seniordev/tool/shell_scan.go @@ -0,0 +1,547 @@ +//go:build !windows + +// The deterministic shell core shared by executors: shell identification and +// the permission scan that extracts paths and command patterns from a command +// line. Process execution lives in bash.go. +package tool + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// shellMeta lists the shells that are refused as tool shells and the ones +// that use PowerShell syntax. Any shell absent from it is accepted and +// scanned as POSIX. +var shellMeta = map[string]struct { + deny bool + ps bool +}{ + "fish": {deny: true}, + "nu": {deny: true}, + "powershell": {ps: true}, + "pwsh": {ps: true}, +} + +// ShellName returns the lowercase executable basename, without its extension +// on Windows. +func ShellName(file string) string { + base := filepath.Base(file) + if runtime.GOOS == "windows" { + base = strings.TrimSuffix(base, filepath.Ext(base)) + } + return strings.ToLower(base) +} + +// ShellPowerShell reports whether a shell uses PowerShell syntax. +func ShellPowerShell(file string) bool { return shellMeta[ShellName(file)].ps } + +// ShellAcceptable reports whether a shell may run tool commands; fish and nu +// are refused. +func ShellAcceptable(file string) bool { return !shellMeta[ShellName(file)].deny } + +// shellKind classifies a shell for permission scanning as bash, pwsh, +// powershell or cmd; anything else is scanned as bash. +func shellKind(file string) string { + switch name := ShellName(file); name { + case "bash", "pwsh", "powershell", "cmd": + return name + default: + return "bash" + } +} + +// ShellPermissionScan is the ordered permission material gathered from a +// parsed shell command. +type ShellPermissionScan struct { + Dirs []string `json:"dirs"` + Patterns []string `json:"patterns"` + Always []string `json:"always"` +} + +// ShellScanOptions supplies the path context used by the permission scanner. +type ShellScanOptions struct { + CWD string + Shell string + Workspace string + Home string + Env map[string]string + IsDir func(string) bool +} + +var shellCWDCommands = stringSet("cd", "chdir", "popd", "pushd", "push-location", "set-location") +var shellFileCommands = stringSet( + "cd", "chdir", "popd", "pushd", "push-location", "set-location", + "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat", + "get-content", "set-content", "add-content", "copy-item", "move-item", + "remove-item", "new-item", "rename-item", +) +var cmdFileCommands = stringSet( + "copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", + "rename", "rmdir", "type", +) + +// ScanShellPermissions extracts the permission material from a command line: +// directories it touches outside the workspace, the simple-command patterns, +// and their arity prefixes. It keeps shell source strings and token boundaries +// intact and skips dynamic path expressions rather than guess at them. +func ScanShellPermissions(command string, opts ShellScanOptions) ShellPermissionScan { + if opts.Home == "" { + opts.Home, _ = os.UserHomeDir() + } + if opts.CWD == "" { + opts.CWD = "." + } + ps := ShellPowerShell(opts.Shell) + kind := shellKind(opts.Shell) + dirs := newOrderedStrings() + patterns := newOrderedStrings() + always := newOrderedStrings() + + var process func(string) + process = func(simple string) { + tokens := shellWords(simple) + if len(tokens) == 0 { + return + } + cmd := tokens[0] + if ps || kind == "cmd" { + cmd = strings.ToLower(cmd) + } + + if shellFileCommands[cmd] || (kind == "cmd" && cmdFileCommands[cmd]) { + for _, arg := range shellPathArgs(tokens, kind == "cmd") { + file := shellArgPath(arg, opts, ps) + if file == "" || pathWithin(file, opts.Workspace) { + continue + } + dir := filepath.Dir(file) + if opts.IsDir != nil && opts.IsDir(file) { + dir = file + } + dirs.Add(dir) + } + } + + if !shellCWDCommands[cmd] { + patterns.Add(strings.TrimSpace(simple)) + always.Add(strings.Join(shellArityPrefix(tokens), " ") + " *") + } + for _, nested := range shellSubstitutions(simple) { + for _, command := range splitShellCommands(nested) { + process(command) + } + } + } + for _, simple := range splitShellCommands(command) { + process(simple) + } + return ShellPermissionScan{Dirs: dirs.Values(), Patterns: patterns.Values(), Always: always.Values()} +} + +func shellSubstitutions(text string) []string { + var out []string + for start := 0; start+1 < len(text); { + rel := strings.Index(text[start:], "$(") + if rel < 0 { + break + } + open := start + rel + 1 + depth := 1 + quote := byte(0) + escaped := false + end := open + 1 + for ; end < len(text); end++ { + c := text[end] + if escaped { + escaped = false + continue + } + if c == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if c == quote { + quote = 0 + } + continue + } + if c == '\'' || c == '"' { + quote = c + continue + } + if c == '(' { + depth++ + } else if c == ')' { + depth-- + if depth == 0 { + break + } + } + } + if depth != 0 { + break + } + out = append(out, text[open+1:end]) + start = end + 1 + } + return out +} + +func shellPathArgs(tokens []string, cmd bool) []string { + out := make([]string, 0, len(tokens)-1) + for _, token := range tokens[1:] { + if strings.HasPrefix(token, "-") || (cmd && strings.HasPrefix(token, "/")) || + (tokens[0] == "chmod" && strings.HasPrefix(token, "+")) { + continue + } + out = append(out, token) + } + return out +} + +func shellArgPath(arg string, opts ShellScanOptions, ps bool) string { + text := unquoteShell(arg) + if ps { + text = expandPowerShellPath(text, opts) + } else { + text = expandHome(text, opts.Home) + } + text = globPrefix(text) + if text == "" || dynamicShellPath(text, ps) { + return "" + } + if ps { + text = filesystemProvider(text) + if text == "" { + return "" + } + } + if filepath.IsAbs(text) { + return filepath.Clean(text) + } + return filepath.Clean(filepath.Join(opts.CWD, text)) +} + +func unquoteShell(text string) string { + if len(text) < 2 { + return text + } + if (text[0] == '"' || text[0] == '\'') && text[len(text)-1] == text[0] { + return text[1 : len(text)-1] + } + return text +} + +func expandHome(text, home string) string { + if text == "~" { + return home + } + if strings.HasPrefix(text, "~/") || strings.HasPrefix(text, `~\`) { + return filepath.Join(home, text[2:]) + } + return text +} + +func expandPowerShellPath(text string, opts ShellScanOptions) string { + // PowerShell arguments are not parsed by a grammar here; these are the + // deterministic expansion rules for already-tokenized arguments. + replaceEnv := func(s string) string { + lower := strings.ToLower(s) + for key, value := range opts.Env { + if strings.ToLower(key) == lower { + return value + } + } + return "" + } + for { + lower := strings.ToLower(text) + start := strings.Index(lower, "${env:") + if start < 0 { + break + } + endRel := strings.IndexByte(text[start:], '}') + if endRel < 0 { + break + } + end := start + endRel + text = text[:start] + replaceEnv(text[start+6:end]) + text[end+1:] + } + for _, prefix := range []string{"$env:"} { + for { + lower := strings.ToLower(text) + start := strings.Index(lower, prefix) + if start < 0 { + break + } + end := start + len(prefix) + for end < len(text) && (text[end] == '_' || text[end] >= '0' && text[end] <= '9' || + text[end] >= 'A' && text[end] <= 'Z' || text[end] >= 'a' && text[end] <= 'z') { + end++ + } + text = text[:start] + replaceEnv(text[start+len(prefix):end]) + text[end:] + } + } + autos := map[string]string{"HOME": opts.Home, "PWD": opts.CWD, "PSHOME": filepath.Dir(opts.Shell)} + for key, value := range autos { + for _, spelling := range []string{"$" + key, "$" + strings.ToLower(key)} { + text = strings.ReplaceAll(text, spelling+"/", value+"/") + text = strings.ReplaceAll(text, spelling+`\`, value+`\`) + if text == spelling { + text = value + } + } + } + return expandHome(text, opts.Home) +} + +func filesystemProvider(text string) string { + if i := strings.Index(text, "::"); i > 0 { + if strings.EqualFold(text[:i], "filesystem") { + return text[i+2:] + } + return "" + } + if i := strings.IndexByte(text, ':'); i > 0 { + if i == 1 { + return text + } + return "" + } + return text +} + +func dynamicShellPath(text string, ps bool) bool { + if strings.HasPrefix(text, "(") || strings.HasPrefix(text, "@(") || + strings.Contains(text, "$(") || strings.Contains(text, "${") || + strings.Contains(text, "`") { + return true + } + if ps { + for i := 0; i < len(text); i++ { + if text[i] == '$' && !strings.HasPrefix(strings.ToLower(text[i:]), "$env:") { + return true + } + } + return false + } + return strings.Contains(text, "$") +} + +func globPrefix(text string) string { + for i, r := range text { + if r == '?' || r == '*' || r == '[' { + if i == 0 { + return "" + } + return text[:i] + } + } + return text +} + +func pathWithin(candidate, root string) bool { + if root == "" { + return false + } + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +// splitShellCommands preserves simple-command source while respecting quoted +// separators and nested substitutions. Redirections remain attached to the +// command they belong to. +func splitShellCommands(text string) []string { + var out []string + start := 0 + quote := byte(0) + escaped := false + paren, brace := 0, 0 + for i := 0; i < len(text); i++ { + c := text[i] + if escaped { + escaped = false + continue + } + if c == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if c == quote { + quote = 0 + } + continue + } + if c == '\'' || c == '"' { + quote = c + continue + } + switch c { + case '(': + paren++ + case ')': + if paren > 0 { + paren-- + } + case '{': + brace++ + case '}': + if brace > 0 { + brace-- + } + } + if paren != 0 || brace != 0 { + continue + } + separator := c == '\n' || c == ';' || c == '|' + if c == '&' { + separator = i+1 < len(text) && text[i+1] == '&' + } + if !separator { + continue + } + if part := strings.TrimSpace(text[start:i]); part != "" { + out = append(out, part) + } + if i+1 < len(text) && text[i+1] == c && (c == '|' || c == '&') { + i++ + } + start = i + 1 + } + if part := strings.TrimSpace(text[start:]); part != "" { + out = append(out, part) + } + return out +} + +func shellWords(text string) []string { + var words []string + var b strings.Builder + quote := byte(0) + escaped := false + flush := func() { + if b.Len() > 0 { + words = append(words, b.String()) + b.Reset() + } + } + for i := 0; i < len(text); i++ { + c := text[i] + if escaped { + b.WriteByte(c) + escaped = false + continue + } + if c == '\\' && quote != '\'' { + b.WriteByte(c) + escaped = true + continue + } + if quote != 0 { + b.WriteByte(c) + if c == quote { + quote = 0 + } + continue + } + if c == '\'' || c == '"' { + quote = c + b.WriteByte(c) + continue + } + if c == ' ' || c == '\t' || c == '\r' || c == '\n' { + flush() + continue + } + if c == '>' || c == '<' { + flush() + break + } + b.WriteByte(c) + } + flush() + return words +} + +var oneTokenCommands = stringSet( + "cat", "cd", "chmod", "chown", "cp", "echo", "env", "export", "grep", + "kill", "killall", "ln", "ls", "mkdir", "mv", "ps", "pwd", "rm", + "rmdir", "sleep", "source", "tail", "touch", "unset", "which", +) + +var threeTokenPrefixes = stringSet( + "bun run", "bun x", "cargo add", "cargo run", "consul kv", "docker builder", + "docker compose", "docker container", "docker image", "docker network", + "docker volume", "eksctl create", "ip addr", "ip link", "ip netns", + "ip route", "kind create", "kubectl kustomize", "kubectl rollout", + "mc admin", "npm exec", "npm init", "npm run", "npm view", "openssl req", + "openssl x509", "pnpm dlx", "pnpm exec", "pnpm run", "podman container", + "podman image", "pulumi stack", "terraform workspace", "vault auth", + "vault kv", "yarn dlx", "yarn run", +) + +var twoTokenCommands = stringSet( + "bazel", "brew", "bun", "cargo", "cdk", "cf", "cmake", "composer", + "consul", "crictl", "deno", "docker", "eksctl", "firebase", "flyctl", + "git", "go", "gradle", "helm", "heroku", "hugo", "ip", "kind", + "kubectl", "kustomize", "make", "mc", "minikube", "mongosh", "mysql", + "mvn", "ng", "npm", "nvm", "nx", "openssl", "pip", "pipenv", "pnpm", + "poetry", "podman", "psql", "pulumi", "pyenv", "python", "rake", + "rbenv", "redis-cli", "rustup", "serverless", "skaffold", "sls", "sst", + "swift", "systemctl", "terraform", "tmux", "turbo", "ufw", "vault", + "vercel", "volta", "wp", "yarn", +) + +var threeTokenCommands = stringSet("aws", "az", "doctl", "gcloud", "gh", "sfdx") + +func shellArityPrefix(tokens []string) []string { + if len(tokens) == 0 { + return []string{} + } + arity := 1 + if oneTokenCommands[tokens[0]] { + arity = 1 + } else if threeTokenCommands[tokens[0]] { + arity = 3 + } else if twoTokenCommands[tokens[0]] { + arity = 2 + } + if len(tokens) >= 2 && threeTokenPrefixes[tokens[0]+" "+tokens[1]] { + arity = 3 + } + if arity > len(tokens) { + arity = len(tokens) + } + return append([]string(nil), tokens[:arity]...) +} + +type orderedStrings struct { + seen map[string]bool + list []string +} + +func newOrderedStrings() *orderedStrings { return &orderedStrings{seen: map[string]bool{}} } +func (s *orderedStrings) Add(value string) { + if !s.seen[value] { + s.seen[value] = true + s.list = append(s.list, value) + } +} +func (s *orderedStrings) Values() []string { + out := make([]string, len(s.list)) + copy(out, s.list) + return out +} + +func stringSet(values ...string) map[string]bool { + out := make(map[string]bool, len(values)) + for _, value := range values { + out[value] = true + } + return out +} diff --git a/internal/seniordev/tool/shell_scan_test.go b/internal/seniordev/tool/shell_scan_test.go new file mode 100644 index 000000000..0872f68c8 --- /dev/null +++ b/internal/seniordev/tool/shell_scan_test.go @@ -0,0 +1,50 @@ +//go:build !windows + +package tool + +import ( + "path/filepath" + "reflect" + "testing" +) + +func TestScanShellPermissions(t *testing.T) { + workspace := filepath.Join(string(filepath.Separator), "work", "repo") + scan := ScanShellPermissions( + `git status && cp "inside.txt" /outside/dst; cd ../other; npm run test`, + ShellScanOptions{ + CWD: workspace, + Workspace: workspace, + Shell: "/bin/bash", + Home: "/home/test", + IsDir: func(path string) bool { + return path == "/outside/dst" + }, + }, + ) + if want := []string{"/outside/dst", "/work"}; !reflect.DeepEqual(scan.Dirs, want) { + t.Errorf("Dirs = %#v, want %#v", scan.Dirs, want) + } + if want := []string{"git status", `cp "inside.txt" /outside/dst`, "npm run test"}; !reflect.DeepEqual(scan.Patterns, want) { + t.Errorf("Patterns = %#v, want %#v", scan.Patterns, want) + } + if want := []string{"git status *", "cp *", "npm run test *"}; !reflect.DeepEqual(scan.Always, want) { + t.Errorf("Always = %#v, want %#v", scan.Always, want) + } +} + +func TestShellPathScannerSkipsDynamicAndKeepsGlobPrefix(t *testing.T) { + scan := ScanShellPermissions( + `rm /external/logs/*.txt; cat "$HOME/secret"; touch ~/outside/new`, + ShellScanOptions{ + CWD: "/repo", + Workspace: "/repo", + Shell: "/bin/bash", + Home: "/home/test", + }, + ) + want := []string{"/external", "/home/test/outside"} + if !reflect.DeepEqual(scan.Dirs, want) { + t.Fatalf("Dirs = %#v, want %#v", scan.Dirs, want) + } +} diff --git a/internal/seniordev/tool/shell_scratch.go b/internal/seniordev/tool/shell_scratch.go new file mode 100644 index 000000000..024a84541 --- /dev/null +++ b/internal/seniordev/tool/shell_scratch.go @@ -0,0 +1,247 @@ +//go:build !windows + +package tool + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +const ( + shellScratchMarker = ".senior-dev-scratch.json" + defaultScratchRoot = "/tmp/senior-dev-scratch" + defaultScratchTTLHours = 24 +) + +type shellScratchOwner struct { + PID int `json:"pid"` + Hostname string `json:"hostname"` + StartedAt int64 `json:"startedAt"` +} + +var shellScratchSweep sync.Once + +var shellScratchUsers = struct { + sync.Mutex + counts map[string]int +}{counts: map[string]int{}} + +func scratchRoot() string { + if root := os.Getenv("SENIOR_DEV_SCRATCH_ROOT"); root != "" { + return root + } + return defaultScratchRoot +} + +func scratchDir(sessionID string) string { + return filepath.Join(scratchRoot(), sessionID) +} + +func ensureShellScratch(sessionID string) { + shellScratchSweep.Do(sweepShellScratch) + dir := scratchDir(sessionID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + hostname, _ := os.Hostname() + marker, _ := json.Marshal(shellScratchOwner{ + PID: os.Getpid(), Hostname: hostname, StartedAt: time.Now().UnixMilli(), + }) + file, err := os.OpenFile(filepath.Join(dir, shellScratchMarker), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return + } + _, _ = file.Write(marker) + _ = file.Close() +} + +// AcquireShellScratch registers one active user of a session's private build +// caches. The returned release removes the cache after the last user exits. +func AcquireShellScratch(sessionID string) func() { + if sessionID == "" { + return func() {} + } + shellScratchUsers.Lock() + shellScratchUsers.counts[sessionID]++ + shellScratchUsers.Unlock() + var once sync.Once + return func() { + once.Do(func() { TeardownShellScratch(sessionID) }) + } +} + +// TeardownShellScratch releases one leaf's private build caches. Shared-cache +// mode never creates these directories, so removal remains a best-effort no-op. +func TeardownShellScratch(sessionID string) { + if sessionID == "" { + return + } + shellScratchUsers.Lock() + defer shellScratchUsers.Unlock() + users := shellScratchUsers.counts[sessionID] + if users > 1 { + shellScratchUsers.counts[sessionID] = users - 1 + return + } + delete(shellScratchUsers.counts, sessionID) + _ = os.RemoveAll(scratchDir(sessionID)) +} + +func sweepShellScratch() { + entries, err := os.ReadDir(scratchRoot()) + if err != nil { + return + } + hostname, _ := os.Hostname() + now := time.Now() + ttl := time.Duration(defaultScratchTTLHours) * time.Hour + if raw := os.Getenv("SENIOR_DEV_SCRATCH_TTL_H"); raw != "" { + if hours, err := strconv.ParseFloat(raw, 64); err == nil && hours > 0 { + ttl = time.Duration(hours * float64(time.Hour)) + } + } + type keptScratch struct { + dir string + owner *shellScratchOwner + started time.Time + } + kept := []keptScratch{} + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "ses_") { + continue + } + dir := filepath.Join(scratchRoot(), entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + owner := readShellScratchOwner(dir) + orphan := false + if owner != nil && owner.Hostname == hostname { + orphan = !shellScratchPIDAlive(owner.PID) + } else { + started := info.ModTime() + if owner != nil && owner.StartedAt > 0 { + started = time.UnixMilli(owner.StartedAt) + } + orphan = now.Sub(started) > ttl + } + if orphan { + _ = os.RemoveAll(dir) + } else { + started := info.ModTime() + if owner != nil && owner.StartedAt > 0 { + started = time.UnixMilli(owner.StartedAt) + } + kept = append(kept, keptScratch{dir: dir, owner: owner, started: started}) + } + } + rawCap := os.Getenv("SENIOR_DEV_SCRATCH_MAX_GB") + capGB, err := strconv.ParseFloat(rawCap, 64) + if err != nil || capGB <= 0 { + return + } + capBytes := int64(capGB * 1024 * 1024 * 1024) + sizes := make(map[string]int64, len(kept)) + var total int64 + for _, item := range kept { + sizes[item.dir] = shellScratchDirSize(item.dir) + total += sizes[item.dir] + } + if total <= capBytes { + return + } + sort.SliceStable(kept, func(i, j int) bool { return kept[i].started.Before(kept[j].started) }) + for _, item := range kept { + if total <= capBytes { + break + } + if item.owner != nil && item.owner.Hostname == hostname && shellScratchPIDAlive(item.owner.PID) { + continue + } + if os.RemoveAll(item.dir) == nil { + total -= sizes[item.dir] + } + } +} + +func shellScratchDirSize(dir string) int64 { + var total int64 + _ = filepath.WalkDir(dir, func(_ string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + if info, infoErr := entry.Info(); infoErr == nil { + total += info.Size() + } + return nil + }) + return total +} + +func readShellScratchOwner(dir string) *shellScratchOwner { + data, err := os.ReadFile(filepath.Join(dir, shellScratchMarker)) + if err != nil { + return nil + } + var owner shellScratchOwner + if json.Unmarshal(data, &owner) != nil || owner.PID == 0 { + return nil + } + return &owner +} + +func shellScratchPIDAlive(pid int) bool { + if runtime.GOOS == "windows" { + return true + } + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} + +func shellEnvironment(sessionID string) []string { + environment := append([]string(nil), os.Environ()...) + // Appended after os.Environ() so exec's last-entry-wins dedup overrides + // any proxy the parent carries; independent of the shared-cache early + // return below, which must not open the network gate. + environment = append(environment, netpolicy.ShellProxyEnv(netpolicy.Current())...) + if os.Getenv("SENIOR_DEV_SHARED_BUILD_CACHE") == "1" || sessionID == "" { + return environment + } + ensureShellScratch(sessionID) + leaf := scratchDir(sessionID) + // Only DERIVED caches may be redirected. Each of these is reconstructible + // from source plus a toolchain, so pointing it at an empty per-session dir + // costs a cold build and nothing else. + // + // GOMODCACHE is deliberately NOT here. It is a source of truth, not an + // output cache: the module sources themselves live in it, and an offline + // environment may have pre-populated it precisely because nothing can be + // downloaded. Redirecting it to an empty dir while netpolicy blackholes the + // network leaves Go unable to build anything -- `go: downloading` storms + // ending in 403 from our own blackhole, then "module lookup disabled by + // GOPROXY=off". Sharing the module cache across sessions is safe -- it is + // content-addressed and written read-only by the go tool. + defaults := map[string]string{ + "CARGO_TARGET_DIR": filepath.Join(leaf, "cargo"), + "GOCACHE": filepath.Join(leaf, "go-build"), + "npm_config_cache": filepath.Join(leaf, "npm"), + "PIP_CACHE_DIR": filepath.Join(leaf, "pip"), + } + for _, name := range []string{"CARGO_TARGET_DIR", "GOCACHE", "npm_config_cache", "PIP_CACHE_DIR"} { + if _, exists := os.LookupEnv(name); !exists { + environment = append(environment, name+"="+defaults[name]) + } + } + return environment +} diff --git a/internal/seniordev/tool/shell_settings.go b/internal/seniordev/tool/shell_settings.go new file mode 100644 index 000000000..27dd33cbe --- /dev/null +++ b/internal/seniordev/tool/shell_settings.go @@ -0,0 +1,52 @@ +//go:build !windows + +package tool + +import ( + "os" + "os/exec" + "path/filepath" +) + +func (r *Registry) executionShell() (string, error) { + settings, err := r.settings() + if err != nil { + return "", err + } + configured, _ := settings["shell"].(string) + if configured != "" { + if shell := resolveShellExecutable(configured); shell != "" && ShellAcceptable(shell) { + return shell, nil + } + return fallbackShell(), nil + } + if shell := resolveShellExecutable(os.Getenv("SHELL")); shell != "" && ShellAcceptable(shell) { + return shell, nil + } + return fallbackShell(), nil +} + +func resolveShellExecutable(shell string) string { + if shell == "" { + return "" + } + if filepath.IsAbs(shell) { + info, err := os.Stat(shell) + if err == nil && !info.IsDir() { + return filepath.Clean(shell) + } + return "" + } + match, err := exec.LookPath(shell) + if err != nil { + return "" + } + return match +} + +func fallbackShell() string { + if bash := resolveShellExecutable("bash"); bash != "" { + return bash + } + return "/bin/sh" +} diff --git a/internal/seniordev/tool/submit.go b/internal/seniordev/tool/submit.go new file mode 100644 index 000000000..da695ab65 --- /dev/null +++ b/internal/seniordev/tool/submit.go @@ -0,0 +1,138 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +// Submitting is the point of no return. Calling it hands senior-dev the tree as it +// stands and ends the run's authority to change it: the candidate is captured +// inside this call, so nothing the model does afterwards can reach what ships. +// +// Finishing is an explicit action rather than a condition inferred from the +// tree. A run that ends because its budget ran out leaves "done" as whatever +// the tree happened to look like at that moment; submit turns it into a +// decision the model makes, with evidence attached. +const submitDescription = `Declare the work finished. The working tree at this instant -- committed, +modified, and untracked files alike, minus git-ignored paths and .senior-dev/ -- is +captured as the answer, and the run ends. This is irreversible: later edits are +not part of the answer. + +Takes a reason, the evidence you verified with (the command you ran and what it +returned), and checklist_satisfied. Refuses, naming the cause, when the tree is +identical to 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 capture anything and does not end the run.` + +const submitSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "reason": { + "description": "Why the work is finished, in one sentence.", + "type": "string" + }, + "evidence": { + "description": "The commands you ran to prove it and what they returned. Name the pinned command and its exit status, and the test counts.", + "type": "string" + }, + "checklist_satisfied": { + "description": "True only if every intake checklist item is satisfied. Do not set it true to get past this gate.", + "type": "boolean" + } + }, + "required": ["reason", "evidence", "checklist_satisfied"] +}` + +type submitInput struct { + Reason string `json:"reason"` + Evidence string `json:"evidence"` + ChecklistSatisfied bool `json:"checklist_satisfied"` +} + +// Submission is what the model claimed when it submitted. The claim is +// recorded verbatim and separately from what senior-dev verifies itself afterwards +// -- a run that says "all tests pass" and did not run them must leave both +// facts in the record, not one reconciled story. +type Submission struct { + Reason string + Evidence string + ChecklistSatisfied bool + SessionID string +} + +// SubmitFreezer captures the candidate at submit time. It returns a short +// human-readable description of what was frozen (a patch size, a sha) that is +// echoed back to the model so the transcript records the handoff, or an error +// if there was nothing to freeze. +// +// The freeze happens inside the tool call rather than after the turn returns +// because that is the only placement where "no later stage may reopen the +// implementation" is structural instead of aspirational. +type SubmitFreezer func(ctx context.Context, submission Submission) (string, error) + +func validateSubmit(raw json.RawMessage) error { + var input submitInput + if err := json.Unmarshal(raw, &input); err != nil { + return fmt.Errorf("submit: %w", err) + } + if strings.TrimSpace(input.Reason) == "" { + return errors.New("submit: reason is required") + } + if strings.TrimSpace(input.Evidence) == "" { + return errors.New("submit: evidence is required — name the command you ran and what it returned") + } + return nil +} + +func (r *Registry) executeSubmit( + ctx context.Context, call steploop.ToolCall, +) (steploop.ToolResult, error) { + var input submitInput + if err := json.Unmarshal(call.Input, &input); err != nil { + return steploop.ToolResult{}, fmt.Errorf("submit: %w", err) + } + if r.submitFreeze == nil { + return steploop.ToolResult{}, errors.New( + "submit: this run has no submission handler; finish by describing the work instead", + ) + } + submission := Submission{ + Reason: strings.TrimSpace(input.Reason), + Evidence: strings.TrimSpace(input.Evidence), + ChecklistSatisfied: input.ChecklistSatisfied, + SessionID: call.SessionID, + } + description, err := r.submitFreeze(ctx, submission) + if err != nil { + // A refused submit is not a crash: the model is told why and may keep + // working. Refusing loudly here is the whole point -- an empty patch or + // a dirty tree caught at submit is worth more than the same thing + // discovered by the verification that runs after the freeze. + return steploop.ToolResult{ + Title: "submit refused", + Output: "Submission refused: " + err.Error() + "\n\nThe tree was NOT captured. Fix the problem and submit again.", + }, nil + } + return steploop.ToolResult{ + Title: "submitted", + Output: "Submission accepted and the tree is frozen: " + description + + "\n\nThis is your answer. Stop editing. Reply with a short summary of what you changed" + + " and the evidence it works; nothing you do now can change what ships.", + }, nil +} + +// SetSubmitFreezer installs the submission handler after construction. The +// registry is built inside the runtime, before the pipeline that owns the +// freeze exists; this is the seam between them. Setting it also advertises the +// submit tool, so it must be called before the first turn is configured. +func (r *Registry) SetSubmitFreezer(freeze SubmitFreezer) { + r.submitFreeze = freeze +} diff --git a/internal/seniordev/tool/submit_test.go b/internal/seniordev/tool/submit_test.go new file mode 100644 index 000000000..1c96e441c --- /dev/null +++ b/internal/seniordev/tool/submit_test.go @@ -0,0 +1,162 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func submitCall(t *testing.T, input map[string]any) steploop.ToolCall { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + return steploop.ToolCall{ + ID: "call_1", Name: "submit", Input: raw, SessionID: "ses_solo", Agent: "coder", + } +} + +func TestSubmitToolIsAbsentWithoutAFreezer(t *testing.T) { + // An embedder that runs no submission protocol must not advertise a submit + // action at all. Advertising one and then refusing every call teaches the + // model that the tool is broken, which is worse than not having it. + for _, id := range New(t.TempDir()).IDs() { + if id == "submit" { + t.Fatal("submit advertised with no SubmitFreeze installed") + } + } +} + +func TestSubmitFreezesTheCandidateInsideTheCall(t *testing.T) { + // The freeze must happen during the tool call, not after the turn returns. + // That placement is what makes "no later stage may reopen the + // implementation" structural: by the time the model's next step runs, the + // artifact of record already exists. + var captured Submission + frozen := 0 + registry := NewWithOptions(t.TempDir(), RegistryOptions{ + SubmitFreeze: func(_ context.Context, submission Submission) (string, error) { + frozen++ + captured = submission + return "812 B across 2 files", nil + }, + }) + + advertised := false + for _, id := range registry.IDs() { + if id == "submit" { + advertised = true + } + } + if !advertised { + t.Fatal("submit not advertised despite an installed freezer") + } + + result, err := registry.Execute(context.Background(), submitCall(t, map[string]any{ + "reason": "parser fix implemented and green", + "evidence": "make test: 12 passed, exit 0", + "checklist_satisfied": true, + })) + if err != nil { + t.Fatal(err) + } + if frozen != 1 { + t.Fatalf("freezer called %d times, want exactly 1", frozen) + } + if captured.Reason != "parser fix implemented and green" || + !captured.ChecklistSatisfied || captured.SessionID != "ses_solo" { + t.Fatalf("captured submission = %#v", captured) + } + if !strings.Contains(result.Output, "812 B across 2 files") { + t.Fatalf("freeze description not echoed to the model: %q", result.Output) + } + if !strings.Contains(result.Output, "Stop editing") { + t.Fatalf("accepted submit does not tell the model to stop: %q", result.Output) + } +} + +func TestRefusedSubmitIsRecoverableRatherThanFatal(t *testing.T) { + // A submit the freezer refuses -- empty patch, dirty tree -- must come back + // as a tool result the model can act on, not an error that kills the turn. + // Catching it here is the entire value: the same defect found after the run + // has ended costs the whole run. + registry := NewWithOptions(t.TempDir(), RegistryOptions{ + SubmitFreeze: func(context.Context, Submission) (string, error) { + return "", errors.New("the working tree is identical to the base commit") + }, + }) + result, err := registry.Execute(context.Background(), submitCall(t, map[string]any{ + "reason": "done", "evidence": "make test exit 0", "checklist_satisfied": true, + })) + if err != nil { + t.Fatalf("a refused submit must not error the turn: %v", err) + } + if !strings.Contains(result.Output, "identical to the base commit") { + t.Fatalf("refusal reason lost: %q", result.Output) + } + if !strings.Contains(result.Output, "NOT captured") { + t.Fatalf("refusal must say the tree was not captured: %q", result.Output) + } +} + +func TestSubmitDemandsEvidenceBeforeItReachesTheFreezer(t *testing.T) { + // "reason" alone is a claim. The evidence field is where the pinned command + // and its exit status go, and a submit without it is refused at validation + // so the freezer never sees an unsupported claim. + for name, input := range map[string]map[string]any{ + "no evidence": {"reason": "done", "checklist_satisfied": true}, + "blank evidence": { + "reason": "done", "evidence": " ", "checklist_satisfied": true, + }, + "no reason": {"evidence": "make test exit 0", "checklist_satisfied": true}, + } { + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + if err := validateSubmit(raw); err == nil { + t.Fatalf("%s: validation accepted %v", name, input) + } + } + raw, err := json.Marshal(map[string]any{ + "reason": "done", "evidence": "make test exit 0", "checklist_satisfied": true, + }) + if err != nil { + t.Fatal(err) + } + if err := validateSubmit(raw); err != nil { + t.Fatalf("a complete submit was rejected: %v", err) + } +} + +func TestSubmitSurvivesTheFilterAndThePerLeafClone(t *testing.T) { + // Two ways a tool silently disappears in this registry: the visibility + // filter drops it for the agent, or forContext's shallow clone loses the + // field it depends on. Both would turn every submit into the "no submission + // handler" refusal at runtime rather than at wiring time. + registry := NewWithOptions(t.TempDir(), RegistryOptions{ + SubmitFreeze: func(context.Context, Submission) (string, error) { return "ok", nil }, + }) + filtered := registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "deepseek-v4-flash", + }) + found := false + for _, definition := range filtered { + if definition.Provider.Name == "submit" { + found = true + } + } + if !found { + t.Fatal("submit was filtered away for the coder agent") + } + if registry.forContext(context.Background()).submitFreeze == nil { + t.Fatal("forContext dropped the freezer") + } +} diff --git a/internal/seniordev/tool/testsupport_test.go b/internal/seniordev/tool/testsupport_test.go new file mode 100644 index 000000000..6fa2ea5f1 --- /dev/null +++ b/internal/seniordev/tool/testsupport_test.go @@ -0,0 +1,25 @@ +//go:build !windows + +package tool + +import ( + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +// Shared helpers for the tests in this package. + +func definitionNames(definitions []steploop.ToolDefinition) []string { + out := make([]string, 0, len(definitions)) + for _, item := range definitions { + out = append(out, item.Provider.Name) + } + return out +} +func containsName(names []string, name string) bool { + for _, item := range names { + if item == name { + return true + } + } + return false +} diff --git a/internal/seniordev/tool/tool_test.go b/internal/seniordev/tool/tool_test.go new file mode 100644 index 000000000..8733f7f4e --- /dev/null +++ b/internal/seniordev/tool/tool_test.go @@ -0,0 +1,412 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/outputoffload" +) + +type failingBashOutputSink struct{} + +func (failingBashOutputSink) WriteOutput(string, string) (string, error) { + return "", errors.New("spill failed") +} + +func TestBash(t *testing.T) { + registry := New(t.TempDir()) + + t.Run("echo roundtrip", func(t *testing.T) { + result, err := execute(t, registry, "bash", map[string]any{"command": "echo roundtrip"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "roundtrip\n" { + t.Fatalf("Output = %q", result.Output) + } + }) + + t.Run("non-zero exit", func(t *testing.T) { + result, err := execute(t, registry, "bash", map[string]any{"command": "printf failure; exit 7"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "failure\nexit status 7") { + t.Fatalf("Output = %q", result.Output) + } + }) + + t.Run("timeout kills process group", func(t *testing.T) { + start := time.Now() + result, err := execute(t, registry, "bash", map[string]any{ + "command": "sleep 30", + "timeout_ms": 200, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("timeout took %v", elapsed) + } + if !strings.Contains(result.Output, "command timed out after 200ms") { + t.Fatalf("Output = %q", result.Output) + } + }) + + t.Run("large output spills full content", func(t *testing.T) { + // A large bash result keeps the existing preview and makes the complete + // output recoverable by tool-call ID. + workDir := t.TempDir() + registry := New(workDir) + input, _ := json.Marshal(map[string]any{ + "command": "head -c 30001 /dev/zero | tr '\\0' x", + }) + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call-large-output", Name: "bash", Input: input, SessionID: "ses_large", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + parts := strings.Split(result.Output, "\n\nThe tool call succeeded but the output was truncated. ") + if len(parts) != 2 { + t.Fatalf("missing recovery message in %q", result.Output) + } + preview := parts[0] + if len(preview) != maxBashOutputBytes { + t.Fatalf("preview length = %d, want %d", len(preview), maxBashOutputBytes) + } + if !strings.Contains(preview, "[... 29 bytes truncated ...]") { + t.Fatalf("missing truncation marker in %q", preview) + } + if !strings.HasPrefix(preview, "xxx") || !strings.HasSuffix(preview, "xxx") { + t.Fatalf("truncation did not preserve head and tail") + } + wantPath := filepath.Join(workDir, ".senior-dev", "tool-output", "ses_large", "call-large-output.log") + if !strings.Contains(result.Output, "Full output saved to: "+wantPath+"\n") { + t.Fatalf("recovery path missing from %q", result.Output) + } + full, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", wantPath, err) + } + if string(full) != strings.Repeat("x", maxBashOutputBytes+1) { + t.Fatalf("saved output length = %d, want %d complete bytes", len(full), maxBashOutputBytes+1) + } + }) + + t.Run("large output reports spill failure", func(t *testing.T) { + // A failed spill keeps the bash preview and appends the offloader's + // could-not-save notice. + previous := bashOffloader + bashOffloader = outputoffload.Offloader{Sink: failingBashOutputSink{}} + t.Cleanup(func() { bashOffloader = previous }) + registry := New(t.TempDir()) + input, _ := json.Marshal(map[string]any{ + "command": "head -c 30001 /dev/zero | tr '\\0' x", + }) + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call-spill-failure", Name: "bash", Input: input, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Output, "full output could not be saved") { + t.Fatalf("spill failure notice missing from %q", result.Output) + } + }) + + t.Run("small output remains inline", func(t *testing.T) { + // Output at or below the cap is unchanged and creates no spill directory. + workDir := t.TempDir() + registry := New(workDir) + input := json.RawMessage(`{"command":"printf unchanged"}`) + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call-small-output", Name: "bash", Input: input, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "unchanged" { + t.Fatalf("Output = %q", result.Output) + } + _, err = os.Stat(filepath.Join(workDir, ".senior-dev", "tool-output")) + if !os.IsNotExist(err) { + t.Fatalf("spill directory exists or stat failed: %v", err) + } + }) +} + +func TestRegistryExecuteNonBashBehaviorUnchanged(t *testing.T) { + // Threading call metadata does not alter non-bash dispatch. + workDir := t.TempDir() + input := json.RawMessage(`{"filePath":"result.txt","content":"unchanged"}`) + result, err := New(workDir).Execute(context.Background(), steploop.ToolCall{ + ID: "call-write", Name: "write", Input: input, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Wrote file successfully." { + t.Fatalf("Output = %q", result.Output) + } + assertTestFile(t, workDir, "result.txt", "unchanged") +} + +func TestRead(t *testing.T) { + workDir := t.TempDir() + if err := os.WriteFile(filepath.Join(workDir, "sample.txt"), []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil { + t.Fatal(err) + } + registry := New(workDir) + + result, err := execute(t, registry, "read", map[string]any{"filePath": "sample.txt"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "" + filepath.Join(workDir, "sample.txt") + "\nfile\n\n" + + "1: alpha\n2: beta\n3: gamma\n\n(End of file - total 3 lines)\n" + if result.Output != want { + t.Fatalf("Output = %q, want %q", result.Output, want) + } + + result, err = execute(t, registry, "read", map[string]any{ + "filePath": "sample.txt", + "offset": 2, + "limit": 1, + }) + if err != nil { + t.Fatalf("Execute offset/limit: %v", err) + } + want = "" + filepath.Join(workDir, "sample.txt") + "\nfile\n\n" + + "2: beta\n\n(Showing lines 2-2 of 3. Use offset=3 to continue.)\n" + if result.Output != want { + t.Fatalf("offset/limit output = %q", result.Output) + } + + _, err = execute(t, registry, "read", map[string]any{"filePath": "missing.txt"}) + if err == nil || err.Error() != "File not found: "+filepath.Join(workDir, "missing.txt") { + t.Fatalf("missing file error = %v", err) + } +} + +func TestWrite(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + result, err := execute(t, registry, "write", map[string]any{ + "filePath": "nested/deep/file.txt", + "content": "roundtrip", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Wrote file successfully." { + t.Fatalf("Output = %q", result.Output) + } + content, err := os.ReadFile(filepath.Join(workDir, "nested", "deep", "file.txt")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(content) != "roundtrip" { + t.Fatalf("content = %q", content) + } +} + +func TestEdit(t *testing.T) { + t.Run("unique replace", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "before middle after") + registry := New(workDir) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "middle", + "newString": "changed", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Edit applied successfully." { + t.Fatalf("Output = %q", result.Output) + } + assertTestFile(t, workDir, "file.txt", "before changed after") + }) + + t.Run("not found", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "content") + registry := New(workDir) + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "missing", + "newString": "changed", + }) + if err == nil || err.Error() != "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings." { + t.Fatalf("error = %v", err) + } + }) + + t.Run("not unique", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "same and same") + registry := New(workDir) + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "same", + "newString": "changed", + }) + if err == nil || err.Error() != "Found multiple matches for oldString. Provide more surrounding context to make the match unique." { + t.Fatalf("error = %v", err) + } + assertTestFile(t, workDir, "file.txt", "same and same") + }) + + t.Run("replace all", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "same and same") + registry := New(workDir) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "same", + "newString": "changed", + "replaceAll": true, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Edit applied successfully." { + t.Fatalf("Output = %q", result.Output) + } + assertTestFile(t, workDir, "file.txt", "changed and changed") + }) +} + +func TestPathsCannotEscapeWorkspace(t *testing.T) { + root := t.TempDir() + workDir := filepath.Join(root, "workspace") + if err := os.Mkdir(workDir, 0o755); err != nil { + t.Fatal(err) + } + absoluteOutside := filepath.Join(root, "outside.txt") + registry := New(workDir) + + for _, toolName := range []string{"read", "write", "edit"} { + toolName := toolName + t.Run(toolName, func(t *testing.T) { + for _, path := range []string{"../outside.txt", absoluteOutside} { + input := map[string]any{"path": path} + switch toolName { + case "read": + delete(input, "path") + input["filePath"] = path + case "write": + delete(input, "path") + input["filePath"] = path + input["content"] = "blocked" + case "edit": + delete(input, "path") + input["filePath"] = path + input["oldString"] = "old" + input["newString"] = "new" + } + _, err := execute(t, registry, toolName, input) + if err == nil || err.Error() != "path escapes workspace: "+path { + t.Fatalf("path %q error = %v", path, err) + } + } + }) + } +} + +func TestDefinitions(t *testing.T) { + definitions := New(t.TempDir()).Definitions() + if len(definitions) != 9 { + t.Fatalf("len(Definitions) = %d", len(definitions)) + } + + var names []string + for _, definition := range definitions { + names = append(names, definition.Provider.Name) + if definition.Provider.Type != "function" { + t.Errorf("%s type = %q", definition.Provider.Name, definition.Provider.Type) + } + var schema map[string]any + if err := json.Unmarshal(definition.Provider.InputSchema, &schema); err != nil { + t.Errorf("%s schema: %v", definition.Provider.Name, err) + } + if schema["type"] != "object" { + t.Errorf("%s schema type = %v", definition.Provider.Name, schema["type"]) + } + } + if want := []string{"bash", "read", "glob", "grep", "edit", "write", "webfetch", "websearch", "apply_patch"}; !reflect.DeepEqual(names, want) { + t.Fatalf("names = %v, want %v", names, want) + } +} + +func TestValidationAndUnknownTool(t *testing.T) { + definitions := New(t.TempDir()).Definitions() + if err := definitions[0].Validate(json.RawMessage(`{"timeout_ms":200}`)); err == nil || !strings.Contains(err.Error(), `missing required field "command"`) { + t.Fatalf("missing field error = %v", err) + } + if err := definitions[0].Validate(json.RawMessage(`{"command":7}`)); err == nil || !strings.Contains(err.Error(), "cannot unmarshal number") { + t.Fatalf("wrong type error = %v", err) + } + if err := definitions[0].Validate(json.RawMessage(`{"command":null}`)); err == nil || !strings.Contains(err.Error(), "must not be null") { + t.Fatalf("null field error = %v", err) + } + if err := definitions[0].Validate(json.RawMessage(`{"command":"true","extra":1}`)); err == nil || !strings.Contains(err.Error(), `unknown field "extra"`) { + t.Fatalf("unknown field error = %v", err) + } + + _, err := New(t.TempDir()).Execute(context.Background(), steploop.ToolCall{Name: "missing", Input: json.RawMessage(`{}`)}) + if err == nil || err.Error() != "unknown tool: missing" { + t.Fatalf("unknown tool error = %v", err) + } +} + +func execute(t *testing.T, registry *Registry, name string, input any) (steploop.ToolResult, error) { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + return registry.Execute(context.Background(), steploop.ToolCall{Name: name, Input: raw}) +} + +func writeTestFile(t *testing.T, workDir, path, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(workDir, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertTestFile(t *testing.T, workDir, path, want string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(workDir, path)) + if err != nil { + t.Fatal(err) + } + if string(content) != want { + t.Fatalf("content = %q, want %q", content, want) + } +} + +func TestExecuteHonorsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := New(t.TempDir()).Execute(ctx, steploop.ToolCall{ + Name: "bash", + Input: json.RawMessage(`{"command":"sleep 30"}`), + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/seniordev/tool/web_common.go b/internal/seniordev/tool/web_common.go new file mode 100644 index 000000000..58682f0fb --- /dev/null +++ b/internal/seniordev/tool/web_common.go @@ -0,0 +1,316 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/id" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +const ( + webOutputMaxLines = 2000 + webOutputMaxBytes = 50 * 1024 + webOutputRetention = 7 * 24 * time.Hour +) + +type webExecutionOptions struct { + client *http.Client + exaURL string + parallelURL string + outputDir string + version string + resolveHost func(context.Context, string) ([]net.IP, error) +} + +// WithWebHostResolver injects redirect-boundary DNS resolution for hermetic +// tests. Production uses net.DefaultResolver. +func WithWebHostResolver( + ctx context.Context, resolver func(context.Context, string) ([]net.IP, error), +) context.Context { + options := webOptions(ctx) + options.resolveHost = resolver + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +type webExecutionOptionsKey struct{} + +// WithWebHTTPClient injects the HTTP seam used by webfetch and websearch. +// Production calls use http.DefaultClient, which follows redirects. +func WithWebHTTPClient(ctx context.Context, client *http.Client) context.Context { + options := webOptions(ctx) + options.client = client + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +// WithWebSearchEndpoints redirects the provider MCP endpoints. It exists so +// tests can exercise the complete registry path without external network I/O. +func WithWebSearchEndpoints(ctx context.Context, exaURL, parallelURL string) context.Context { + options := webOptions(ctx) + options.exaURL = exaURL + options.parallelURL = parallelURL + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +// WithWebOutputDir redirects the shared 50 KiB/2000-line truncation spill. +func WithWebOutputDir(ctx context.Context, directory string) context.Context { + options := webOptions(ctx) + options.outputDir = directory + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +func webOptions(ctx context.Context) webExecutionOptions { + options, _ := ctx.Value(webExecutionOptionsKey{}).(webExecutionOptions) + return options +} + +func webClient(ctx context.Context) *http.Client { + client := http.DefaultClient + if injected := webOptions(ctx).client; injected != nil { + client = injected + } + // netpolicy is enforced at this single chokepoint so every present and + // future in-process tool HTTP call inherits it, including each redirect + // hop (redirects re-enter the transport). The client is copied, never + // mutated: http.DefaultClient is shared process state, and injected test + // clients belong to their owners. + policy := netpolicy.Current() + if !policy.Restricted() { + return client + } + wrapped := *client + wrapped.Transport = policy.Transport(client.Transport) + return &wrapped +} + +func webHostResolver(ctx context.Context) func(context.Context, string) ([]net.IP, error) { + if resolver := webOptions(ctx).resolveHost; resolver != nil { + return resolver + } + return func(ctx context.Context, host string) ([]net.IP, error) { + return net.DefaultResolver.LookupIP(ctx, "ip", host) + } +} + +type redirectBlockedError struct{ destination string } + +func (err *redirectBlockedError) Error() string { + return "redirect destination blocked: " + err.destination +} + +func redirectSafeWebClient(ctx context.Context, base *http.Client, originalURL string) *http.Client { + client := *base + resolve := webHostResolver(ctx) + var originalOnce sync.Once + var originalRestricted bool + originalHost := "" + if parsed, err := url.Parse(originalURL); err == nil { + originalHost = parsed.Hostname() + } + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + originalOnce.Do(func() { + originalRestricted = hostResolvesRestricted(request.Context(), resolve, originalHost) + }) + if !originalRestricted && hostResolvesRestricted(request.Context(), resolve, request.URL.Hostname()) { + return &redirectBlockedError{destination: request.URL.String()} + } + return nil + } + return &client +} + +func hostResolvesRestricted( + ctx context.Context, + resolve func(context.Context, string) ([]net.IP, error), + host string, +) bool { + if host == "" { + return false + } + addresses := []net.IP(nil) + if literal := net.ParseIP(host); literal != nil { + addresses = []net.IP{literal} + } else { + resolved, err := resolve(ctx, host) + if err != nil { + return false + } + addresses = resolved + } + metadata4 := net.ParseIP("169.254.169.254") + metadata6 := net.ParseIP("fd00:ec2::254") + for _, address := range addresses { + if address.IsLoopback() || address.IsPrivate() || address.IsLinkLocalUnicast() || + address.IsLinkLocalMulticast() || address.Equal(metadata4) || address.Equal(metadata6) { + return true + } + } + return false +} + +func webOutputDirectory(ctx context.Context) string { + if directory := webOptions(ctx).outputDir; directory != "" { + return directory + } + if directory := os.Getenv("XDG_DATA_HOME"); directory != "" { + return filepath.Join(directory, "senior-dev", "tool-output") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "senior-dev", "tool-output") +} + +type webTruncationMetadata struct { + Truncated bool `json:"truncated"` + OutputPath string `json:"outputPath,omitempty"` +} + +func (r *Registry) truncateWebOutput( + ctx context.Context, + call steploop.ToolCall, + output string, +) (string, webTruncationMetadata, error) { + directory := webOutputDirectory(ctx) + // A seven-day sweep of old spill files runs on every truncation pass; + // cleanup failures are non-fatal. + cleanupWebOutput(directory, time.Now()) + // Output beyond the 2000-line/50-KiB head preview is spilled to a file; the + // preview carries a marker, the path, and matching metadata. + maxLines, maxBytes := r.webOutputLimits() + lines := strings.Split(output, "\n") + if len(lines) <= maxLines && len([]byte(output)) <= maxBytes { + return output, webTruncationMetadata{Truncated: false}, nil + } + + preview := make([]string, 0, min(len(lines), maxLines)) + bytesUsed := 0 + hitBytes := false + for index := 0; index < len(lines) && index < maxLines; index++ { + size := len([]byte(lines[index])) + if index > 0 { + size++ + } + if bytesUsed+size > maxBytes { + hitBytes = true + break + } + preview = append(preview, lines[index]) + bytesUsed += size + } + + removed := len(lines) - len(preview) + unit := "lines" + if hitBytes { + removed = len([]byte(output)) - bytesUsed + unit = "bytes" + } + if err := os.MkdirAll(directory, 0o755); err != nil { + return "", webTruncationMetadata{}, err + } + name, err := id.Ascending("tool") + if err != nil { + return "", webTruncationMetadata{}, err + } + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte(output), 0o644); err != nil { + return "", webTruncationMetadata{}, err + } + + hint := "The tool call succeeded but the output was truncated. Full output saved to: " + path + + "\nUse Grep to search the full content or Read with offset/limit to view specific sections." + content := fmt.Sprintf("%s\n\n...%d %s truncated...\n\n%s", strings.Join(preview, "\n"), removed, unit, hint) + return content, webTruncationMetadata{Truncated: true, OutputPath: path}, nil +} + +func cleanupWebOutput(directory string, now time.Time) { + entries, err := os.ReadDir(directory) + if err != nil { + return + } + cutoffID, err := id.Create("tool", id.AscendingDirection, now.Add(-webOutputRetention).UnixMilli()) + if err != nil { + return + } + cutoff, err := id.Timestamp(cutoffID) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), "tool_") { + continue + } + timestamp, err := id.Timestamp(entry.Name()) + if err != nil || timestamp >= cutoff { + continue + } + _ = os.Remove(filepath.Join(directory, entry.Name())) + } +} + +func (r *Registry) webOutputLimits() (int, int) { + maxLines, maxBytes := webOutputMaxLines, webOutputMaxBytes + settings, err := r.settings() + if err != nil { + return maxLines, maxBytes + } + value, ok := settings["tool_output"].(map[string]any) + if !ok { + return maxLines, maxBytes + } + if number, ok := value["max_lines"].(float64); ok { + maxLines = int(number) + } + if number, ok := value["max_bytes"].(float64); ok { + maxBytes = int(number) + } + return maxLines, maxBytes +} + +func statusCodeError(method, rawURL string, status int) error { + return fmt.Errorf("StatusCode error (%d %s %s)", status, method, rawURL) +} + +func transportError(method, rawURL string) error { + return fmt.Errorf("Transport error (%s %s)", method, rawURL) +} + +func decodeWebInput(raw json.RawMessage, destination any, known []string, required ...string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return fmt.Errorf("input must be a JSON object: %w", err) + } + if fields == nil { + return fmt.Errorf("input must be a JSON object") + } + for _, name := range required { + if _, ok := fields[name]; !ok { + return fmt.Errorf("missing required field %q", name) + } + } + for _, name := range known { + if value, ok := fields[name]; ok && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("field %q must not be null", name) + } + } + // Unknown properties are ignored rather than rejected for the web tools. + if err := json.Unmarshal(raw, destination); err != nil { + return fmt.Errorf("invalid input: %w", err) + } + return nil +} diff --git a/internal/seniordev/tool/web_descriptions.go b/internal/seniordev/tool/web_descriptions.go new file mode 100644 index 000000000..22b12aa52 --- /dev/null +++ b/internal/seniordev/tool/web_descriptions.go @@ -0,0 +1,11 @@ +//go:build !windows + +package tool + +import _ "embed" + +//go:embed webfetch.txt +var webFetchDescription string + +//go:embed websearch.txt +var webSearchDescriptionTemplate string diff --git a/internal/seniordev/tool/webfetch.go b/internal/seniordev/tool/webfetch.go new file mode 100644 index 000000000..adae12940 --- /dev/null +++ b/internal/seniordev/tool/webfetch.go @@ -0,0 +1,244 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "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" +) + +const ( + webFetchMaxResponseSize = 5 * 1024 * 1024 + webFetchDefaultTimeout = 30 * time.Second + webFetchMaxTimeout = 120 * time.Second + webFetchUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" +) + +const webFetchSchema = `{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "type":"object", + "properties":{ + "url":{"type":"string","description":"The URL to fetch content from"}, + "format":{"type":"string","enum":["text","markdown","html"],"default":"markdown","description":"The format to return the content in (text, markdown, or html). Defaults to markdown."}, + "timeout":{"type":"number","description":"Optional timeout in seconds (max 120)"} + }, + "required":["url"] +}` + +type webFetchInput struct { + URL string `json:"url"` + Format string `json:"format,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` +} + +type webFetchMetadata struct { + Truncated bool `json:"truncated"` + OutputPath string `json:"outputPath,omitempty"` +} + +func validateWebFetch(raw json.RawMessage) error { + var input webFetchInput + if err := decodeWebInput(raw, &input, []string{"url", "format", "timeout"}, "url"); err != nil { + return err + } + if input.Format != "" && input.Format != "text" && input.Format != "markdown" && input.Format != "html" { + return fmt.Errorf("format must be one of text, markdown, or html") + } + return nil +} + +func (r *Registry) executeWebFetch(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input webFetchInput + if err := decodeWebInput(call.Input, &input, []string{"url", "format", "timeout"}, "url"); err != nil { + return steploop.ToolResult{}, err + } + if input.Format == "" { + input.Format = "markdown" + } + // Only the literal http:// and https:// prefixes are accepted; http URLs + // are fetched as given, not upgraded. + if !strings.HasPrefix(input.URL, "http://") && !strings.HasPrefix(input.URL, "https://") { + return steploop.ToolResult{}, fmt.Errorf("URL must start with http:// or https://") + } + // Refuse before the permission ask so the user is never prompted for a + // request that cannot proceed. The transport wrap in webClient repeats + // the refusal on every hop as a fast-fail backstop. + if policy := netpolicy.Current(); policy.Restricted() { + host := input.URL + if parsed, parseErr := url.Parse(input.URL); parseErr == nil { + host = parsed.Host + } + return steploop.ToolResult{}, policy.HostError(host) + } + metadata := map[string]any{"url": input.URL, "format": input.Format} + if input.Timeout != nil { + metadata["timeout"] = *input.Timeout + } + if err := r.ask(ctx, call, "webfetch", []string{input.URL}, metadata); err != nil { + return steploop.ToolResult{}, err + } + + timeout := webFetchDefaultTimeout + if input.Timeout != nil { + // Cap in floating-point space before converting to time.Duration so a + // huge JSON number saturates at the maximum instead of overflowing the + // Go duration. + if *input.Timeout > webFetchMaxTimeout.Seconds() { + timeout = webFetchMaxTimeout + } else { + timeout = time.Duration(*input.Timeout * float64(time.Second)) + } + } + requestCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + response, err := executeWebFetchRequest(requestCtx, webClient(ctx), input) + if err != nil { + if requestCtx.Err() == context.DeadlineExceeded { + return steploop.ToolResult{}, fmt.Errorf("Request timed out") + } + return steploop.ToolResult{}, err + } + defer response.Body.Close() + + // A response over 5 MiB is rejected both from a declared Content-Length + // and after reading, before any content conversion. + if declared := response.Header.Get("Content-Length"); declared != "" { + if size, parseErr := strconv.ParseInt(declared, 10, 64); parseErr == nil && size > webFetchMaxResponseSize { + return steploop.ToolResult{}, fmt.Errorf("Response too large (exceeds 5MB limit)") + } + } + body, err := io.ReadAll(io.LimitReader(response.Body, webFetchMaxResponseSize+1)) + if err != nil { + return steploop.ToolResult{}, err + } + if len(body) > webFetchMaxResponseSize { + return steploop.ToolResult{}, fmt.Errorf("Response too large (exceeds 5MB limit)") + } + + contentType := response.Header.Get("Content-Type") + mime := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + title := input.URL + " (" + contentType + ")" + // Images (every image/* type except SVG and fastbidsheet) are returned as + // a data-URL attachment. + if isWebFetchImage(mime) { + attachments := []msgmodel.FilePart{{ + Type: "file", Mime: mime, + URL: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(body), + }} + return steploop.ToolResult{ + Title: title, Output: "Image fetched successfully", + Metadata: rawMetadata(webFetchMetadata{Truncated: false}), Attachments: &attachments, + }, nil + } + + // Malformed UTF-8 is replaced rather than rejected, and no MIME type other + // than images is treated specially. + content := strings.ToValidUTF8(string(body), "\uFFFD") + if strings.Contains(contentType, "text/html") { + switch input.Format { + case "markdown": + content, err = convertWebHTMLToMarkdown(content) + case "text": + content, err = extractWebHTMLText(content) + } + if err != nil { + return steploop.ToolResult{}, err + } + } + output, truncation, err := r.truncateWebOutput(ctx, call, content) + if err != nil { + return steploop.ToolResult{}, err + } + return steploop.ToolResult{ + Title: title, Output: output, + Metadata: rawMetadata(webFetchMetadata{ + Truncated: truncation.Truncated, OutputPath: truncation.OutputPath, + }), + }, nil +} + +func executeWebFetchRequest(ctx context.Context, client *http.Client, input webFetchInput) (*http.Response, error) { + // A direct GET with no robots.txt check. Redirects follow the client's + // normal behaviour, bounded by redirectSafeWebClient. + headers := map[string]string{ + "User-Agent": webFetchUserAgent, + "Accept": acceptHeader(input.Format), + "Accept-Language": "en-US,en;q=0.9", + } + do := func(userAgent string) (*http.Response, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, input.URL, nil) + if err != nil { + return nil, fmt.Errorf("InvalidUrl error (GET %s)", input.URL) + } + for name, value := range headers { + request.Header.Set(name, value) + } + request.Header.Set("User-Agent", userAgent) + response, err := redirectSafeWebClient(ctx, client, input.URL).Do(request) + if err != nil { + // A policy refusal on a redirect hop comes back wrapped in + // *url.Error; surface its no-retry framing instead of collapsing + // it into a retryable-looking transport failure. + var policyBlocked *netpolicy.BlockedError + if errors.As(err, &policyBlocked) { + return nil, policyBlocked + } + var blocked *redirectBlockedError + if errors.As(err, &blocked) { + return nil, transportError(http.MethodGet, blocked.destination) + } + return nil, transportError(http.MethodGet, input.URL) + } + return response, nil + } + + response, err := do(headers["User-Agent"]) + if err != nil { + return nil, err + } + // Only Cloudflare's explicit 403 challenge is retried, with an honest + // senior-dev User-Agent. Other non-2xx responses are not retried. + if response.StatusCode == http.StatusForbidden && response.Header.Get("cf-mitigated") == "challenge" { + response.Body.Close() + response, err = do("senior-dev") + if err != nil { + return nil, err + } + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + response.Body.Close() + return nil, statusCodeError(http.MethodGet, input.URL, response.StatusCode) + } + return response, nil +} + +func acceptHeader(format string) string { + switch format { + case "markdown": + return "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" + case "text": + return "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" + case "html": + return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" + default: + return "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" + } +} + +func isWebFetchImage(mime string) bool { + return strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" && mime != "image/vnd.fastbidsheet" +} diff --git a/internal/seniordev/tool/webfetch.txt b/internal/seniordev/tool/webfetch.txt new file mode 100644 index 000000000..532a213f1 --- /dev/null +++ b/internal/seniordev/tool/webfetch.txt @@ -0,0 +1,12 @@ +- Fetches content from a specified URL +- Takes a URL and optional format as input +- Fetches the URL content, converts to requested format (markdown by default) +- Returns the content in the specified format +- Use this tool when you need to retrieve and analyze web content + +Usage notes: + - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one. + - The URL must be a fully-formed valid URL starting with http:// or https:// + - Format options: "markdown" (default), "text", or "html" + - This tool is read-only and does not modify any files + - Results may be summarized if the content is very large diff --git a/internal/seniordev/tool/webfetch_html.go b/internal/seniordev/tool/webfetch_html.go new file mode 100644 index 000000000..a2a57afaa --- /dev/null +++ b/internal/seniordev/tool/webfetch_html.go @@ -0,0 +1,216 @@ +//go:build !windows + +package tool + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "golang.org/x/net/html" +) + +var webHTMLWhitespace = regexp.MustCompile(`\s+`) +var ( + webMarkdownLeadingEquals = regexp.MustCompile(`^(=+)`) + webMarkdownLeadingHash = regexp.MustCompile(`^(#{1,6}) `) + webMarkdownLeadingNumber = regexp.MustCompile(`^(\d+)\. `) +) + +func extractWebHTMLText(source string) (string, error) { + document, err := html.Parse(strings.NewReader(source)) + if err != nil { + return "", err + } + var output strings.Builder + var walk func(*html.Node, bool) + walk = func(node *html.Node, skip bool) { + if node.Type == html.ElementNode { + switch node.Data { + case "script", "style", "noscript", "iframe", "object", "embed": + skip = true + } + } + if node.Type == html.TextNode && !skip { + output.WriteString(node.Data) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child, skip) + } + } + walk(document, false) + // Text chunks are concatenated with only a final trim; no separators are + // invented between elements. + return strings.TrimSpace(output.String()), nil +} + +func convertWebHTMLToMarkdown(source string) (string, error) { + document, err := html.Parse(strings.NewReader(source)) + if err != nil { + return "", err + } + markdown := renderWebMarkdown(document, markdownRenderState{}) + markdown = strings.ReplaceAll(markdown, "\u00a0", " ") + markdown = regexp.MustCompile(`[ \t]+\n`).ReplaceAllString(markdown, "\n") + markdown = regexp.MustCompile(`\n{3,}`).ReplaceAllString(markdown, "\n\n") + return strings.TrimSpace(markdown), nil +} + +type markdownRenderState struct { + pre bool + code bool + listDepth int +} + +func renderWebMarkdown(node *html.Node, state markdownRenderState) string { + if node.Type == html.TextNode { + if state.pre { + return node.Data + } + text := webHTMLWhitespace.ReplaceAllString(node.Data, " ") + if state.code { + return text + } + return escapeWebMarkdownText(text) + } + if node.Type != html.ElementNode && node.Type != html.DocumentNode { + return "" + } + if node.Type == html.ElementNode { + switch node.Data { + case "script", "style", "meta", "link": + // These four elements are dropped outright (rather than all head + // content). + return "" + } + } + + childState := state + if node.Type == html.ElementNode && node.Data == "pre" { + childState.pre = true + } + if node.Type == html.ElementNode && node.Data == "code" { + childState.code = true + } + if node.Type == html.ElementNode && (node.Data == "ul" || node.Data == "ol") { + childState.listDepth++ + } + var content strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + content.WriteString(renderWebMarkdown(child, childState)) + } + inner := content.String() + if node.Type != html.ElementNode { + return inner + } + + switch node.Data { + case "h1", "h2", "h3", "h4", "h5", "h6": + level, _ := strconv.Atoi(node.Data[1:]) + return "\n\n" + strings.Repeat("#", level) + " " + strings.TrimSpace(inner) + "\n\n" + case "p", "div", "section", "article", "header", "footer", "main", "aside", "nav", "figure", "figcaption": + if strings.TrimSpace(inner) == "" { + return "" + } + return "\n\n" + strings.TrimSpace(inner) + "\n\n" + case "br": + return " \n" + case "hr": + return "\n\n---\n\n" + case "strong", "b": + return "**" + strings.TrimSpace(inner) + "**" + case "em", "i": + return "*" + strings.TrimSpace(inner) + "*" + case "del", "s", "strike": + return "~~" + strings.TrimSpace(inner) + "~~" + case "code": + if state.pre { + return inner + } + return "`" + strings.TrimSpace(inner) + "`" + case "pre": + return "\n\n```\n" + strings.Trim(inner, "\n") + "\n```\n\n" + case "a": + href := webHTMLAttribute(node, "href") + if href == "" { + return inner + } + title := webHTMLAttribute(node, "title") + if title != "" { + href += ` "` + title + `"` + } + return "[" + strings.TrimSpace(inner) + "](" + href + ")" + case "img": + source := webHTMLAttribute(node, "src") + if source == "" { + return "" + } + title := webHTMLAttribute(node, "title") + if title != "" { + source += ` "` + title + `"` + } + return "![" + webHTMLAttribute(node, "alt") + "](" + source + ")" + case "blockquote": + value := strings.TrimSpace(inner) + return "\n\n> " + strings.ReplaceAll(value, "\n", "\n> ") + "\n\n" + case "ul", "ol": + return "\n\n" + strings.Trim(inner, "\n") + "\n\n" + case "li": + prefix := "- " + if node.Parent != nil && node.Parent.Data == "ol" { + index := 1 + for sibling := node.PrevSibling; sibling != nil; sibling = sibling.PrevSibling { + if sibling.Type == html.ElementNode && sibling.Data == "li" { + index++ + } + } + prefix = fmt.Sprintf("%d. ", index) + } + indent := strings.Repeat(" ", max(0, state.listDepth-1)) + value := strings.TrimSpace(inner) + value = strings.ReplaceAll(value, "\n", "\n"+indent+" ") + return "\n" + indent + prefix + value + case "table", "thead", "tbody", "tfoot", "tr": + return "\n" + strings.TrimSpace(inner) + "\n" + case "th", "td": + return strings.TrimSpace(inner) + "\t" + default: + return inner + } +} + +// escapeWebMarkdownText is Turndown 7.2.0's ordered escape list. Applying it +// only to ordinary text nodes keeps markup emitted by element rules intact. +func escapeWebMarkdownText(text string) string { + text = strings.ReplaceAll(text, `\`, `\\`) + text = strings.ReplaceAll(text, `*`, `\*`) + if strings.HasPrefix(text, "-") { + text = `\` + text + } + if strings.HasPrefix(text, "+ ") { + text = `\` + text + } + text = webMarkdownLeadingEquals.ReplaceAllString(text, `\$1`) + text = webMarkdownLeadingHash.ReplaceAllString(text, `\$1 `) + text = strings.ReplaceAll(text, "`", "\\`") + if strings.HasPrefix(text, "~~~") { + text = `\` + text + } + text = strings.ReplaceAll(text, "[", `\[`) + text = strings.ReplaceAll(text, "]", `\]`) + if strings.HasPrefix(text, ">") { + text = `\` + text + } + text = strings.ReplaceAll(text, "_", `\_`) + return webMarkdownLeadingNumber.ReplaceAllString(text, `$1\. `) +} + +func webHTMLAttribute(node *html.Node, name string) string { + for _, attribute := range node.Attr { + if attribute.Key == name { + return attribute.Val + } + } + return "" +} diff --git a/internal/seniordev/tool/webfetch_test.go b/internal/seniordev/tool/webfetch_test.go new file mode 100644 index 000000000..51b30eeb6 --- /dev/null +++ b/internal/seniordev/tool/webfetch_test.go @@ -0,0 +1,373 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/id" +) + +type webRoundTripFunc func(*http.Request) (*http.Response, error) + +func (function webRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func newLocalWebServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("sandbox blocks loopback listeners: %v", err) + } + server := &httptest.Server{ + Listener: listener, + Config: &http.Server{Handler: handler}, + } + server.Start() + t.Cleanup(server.Close) + return server +} + +func executeWebTest( + t *testing.T, + registry *Registry, + ctx context.Context, + name string, + input map[string]any, +) (steploop.ToolResult, error) { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + return registry.Execute(ctx, steploop.ToolCall{ + ID: "call-web", Name: name, Input: raw, SessionID: "ses-web", Agent: "coder", + ModelID: "fixture/model", + }) +} + +func TestWebFetchHTMLFullExecution(t *testing.T) { + html := `T

Hello

plain bold & link.

  • one
  • two
` + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Accept") != acceptHeader("markdown") { + t.Errorf("Accept = %q", request.Header.Get("Accept")) + } + if request.Header.Get("User-Agent") != webFetchUserAgent { + t.Errorf("User-Agent = %q", request.Header.Get("User-Agent")) + } + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(writer, html) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{"url": server.URL}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "T\n\n# Hello\n\nplain **bold** & [link](/x).\n\n- one\n- two" + if result.Output != want { + t.Fatalf("Output = %q, want %q", result.Output, want) + } + if result.Title != server.URL+" (text/html; charset=utf-8)" { + t.Fatalf("Title = %q", result.Title) + } + if string(result.Metadata) != `{"truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestWebFetchHTMLConversionFixture(t *testing.T) { + source := `T

Hello

plain bold & link.

  • one
  • two
` + want := "T\n\n# Hello\n\nplain **bold** & [link](/x).\n\n- one\n- two" + got, err := convertWebHTMLToMarkdown(source) + if err != nil || got != want { + t.Fatalf("conversion = (%q, %v), want %q", got, err, want) + } +} + +func TestWebFetchHTMLMarkdownEscapesTurndownPunctuation(t *testing.T) { + tests := []struct { + html string + want string + }{ + {`

# not heading

`, `\# not heading`}, + {`

1. not list

`, `1\. not list`}, + {`

a_b*c

`, `a\_b\*c`}, + {"

`literal`

", `\` + "`literal\\`"}, + {`

[not a link]

`, `\[not a link\]`}, + {`

> not a quote

`, `\> not a quote`}, + } + for _, test := range tests { + got, err := convertWebHTMLToMarkdown(test.html) + if err != nil || got != test.want { + t.Errorf("convert %q = (%q, %v), want %q", test.html, got, err, test.want) + } + } +} + +func TestWebFetchHTMLTextFixture(t *testing.T) { + got, err := extractWebHTMLText(`

Hello world

tail
`) + if err != nil || got != "Hello worldtail" { + t.Fatalf("text = (%q, %v)", got, err) + } +} + +func TestWebFetchTruncatesToolOutput(t *testing.T) { + full := strings.Repeat("x", webOutputMaxBytes+1) + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(writer, full) + })) + spill := t.TempDir() + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebOutputDir(ctx, spill) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": server.URL, "format": "text", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Output, "...51201 bytes truncated...") || + !strings.Contains(result.Output, "The tool call succeeded but the output was truncated.") { + t.Fatalf("Output = %q", result.Output) + } + var metadata webFetchMetadata + if err := json.Unmarshal(result.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if !metadata.Truncated || metadata.OutputPath == "" { + t.Fatalf("Metadata = %s", result.Metadata) + } + saved, err := os.ReadFile(metadata.OutputPath) + if err != nil { + t.Fatal(err) + } + if string(saved) != full { + t.Fatalf("saved output length = %d", len(saved)) + } +} + +func TestWebFetchTruncatesWithoutSocket(t *testing.T) { + full := strings.Repeat("x", webOutputMaxBytes+1) + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader(full)), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": "https://fixture.invalid/large", "format": "text", + }) + if err != nil || !strings.Contains(result.Output, "...51201 bytes truncated...") { + t.Fatalf("result = %#v, error = %v", result, err) + } +} + +func TestWebOutputSweepExpiresSevenDayOldSpills(t *testing.T) { + directory := t.TempDir() + now := time.Now() + oldID, err := id.Create("tool", id.AscendingDirection, now.Add(-8*24*time.Hour).UnixMilli()) + if err != nil { + t.Fatal(err) + } + recentID, err := id.Create("tool", id.AscendingDirection, now.Add(-6*24*time.Hour).UnixMilli()) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{oldID, recentID, "unrelated"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("spill"), 0o644); err != nil { + t.Fatal(err) + } + } + registry := New(t.TempDir()) + if _, _, err := registry.truncateWebOutput(WithWebOutputDir(context.Background(), directory), steploop.ToolCall{}, "short"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(directory, oldID)); !os.IsNotExist(err) { + t.Fatalf("expired spill still exists: %v", err) + } + for _, name := range []string{recentID, "unrelated"} { + if _, err := os.Stat(filepath.Join(directory, name)); err != nil { + t.Fatalf("retained file %s: %v", name, err) + } + } +} + +func TestWebFetchResponseAndFailure(t *testing.T) { + registry := New(t.TempDir()) + t.Run("invalid scheme", func(t *testing.T) { + _, err := executeWebTest(t, registry, context.Background(), "webfetch", map[string]any{"url": "ftp://example.com"}) + if err == nil || err.Error() != "URL must start with http:// or https://" { + t.Fatalf("error = %v", err) + } + }) + t.Run("invalid URL", func(t *testing.T) { + _, err := executeWebTest(t, registry, context.Background(), "webfetch", map[string]any{"url": "http://["}) + if err == nil || err.Error() != "InvalidUrl error (GET http://[)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("connection refused shape", func(t *testing.T) { + client := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })} + ctx := WithWebHTTPClient(context.Background(), client) + _, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": "http://127.0.0.1:1/x"}) + if err == nil || err.Error() != "Transport error (GET http://127.0.0.1:1/x)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("non-2xx", func(t *testing.T) { + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusTeapot) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + _, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": server.URL}) + want := "StatusCode error (418 GET " + server.URL + ")" + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) + t.Run("unsupported MIME is decoded", func(t *testing.T) { + // There is no unsupported-content-type rejection: all non-image bodies + // are decoded as text. + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.WriteString(writer, "opaque") + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + result, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": server.URL}) + if err != nil || result.Output != "opaque" { + t.Fatalf("result = %#v, error = %v", result, err) + } + }) + t.Run("declared over 5 MiB", func(t *testing.T) { + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Length", "5242881") + _, _ = io.WriteString(writer, "short") + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + _, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": server.URL}) + if err == nil || err.Error() != "Response too large (exceeds 5MB limit)" { + t.Fatalf("error = %v", err) + } + }) +} + +func TestWebFetchFailuresWithoutSocket(t *testing.T) { + executeResponse := func(status int, headers http.Header, body string) (steploop.ToolResult, error) { + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, Header: headers, + Body: io.NopCloser(strings.NewReader(body)), Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebOutputDir(ctx, t.TempDir()) + return executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": "https://fixture.invalid/value", + }) + } + + _, err := executeResponse(http.StatusBadGateway, http.Header{}, "") + if err == nil || err.Error() != "StatusCode error (502 GET https://fixture.invalid/value)" { + t.Fatalf("status error = %v", err) + } + result, err := executeResponse(http.StatusOK, http.Header{ + "Content-Type": []string{"application/octet-stream"}, + }, "opaque") + if err != nil || result.Output != "opaque" { + t.Fatalf("unsupported MIME result = %#v, error = %v", result, err) + } + _, err = executeResponse(http.StatusOK, http.Header{ + "Content-Length": []string{"5242881"}, + }, "") + if err == nil || err.Error() != "Response too large (exceeds 5MB limit)" { + t.Fatalf("size error = %v", err) + } +} + +func TestWebFetchRedirectBoundaryWithoutSocket(t *testing.T) { + resolver := func(_ context.Context, host string) ([]net.IP, error) { + addresses := map[string]string{ + "public-origin.test": "203.0.113.10", + "public-next.test": "198.51.100.20", + "public-final.test": "192.0.2.30", + "private-origin.test": "10.0.0.10", + "private-next.test": "127.0.0.2", + } + return []net.IP{net.ParseIP(addresses[host])}, nil + } + redirectClient := func(routes map[string]string) *http.Client { + return &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if destination := routes[request.URL.String()]; destination != "" { + return &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{destination}}, + Body: io.NopCloser(strings.NewReader("")), + Request: request, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader("redirected")), + Request: request, + }, nil + })} + } + execute := func(origin string, routes map[string]string) (steploop.ToolResult, error) { + ctx := WithWebHTTPClient(context.Background(), redirectClient(routes)) + ctx = WithWebHostResolver(ctx, resolver) + ctx = WithWebOutputDir(ctx, t.TempDir()) + return executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": origin, "format": "text", + }) + } + + publicOrigin := "https://public-origin.test/start" + publicNext := "https://public-next.test/next" + publicFinal := "https://public-final.test/final" + result, err := execute(publicOrigin, map[string]string{ + publicOrigin: publicNext, + publicNext: publicFinal, + }) + if err != nil || result.Output != "redirected" { + t.Fatalf("public redirect chain = (%#v, %v)", result, err) + } + + for _, destination := range []string{ + "http://127.0.0.1/private", + "http://169.254.169.254/latest/meta-data", + } { + _, err := execute(publicOrigin, map[string]string{publicOrigin: destination}) + want := "Transport error (GET " + destination + ")" + if err == nil || err.Error() != want { + t.Errorf("redirect to %s error = %v, want %q", destination, err, want) + } + } + + privateOrigin := "http://private-origin.test/start" + privateNext := "http://private-next.test/inside" + result, err = execute(privateOrigin, map[string]string{privateOrigin: privateNext}) + if err != nil || result.Output != "redirected" { + t.Fatalf("private-origin redirect = (%#v, %v)", result, err) + } +} diff --git a/internal/seniordev/tool/websearch.go b/internal/seniordev/tool/websearch.go new file mode 100644 index 000000000..0c94b1040 --- /dev/null +++ b/internal/seniordev/tool/websearch.go @@ -0,0 +1,390 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/config" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +const ( + defaultExaWebSearchURL = "https://mcp.exa.ai/mcp" + defaultParallelWebSearchURL = "https://search.parallel.ai/mcp" + webSearchTimeout = 25 * time.Second + webSearchMaxResponseSize = 5 * 1024 * 1024 +) + +const webSearchSchema = `{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "type":"object", + "properties":{ + "query":{"type":"string","description":"Websearch query"}, + "numResults":{"type":"number","description":"Number of search results to return (default: 8)"}, + "livecrawl":{"type":"string","enum":["fallback","preferred"],"description":"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')"}, + "type":{"type":"string","enum":["auto","fast","deep"],"description":"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"}, + "contextMaxCharacters":{"type":"number","description":"Maximum characters for context string optimized for LLMs (default: 10000)"} + }, + "required":["query"] +}` + +type webSearchInput struct { + Query string `json:"query"` + NumResults *float64 `json:"numResults,omitempty"` + Livecrawl string `json:"livecrawl,omitempty"` + Type string `json:"type,omitempty"` + ContextMaxCharacters *float64 `json:"contextMaxCharacters,omitempty"` +} + +type webSearchMetadata struct { + Provider string `json:"provider"` + Truncated bool `json:"truncated"` + OutputPath string `json:"outputPath,omitempty"` +} + +func webSearchDescription() string { + // Only the first {{year}} placeholder is replaced, each time the + // description is read. + return strings.Replace(webSearchDescriptionTemplate, "{{year}}", strconv.Itoa(time.Now().Year()), 1) +} + +func validateWebSearch(raw json.RawMessage) error { + var input webSearchInput + if err := decodeWebInput(raw, &input, []string{"query", "numResults", "livecrawl", "type", "contextMaxCharacters"}, "query"); err != nil { + return err + } + if input.Livecrawl != "" && input.Livecrawl != "fallback" && input.Livecrawl != "preferred" { + return fmt.Errorf("livecrawl must be fallback or preferred") + } + if input.Type != "" && input.Type != "auto" && input.Type != "fast" && input.Type != "deep" { + return fmt.Errorf("type must be auto, fast, or deep") + } + return nil +} + +// CurrentWebSearchFlags reads the search-backend switches: SENIOR_DEV_EXPERIMENTAL +// (which enables Exa), SENIOR_DEV_ENABLE_EXA or SENIOR_DEV_EXPERIMENTAL_EXA, and +// SENIOR_DEV_ENABLE_PARALLEL or SENIOR_DEV_EXPERIMENTAL_PARALLEL. API keys are +// intentionally not feature flags. +func CurrentWebSearchFlags() WebSearchFlags { + truthy := func(name string) bool { + return config.ParseBoolean(config.Truthy, environmentValue(name)) + } + experimental := truthy("SENIOR_DEV_EXPERIMENTAL") + return WebSearchFlags{ + Exa: experimental || truthy("SENIOR_DEV_ENABLE_EXA") || truthy("SENIOR_DEV_EXPERIMENTAL_EXA"), + Parallel: truthy("SENIOR_DEV_ENABLE_PARALLEL") || truthy("SENIOR_DEV_EXPERIMENTAL_PARALLEL"), + } +} + +func (r *Registry) executeWebSearch(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input webSearchInput + if err := decodeWebInput(call.Input, &input, []string{"query", "numResults", "livecrawl", "type", "contextMaxCharacters"}, "query"); err != nil { + return steploop.ToolResult{}, err + } + provider := selectWebSearchProvider(call.SessionID, CurrentWebSearchFlags()) + label := webSearchProviderLabel(provider) + permissionMetadata := map[string]any{"query": input.Query, "provider": provider} + if input.NumResults != nil { + permissionMetadata["numResults"] = *input.NumResults + } + if input.Livecrawl != "" { + permissionMetadata["livecrawl"] = input.Livecrawl + } + if input.Type != "" { + permissionMetadata["type"] = input.Type + } + if input.ContextMaxCharacters != nil { + permissionMetadata["contextMaxCharacters"] = *input.ContextMaxCharacters + } + if err := r.ask(ctx, call, "websearch", []string{input.Query}, permissionMetadata); err != nil { + return steploop.ToolResult{}, err + } + + result, err := callWebSearchProvider(ctx, provider, input, call) + if err != nil { + return steploop.ToolResult{}, err + } + if result == "" { + result = "No search results found. Please try a different query." + } + output, truncation, err := r.truncateWebOutput(ctx, call, result) + if err != nil { + return steploop.ToolResult{}, err + } + return steploop.ToolResult{ + Title: label + ": " + input.Query, Output: output, + Metadata: rawMetadata(webSearchMetadata{ + Provider: provider, Truncated: truncation.Truncated, OutputPath: truncation.OutputPath, + }), + }, nil +} + +func selectWebSearchProvider(sessionID string, flags WebSearchFlags) string { + // The env override wins, then Parallel, then Exa, and finally a stable + // per-session split. + if override := os.Getenv("SENIOR_DEV_WEBSEARCH_PROVIDER"); override == "exa" || override == "parallel" { + return override + } + if flags.Parallel { + return "parallel" + } + if flags.Exa { + return "exa" + } + if fnv1aUTF16(sessionID)%2 == 0 { + return "exa" + } + return "parallel" +} + +// fnv1aUTF16 hashes a session id over its UTF-16 code units so the provider +// split is stable for the life of the session. An empty id hashes to 0. +func fnv1aUTF16(value string) uint32 { + if value == "" { + return 0 + } + hash := uint32(0x811c9dc5) + for _, unit := range utf16.Encode([]rune(value)) { + hash ^= uint32(unit) + hash *= 0x01000193 + } + return hash +} + +func webSearchProviderLabel(provider string) string { + if provider == "parallel" { + return "Parallel Web Search" + } + if provider == "exa" { + return "Exa Web Search" + } + return "Web Search" +} + +func callWebSearchProvider( + ctx context.Context, + provider string, + input webSearchInput, + call steploop.ToolCall, +) (string, error) { + options := webOptions(ctx) + endpoint := options.exaURL + toolName := "web_search_exa" + arguments := map[string]any{ + "query": input.Query, + "type": valueOr(input.Type, "auto"), + "numResults": nonzeroOr(input.NumResults, 8), + "livecrawl": valueOr(input.Livecrawl, "fallback"), + } + if input.ContextMaxCharacters != nil { + arguments["contextMaxCharacters"] = *input.ContextMaxCharacters + } + headers := map[string]string{} + if endpoint == "" { + endpoint = defaultExaWebSearchURL + } + if key := os.Getenv("EXA_API_KEY"); provider == "exa" && key != "" { + // An Exa key, when present, goes in the query string; otherwise the + // public endpoint is called unchanged. + separator := "?" + if strings.Contains(endpoint, "?") { + separator = "&" + } + endpoint += separator + "exaApiKey=" + encodeURIComponent(key) + } + if provider == "parallel" { + endpoint = options.parallelURL + if endpoint == "" { + endpoint = defaultParallelWebSearchURL + } + toolName = "web_search" + arguments = map[string]any{ + "objective": input.Query, "search_queries": []string{input.Query}, + "session_id": call.SessionID, + } + if call.ModelID != "" { + arguments["model_name"] = firstRunes(call.ModelID, 100) + } + version := options.version + if version == "" { + version = "local" + } + headers["User-Agent"] = "senior-dev/" + version + if key := os.Getenv("PARALLEL_API_KEY"); key != "" { + headers["Authorization"] = "Bearer " + key + } + } + return callMCPWebSearch(ctx, endpoint, toolName, arguments, headers) +} + +func callMCPWebSearch( + ctx context.Context, + endpoint, toolName string, + arguments map[string]any, + headers map[string]string, +) (string, error) { + // Refuse up front with the model-facing policy error rather than the + // transport's, which would otherwise surface wrapped in a generic fetch + // failure. The transport wrap in webClient repeats the refusal as a + // fast-fail backstop. + if policy := netpolicy.Current(); policy.Restricted() { + host := endpoint + if parsed, parseErr := url.Parse(endpoint); parseErr == nil { + host = parsed.Host + } + return "", policy.HostError(host) + } + payload := struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + }{JSONRPC: "2.0", ID: 1, Method: "tools/call"} + payload.Params.Name = toolName + payload.Params.Arguments = arguments + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + requestCtx, cancel := context.WithTimeout(ctx, webSearchTimeout) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("InvalidUrl error (POST %s)", endpoint) + } + request.Header.Set("Accept", "application/json, text/event-stream") + request.Header.Set("Content-Type", "application/json") + for name, value := range headers { + request.Header.Set(name, value) + } + response, err := webClient(ctx).Do(request) + if err != nil { + // Surface a policy refusal (initial host or redirect hop) with its + // no-retry framing instead of a retryable-looking transport failure. + var policyBlocked *netpolicy.BlockedError + if errors.As(err, &policyBlocked) { + return "", policyBlocked + } + if requestCtx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("%s request timed out", toolName) + } + return "", transportError(http.MethodPost, endpoint) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", statusCodeError(http.MethodPost, endpoint, response.StatusCode) + } + if declared := response.Header.Get("Content-Length"); declared != "" { + if size, parseErr := strconv.ParseInt(declared, 10, 64); parseErr == nil && size > webSearchMaxResponseSize { + return "", fmt.Errorf("Response too large (exceeds 5MB limit)") + } + } + raw, err := io.ReadAll(io.LimitReader(response.Body, webSearchMaxResponseSize+1)) + if err != nil { + return "", err + } + if len(raw) > webSearchMaxResponseSize { + return "", fmt.Errorf("Response too large (exceeds 5MB limit)") + } + // The endpoint may answer with a direct JSON-RPC object or with SSE data + // lines; the first non-empty content text wins. + return parseMCPWebSearchResponse(string(raw)) +} + +func parseMCPWebSearchResponse(body string) (string, error) { + trimmed := strings.TrimSpace(body) + if strings.HasPrefix(trimmed, "{") { + return parseMCPWebSearchPayload(trimmed) + } + for _, line := range strings.Split(body, "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + result, err := parseMCPWebSearchPayload(line[6:]) + if err != nil || result != "" { + return result, err + } + } + return "", nil +} + +func parseMCPWebSearchPayload(payload string) (string, error) { + trimmed := strings.TrimSpace(payload) + if !strings.HasPrefix(trimmed, "{") { + return "", nil + } + var value struct { + Result *struct { + Content []struct { + Type *string `json:"type"` + Text *string `json:"text"` + } `json:"content"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(trimmed), &value); err != nil { + return "", err + } + if value.Result == nil || value.Result.Content == nil { + return "", fmt.Errorf("invalid MCP response") + } + for _, item := range value.Result.Content { + if item.Type == nil || item.Text == nil { + return "", fmt.Errorf("invalid MCP response") + } + } + for _, item := range value.Result.Content { + if *item.Text != "" { + return *item.Text, nil + } + } + return "", nil +} + +func valueOr(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func nonzeroOr(value *float64, fallback float64) float64 { + if value == nil || *value == 0 { + return fallback + } + return *value +} + +func encodeURIComponent(value string) string { + const hexadecimal = "0123456789ABCDEF" + var output strings.Builder + for index := 0; index < len(value); index++ { + character := value[index] + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || strings.ContainsRune("-_.!~*'()", rune(character)) { + output.WriteByte(character) + continue + } + output.WriteByte('%') + output.WriteByte(hexadecimal[character>>4]) + output.WriteByte(hexadecimal[character&15]) + } + return output.String() +} diff --git a/internal/seniordev/tool/websearch.txt b/internal/seniordev/tool/websearch.txt new file mode 100644 index 000000000..ad5238cbd --- /dev/null +++ b/internal/seniordev/tool/websearch.txt @@ -0,0 +1,14 @@ +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs +- Provides up-to-date information for current events and recent data +- Supports configurable result counts and returns the content from the most relevant websites +- Use this tool for accessing information beyond knowledge cutoff +- Searches are performed automatically within a single API call + +Usage notes: + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Configurable context length for optimal LLM integration + - Domain filtering and advanced search options available + +The current year is {{year}}. You MUST use this year when searching for recent information or current events +- Example: If the current year is 2026 and the user asks for "latest AI news", search for "AI news 2026", NOT "AI news 2025" diff --git a/internal/seniordev/tool/websearch_test.go b/internal/seniordev/tool/websearch_test.go new file mode 100644 index 000000000..34a6b01d9 --- /dev/null +++ b/internal/seniordev/tool/websearch_test.go @@ -0,0 +1,272 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +func TestWebSearchFullExecutionWithoutAPIKey(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + t.Setenv("EXA_API_KEY", "") + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.RawQuery != "" { + t.Errorf("unexpected API key query: %q", request.URL.RawQuery) + } + if request.Header.Get("Accept") != "application/json, text/event-stream" { + t.Errorf("Accept = %q", request.Header.Get("Accept")) + } + var payload struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.JSONRPC != "2.0" || payload.Method != "tools/call" || payload.Params.Name != "web_search_exa" { + t.Errorf("payload = %#v", payload) + } + if payload.Params.Arguments["query"] != "go tools" || payload.Params.Arguments["numResults"] != float64(8) || + payload.Params.Arguments["type"] != "auto" || payload.Params.Arguments["livecrawl"] != "fallback" { + t.Errorf("arguments = %#v", payload.Params.Arguments) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"result":{"content":[{"type":"text","text":"first result"}]}}`) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebSearchEndpoints(ctx, server.URL, "") + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "go tools"}) + if err != nil { + t.Fatal(err) + } + if result.Output != "first result" || result.Title != "Exa Web Search: go tools" { + t.Fatalf("result = %#v", result) + } + if string(result.Metadata) != `{"provider":"exa","truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestWebSearchFullExecutionWithoutSocket(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodPost || request.Header.Get("Accept") != "application/json, text/event-stream" { + t.Errorf("request = %s, Accept = %q", request.Method, request.Header.Get("Accept")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"result":{"content":[{"type":"text","text":"fixture result"}]}}`, + )), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "https://fixture.invalid/mcp", "") + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "fixture"}) + if err != nil || result.Output != "fixture result" { + t.Fatalf("result = %#v, error = %v", result, err) + } +} + +func TestParallelWebSearchSendsModelNameAndFinalMetadata(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "parallel") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload struct { + Params struct { + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.Params.Arguments["model_name"] != "fixture/model" { + t.Errorf("parallel arguments = %#v", payload.Params.Arguments) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"result":{"content":[{"type":"text","text":"parallel result"}]}}`, + )), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "", "https://parallel.test/mcp") + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "fixture"}) + if err != nil || result.Output != "parallel result" || string(result.Metadata) != `{"provider":"parallel","truncated":false}` { + t.Fatalf("parallel result = (%#v, %v)", result, err) + } +} + +func TestWebSearchResponseIsCappedAtFiveMiB(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(strings.Repeat("x", webSearchMaxResponseSize+1))), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "https://exa.test/mcp", "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "large"}) + if err == nil || err.Error() != "Response too large (exceeds 5MB limit)" { + t.Fatalf("oversized search error = %v", err) + } +} + +func TestWebSearchRegistrationGateAndDescriptions(t *testing.T) { + registry := New(t.TempDir()) + definitions := registry.Definitions() + all := definitionNames(definitions) + if !containsName(all, "webfetch") || !containsName(all, "websearch") { + t.Fatalf("Definitions = %v", all) + } + withoutBackend := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "claude", + })) + if !containsName(withoutBackend, "webfetch") || containsName(withoutBackend, "websearch") { + t.Fatalf("without backend = %v", withoutBackend) + } + seniorDev := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "senior-dev", ModelID: "claude", + })) + if !containsName(seniorDev, "websearch") { + t.Fatalf("senior-dev = %v", seniorDev) + } + exa := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "claude", Flags: WebSearchFlags{Exa: true}, + })) + if !containsName(exa, "websearch") { + t.Fatalf("exa = %v", exa) + } + for _, definition := range definitions { + switch definition.Provider.Name { + case "webfetch": + if definition.Provider.Description != webFetchDescription { + t.Fatal("webfetch description differs from embedded bytes") + } + case "websearch": + if definition.Provider.Description != webSearchDescription() { + t.Fatal("websearch description did not substitute the current year") + } + } + } +} + +func TestCurrentWebSearchFlags(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, "") + } + if got := CurrentWebSearchFlags(); got != (WebSearchFlags{}) { + t.Fatalf("empty flags = %#v", got) + } + t.Setenv("SENIOR_DEV_EXPERIMENTAL", "TRUE") + if got := CurrentWebSearchFlags(); !got.Exa || got.Parallel { + t.Fatalf("experimental flags = %#v", got) + } + t.Setenv("SENIOR_DEV_EXPERIMENTAL", "") + t.Setenv("SENIOR_DEV_EXPERIMENTAL_PARALLEL", "1") + if got := CurrentWebSearchFlags(); got.Exa || !got.Parallel { + t.Fatalf("parallel alias flags = %#v", got) + } +} + +func TestWebSearchProviderAndResponse(t *testing.T) { + t.Run("encodeURIComponent API key", func(t *testing.T) { + input := "a b!~*'()+/?=:&" + if got, want := encodeURIComponent(input), "a%20b!~*'()%2B%2F%3F%3D%3A%26"; got != want { + t.Fatalf("encoded = %q, want %q", got, want) + } + }) + t.Run("provider priority", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "") + if got := selectWebSearchProvider("session", WebSearchFlags{Exa: true, Parallel: true}); got != "parallel" { + t.Fatalf("provider = %q", got) + } + }) + t.Run("SSE", func(t *testing.T) { + got, err := parseMCPWebSearchResponse("event: message\ndata: {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"from sse\"}]}}\n") + if err != nil || got != "from sse" { + t.Fatalf("parse = (%q, %v)", got, err) + } + }) + t.Run("empty fallback", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(writer, `{"result":{"content":[]}}`) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebSearchEndpoints(ctx, server.URL, "") + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "none"}) + if err != nil || result.Output != "No search results found. Please try a different query." { + t.Fatalf("result = %#v, error = %v", result, err) + } + }) + t.Run("non-2xx", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusUnauthorized) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebSearchEndpoints(ctx, server.URL, "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "denied"}) + want := "StatusCode error (401 POST " + server.URL + ")" + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) + t.Run("connection refused shape", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "http://127.0.0.1:1/mcp", "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "offline"}) + if err == nil || err.Error() != "Transport error (POST http://127.0.0.1:1/mcp)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("non-2xx without socket", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, Header: http.Header{}, + Body: io.NopCloser(strings.NewReader("denied")), Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "https://fixture.invalid/mcp", "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "denied"}) + if err == nil || err.Error() != "StatusCode error (403 POST https://fixture.invalid/mcp)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("malformed MCP response", func(t *testing.T) { + _, err := parseMCPWebSearchResponse(`{"result":{}}`) + if err == nil || !strings.Contains(err.Error(), "invalid MCP response") { + t.Fatalf("error = %v", err) + } + }) +} diff --git a/internal/seniordev/tool/write.go b/internal/seniordev/tool/write.go new file mode 100644 index 000000000..4dcb10d00 --- /dev/null +++ b/internal/seniordev/tool/write.go @@ -0,0 +1,106 @@ +//go:build !windows + +// The write tool: whole-file writes that preserve an existing BOM, run the +// configured formatter, and report a unified diff of the change. +package tool + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + patchpkg "github.com/Agent-Field/codeaf/internal/seniordev/patch" + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type writeMetadata struct { + Diagnostics map[string]any `json:"diagnostics"` + Diff string `json:"diff"` + FilePath string `json:"filepath"` + Exists bool `json:"exists"` +} + +func (r *Registry) executeWrite(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input writeInput + if err := decodeInput(call.Input, &input, "content", "filePath"); err != nil { + return steploop.ToolResult{}, err + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + + resolved, err := r.resolvePath(input.FilePath) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, resolved, "file"); err != nil { + return steploop.ToolResult{}, err + } + formatter, err := r.formatterService() + if err != nil { + return steploop.ToolResult{}, err + } + source, readErr := os.ReadFile(resolved) + exists := readErr == nil + if readErr != nil && !os.IsNotExist(readErr) { + return steploop.ToolResult{}, readErr + } + sourceBOM, contentOld := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + nextBOM, content := splitBOM(input.Content) + desiredBOM := sourceBOM || nextBOM + proposedDiff := proposedFileDiff(resolved, contentOld, content) + pattern, relErr := filepath.Rel(r.worktree(), resolved) + if relErr != nil { + pattern = resolved + } + metadata := map[string]any{"filepath": resolved, "diff": proposedDiff} + if err := r.ask(ctx, call, "edit", []string{filepath.ToSlash(pattern)}, metadata); err != nil { + return steploop.ToolResult{}, err + } + + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(resolved, []byte(joinBOM(content, desiredBOM)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + content, err = formatMutationFile(ctx, formatter, resolved, desiredBOM) + if err != nil { + return steploop.ToolResult{}, err + } + + util.EagerCommit(ctx, util.EagerCommitOptions{Cwd: r.workDir, FilePath: resolved, Label: "write"}) + + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + diff := TrimDiff(patchpkg.GenerateTwoFilesPatch(resolved, contentOld, content)) + return steploop.ToolResult{ + Title: title, + Output: "Wrote file successfully.", + Metadata: rawMetadata(writeMetadata{ + Diagnostics: map[string]any{}, + Diff: diff, + FilePath: resolved, + Exists: exists, + }), + }, nil +} + +func splitBOM(value string) (bool, string) { + if strings.HasPrefix(value, "\ufeff") { + return true, strings.TrimPrefix(value, "\ufeff") + } + return false, value +} + +func joinBOM(value string, bom bool) string { + _, stripped := splitBOM(value) + if bom { + return "\ufeff" + stripped + } + return stripped +} diff --git a/internal/seniordev/tool/write_test.go b/internal/seniordev/tool/write_test.go new file mode 100644 index 000000000..b0ba24dc8 --- /dev/null +++ b/internal/seniordev/tool/write_test.go @@ -0,0 +1,68 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +func TestWriteBOMAndMetadata(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + path := filepath.Join(workDir, "nested", "file.txt") + + result, err := execute(t, registry, "write", map[string]any{ + "content": "\ufefffirst", + "filePath": "nested/file.txt", + }) + if err != nil { + t.Fatalf("Execute add: %v", err) + } + if result.Title != filepath.Join("nested", "file.txt") || result.Output != "Wrote file successfully." { + t.Fatalf("result = %#v", result) + } + // write results expose the unified diff of the actual change. + diffPrefix := "Index: " + path + "\n===================================================================\n--- " + path + "\n+++ " + path + "\n" + wantMetadata := `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -0,0 +1,1 @@\n+first\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":false}` + if string(result.Metadata) != wantMetadata { + t.Fatalf("Metadata = %s, want %s", result.Metadata, wantMetadata) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufefffirst" { + t.Fatalf("content = %q", data) + } + + result, err = execute(t, registry, "write", map[string]any{ + "content": "second", + "filePath": "nested/file.txt", + }) + if err != nil { + t.Fatalf("Execute overwrite: %v", err) + } + wantMetadata = `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -1,1 +1,1 @@\n-first\n\\ No newline at end of file\n+second\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":true}` + if string(result.Metadata) != wantMetadata { + t.Fatalf("Metadata = %s", result.Metadata) + } + data, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufeffsecond" { + t.Fatalf("existing BOM was not preserved: %q", data) + } +} + +func quotedJSON(value string) string { + data, err := jsonutil.Marshal(value) + if err != nil { + panic(err) + } + return string(data) +} diff --git a/internal/seniordev/util/eagercommit.go b/internal/seniordev/util/eagercommit.go new file mode 100644 index 000000000..1aafc1470 --- /dev/null +++ b/internal/seniordev/util/eagercommit.go @@ -0,0 +1,74 @@ +//go:build !windows + +// Eager per-write git checkpoint +package util + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + + "github.com/Agent-Field/codeaf/internal/seniordev/attribution" +) + +// skipEagerCommit is read on every write and set from two places: the +// environment at startup, and the run once it knows which workspace recorder +// it is using. Atomic because the write happens before the run starts while +// the reads happen on tool goroutines. +var skipEagerCommit atomic.Bool + +func init() { skipEagerCommit.Store(os.Getenv("SENIOR_DEV_EAGER_COMMIT") == "0") } + +// DisableEagerCommit turns off the per-write checkpoint for the rest of the +// process. A run whose recorder keeps its own snapshots does not want commits +// in the workspace -- under --in-place that workspace may be a repository the +// run has no business writing history into. Call it before the run starts. +func DisableEagerCommit() { skipEagerCommit.Store(true) } + +type EagerCommitOptions struct { + Cwd string + FilePath string + Label string +} + +func EagerCommit(ctx context.Context, options EagerCommitOptions) { + if skipEagerCommit.Load() { + return + } + defer func() { _ = recover() }() + inRepo, _ := RunProcess(ctx, []string{"git", "rev-parse", "--is-inside-work-tree"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: options.Cwd}, NoThrow: true, + }) + if inRepo.Code != 0 { + return + } + rootResult, _ := RunProcess(ctx, []string{"git", "rev-parse", "--show-toplevel"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: options.Cwd}, NoThrow: true, + }) + root := options.Cwd + if rootResult.Code == 0 { + root = strings.TrimSpace(string(rootResult.Stdout)) + } + relative, _ := filepath.Rel(root, options.FilePath) + if relative == "" { + relative = options.FilePath + } + add, _ := RunProcess(ctx, []string{"git", "add", "--", relative}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, + }) + if add.Code != 0 { + return + } + diff, _ := RunProcess(ctx, []string{"git", "diff", "--cached", "--quiet", "--", relative}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, + }) + if diff.Code == 0 { + return + } + message := attribution.AppendCommitTrailer("wip(" + options.Label + "): " + relative) + _, _ = RunProcess(ctx, attribution.GitArgv( + "commit", "-m", message, "--no-verify", "--only", "--", relative, + ), RunOptions{ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true}) +} diff --git a/internal/seniordev/util/error.go b/internal/seniordev/util/error.go new file mode 100644 index 000000000..1807272f3 --- /dev/null +++ b/internal/seniordev/util/error.go @@ -0,0 +1,230 @@ +//go:build !windows + +// Error formatting +package util + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "runtime/debug" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// StackError may supply a pre-recorded stack trace. +type StackError interface { + error + Stack() string +} + +func ErrorFormat(value any) string { + if err, ok := value.(error); ok { + if stack, ok := err.(StackError); ok && stack.Stack() != "" { + return stack.Stack() + } + name := reflect.TypeOf(err).String() + name = strings.TrimPrefix(name, "*") + if named, ok := err.(interface{ ErrorName() string }); ok { + name = named.ErrorName() + } + return name + ": " + err.Error() + } + if IsRecord(value) { + data, err := jsonutil.MarshalIndent(value) + if err != nil { + return "Unexpected error (unserializable)" + } + if string(data) == "{}" { + t := reflect.TypeOf(value) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + prefix := t.Name() + if prefix == "" { + prefix = "Error" + } + names := ownPropertyNames(value) + if len(names) == 0 { + return prefix + " (no message)" + } + return prefix + " { " + strings.Join(names, ", ") + " }" + } + return string(data) + } + return stringifyValue(value) +} + +func ErrorMessage(value any) string { + if err, ok := value.(error); ok { + if err.Error() != "" { + return err.Error() + } + name := reflect.TypeOf(err).String() + if name != "" { + return strings.TrimPrefix(name, "*") + } + } + if record, ok := stringMap(value); ok { + if message, ok := record["message"].(string); ok && message != "" { + return message + } + if data, ok := stringMap(record["data"]); ok { + if message, ok := data["message"].(string); ok && message != "" { + return message + } + } + } + text := stringifyValue(value) + if text != "" && text != "[object Object]" { + return text + } + if formatted := ErrorFormat(value); formatted != "" { + return formatted + } + return "unknown error" +} + +func ErrorData(value any) map[string]any { + if err, ok := value.(error); ok { + name := reflect.TypeOf(err).String() + name = strings.TrimPrefix(name, "*") + out := map[string]any{ + "type": name, + "message": ErrorMessage(err), + "formatted": ErrorFormat(err), + } + if stack, ok := err.(StackError); ok { + out["stack"] = stack.Stack() + } + if cause := errors.Unwrap(err); cause != nil { + out["cause"] = ErrorFormat(cause) + } + return out + } + if !IsRecord(value) { + return map[string]any{ + "type": valueTypeName(value), + "message": ErrorMessage(value), + "formatted": ErrorFormat(value), + } + } + out := map[string]any{} + if record, ok := stringMap(value); ok { + for key, item := range record { + switch typed := item.(type) { + case nil: + out[key] = "null" + case string, float64, float32, int, int64, bool: + out[key] = typed + case error: + out[key] = typed.Error() + default: + out[key] = stringifyValue(typed) + } + } + } + if _, ok := out["message"].(string); !ok { + out["message"] = ErrorMessage(value) + } + if _, ok := out["type"].(string); !ok { + t := reflect.TypeOf(value) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + name := t.Name() + if name == "" && t.Kind() == reflect.Map { + name = "Object" + } + out["type"] = name + } + out["formatted"] = ErrorFormat(value) + return out +} + +// stringifyValue renders a value the way error payloads expect: nil as +// "null", records as "[object Object]", scalars as their plain text. +func stringifyValue(value any) string { + switch typed := value.(type) { + case nil: + return "null" + case string: + return typed + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 64) + case error: + return typed.Error() + } + if IsRecord(value) { + return "[object Object]" + } + return fmt.Sprint(value) +} + +// valueTypeName is the coarse type label reported for non-record error +// values; nil and unknown kinds report "object". +func valueTypeName(value any) string { + switch value.(type) { + case nil: + return "object" + case string: + return "string" + case bool: + return "boolean" + case float64, float32, int, int64, uint, uint64: + return "number" + case func(): + return "function" + default: + return "object" + } +} + +func stringMap(value any) (map[string]any, bool) { + if direct, ok := value.(map[string]any); ok { + return direct, true + } + data, err := json.Marshal(value) + if err != nil { + return nil, false + } + var out map[string]any + if json.Unmarshal(data, &out) != nil { + return nil, false + } + return out, out != nil +} + +func ownPropertyNames(value any) []string { + v := reflect.ValueOf(value) + for v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() == reflect.Map { + out := []string{} + iter := v.MapRange() + for iter.Next() { + out = append(out, fmt.Sprint(iter.Key().Interface())) + } + return out + } + if v.Kind() == reflect.Struct { + out := []string{} + for i := 0; i < v.NumField(); i++ { + out = append(out, v.Type().Field(i).Name) + } + return out + } + return nil +} + +// CaptureStack provides a convenient StackError stack for callers that need +// ErrorFormat's stack branch. +func CaptureStack() string { return string(debug.Stack()) } diff --git a/internal/seniordev/util/filesystem.go b/internal/seniordev/util/filesystem.go new file mode 100644 index 000000000..88b70dea4 --- /dev/null +++ b/internal/seniordev/util/filesystem.go @@ -0,0 +1,199 @@ +//go:build !windows + +// Filesystem helpers +package util + +import ( + "encoding/json" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/seniordev/core" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func IsDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func Stat(path string) (os.FileInfo, bool) { + info, err := os.Stat(path) + return info, err == nil +} + +func StatAsync(path string) (os.FileInfo, bool, error) { + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return info, err == nil, err +} + +func Size(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +func ReadText(path string) (string, error) { + data, err := os.ReadFile(path) + return string(data), err +} + +func ReadJSON(path string, dst any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} + +func ReadBytes(path string) ([]byte, error) { return os.ReadFile(path) } + +func Write(path string, content []byte, mode ...fs.FileMode) error { + permission := fs.FileMode(0o666) + if len(mode) > 0 && mode[0] != 0 { + permission = mode[0] + } + err := os.WriteFile(path, content, permission) + if errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil { + return err + } + return os.WriteFile(path, content, permission) + } + return err +} + +func WriteText(path, content string, mode ...fs.FileMode) error { + return Write(path, []byte(content), mode...) +} + +func WriteJSON(path string, data any, mode ...fs.FileMode) error { + content, err := jsonutil.MarshalIndent(data) + if err != nil { + return err + } + return Write(path, content, mode...) +} + +func WriteStream(path string, stream io.Reader, mode ...fs.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil { + return err + } + file, err := os.Create(path) + if err != nil { + return err + } + _, copyErr := io.Copy(file, stream) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if len(mode) > 0 && mode[0] != 0 { + return os.Chmod(path, mode[0]) + } + return nil +} + +func FileMimeType(path string) string { return core.MimeType(path) } +func NormalizePath(path string) string { return core.NormalizePath(path) } +func NormalizePathPattern(path string) string { return core.NormalizePathPattern(path) } +func WindowsPath(path string) string { return core.WindowsPath(path) } +func Overlaps(a, b string) bool { return core.Overlaps(a, b) } +func Contains(parent, child string) bool { return core.Contains(parent, child) } +func ResolvePath(path string) (string, error) { return core.Resolve(path) } + +type FindUpOptions struct { + RootFirst bool +} + +func FindUp(targets []string, start string, stop string, options ...FindUpOptions) []string { + dirs := []string{start} + current := start + for { + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + dirs = append(dirs, parent) + current = parent + } + if len(options) > 0 && options[0].RootFirst { + for left, right := 0, len(dirs)-1; left < right; left, right = left+1, right-1 { + dirs[left], dirs[right] = dirs[right], dirs[left] + } + } + result := []string{} + for _, dir := range dirs { + for _, target := range targets { + search := filepath.Join(dir, target) + if Exists(search) { + result = append(result, search) + } + } + } + return result +} + +func Up(targets []string, start, stop string) []string { + result := []string{} + current := start + for { + for _, target := range targets { + search := filepath.Join(current, target) + if Exists(search) { + result = append(result, search) + } + } + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result +} + +func GlobUp(pattern, start, stop string) []string { + filesystem := core.NewFileSystem() + result := []string{} + current := start + for { + matches, err := filesystem.Glob(pattern, core.GlobOptions{ + Cwd: current, Absolute: true, Dot: true, + }) + if err == nil { + result = append(result, matches...) + } + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result +} diff --git a/internal/seniordev/util/gitexclude.go b/internal/seniordev/util/gitexclude.go new file mode 100644 index 000000000..0539f2285 --- /dev/null +++ b/internal/seniordev/util/gitexclude.go @@ -0,0 +1,69 @@ +//go:build !windows + +// Git workflow-artifact exclusion +package util + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" +) + +var ExcludedPaths = []string{ + ".senior-dev/", +} + +const excludeSentinel = "# senior-dev: workflow artifacts (managed by senior-dev)" + +func EnsureSeniorDevExcluded(ctx context.Context, workspace string) (bool, error) { + result, err := RunProcess(ctx, []string{"git", "rev-parse", "--git-dir"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: workspace}, + NoThrow: true, + }) + if err != nil || result.Code != 0 { + return false, nil + } + gitDirText := strings.TrimSpace(string(result.Stdout)) + if gitDirText == "" { + return false, nil + } + gitDir := gitDirText + if !filepath.IsAbs(gitDir) { + gitDir, _ = filepath.Abs(filepath.Join(workspace, gitDir)) + } + infoDir := filepath.Join(gitDir, "info") + _ = os.MkdirAll(infoDir, 0o777) + excludePath := filepath.Join(infoDir, "exclude") + currentBytes, err := os.ReadFile(excludePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + currentBytes = nil + } + current := string(currentBytes) + existing := map[string]bool{} + for _, line := range strings.Split(current, "\n") { + line = strings.TrimSpace(line) + if line != "" { + existing[line] = true + } + } + missing := []string{} + for _, path := range ExcludedPaths { + if !existing[path] { + missing = append(missing, path) + } + } + if len(missing) == 0 { + return true, nil + } + addition := "" + if current != "" && !strings.HasSuffix(current, "\n") { + addition = "\n" + } + addition += excludeSentinel + "\n" + strings.Join(missing, "\n") + "\n" + if err := os.WriteFile(excludePath, []byte(current+addition), 0o666); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/seniordev/util/gitutils_test.go b/internal/seniordev/util/gitutils_test.go new file mode 100644 index 000000000..816ceb630 --- /dev/null +++ b/internal/seniordev/util/gitutils_test.go @@ -0,0 +1,79 @@ +//go:build !windows + +package util + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func gitTestRun(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } + return string(output) +} + +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitTestRun(t, dir, "init", "-q") + gitTestRun(t, dir, "config", "user.email", "test@example.com") + gitTestRun(t, dir, "config", "user.name", "Test") + return dir +} + +func TestEnsureSeniorDevExcludedIdempotent(t *testing.T) { + dir := initGitRepo(t) + ok, err := EnsureSeniorDevExcluded(context.Background(), dir) + if err != nil || !ok { + t.Fatalf("ensure = %v, %v", ok, err) + } + ok, err = EnsureSeniorDevExcluded(context.Background(), dir) + if err != nil || !ok { + t.Fatalf("second ensure = %v, %v", ok, err) + } + data, err := os.ReadFile(filepath.Join(dir, ".git", "info", "exclude")) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Count(text, excludeSentinel) != 1 { + t.Fatalf("exclude:\n%s", text) + } + for _, path := range ExcludedPaths { + if strings.Count(text, path+"\n") != 1 { + t.Fatalf("%q count in:\n%s", path, text) + } + } +} + +func TestEagerCommit(t *testing.T) { + dir := initGitRepo(t) + file := filepath.Join(dir, "file.txt") + if err := os.WriteFile(file, []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTestRun(t, dir, "add", "file.txt") + gitTestRun(t, dir, "commit", "-qm", "initial") + if err := os.WriteFile(file, []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + + previous := skipEagerCommit.Load() + skipEagerCommit.Store(false) + defer func() { skipEagerCommit.Store(previous) }() + EagerCommit(context.Background(), EagerCommitOptions{Cwd: dir, FilePath: file, Label: "write"}) + subject := strings.TrimSpace(gitTestRun(t, dir, "log", "-1", "--pretty=%s")) + if subject != "wip(write): file.txt" { + t.Fatalf("subject = %q", subject) + } +} diff --git a/internal/seniordev/util/localcontext.go b/internal/seniordev/util/localcontext.go new file mode 100644 index 000000000..f0c450c3b --- /dev/null +++ b/internal/seniordev/util/localcontext.go @@ -0,0 +1,48 @@ +//go:build !windows + +// Local context: typed values carried on an explicit context.Context, the +// native propagation mechanism across goroutines. +package util + +import ( + "context" + "fmt" +) + +type ContextNotFound struct{ Name string } + +func (e *ContextNotFound) Error() string { return "No context found for " + e.Name } + +type localContextKey[T any] struct{ owner *LocalContext[T] } + +type LocalContext[T any] struct { + Name string + key localContextKey[T] +} + +func CreateLocalContext[T any](name string) *LocalContext[T] { + local := &LocalContext[T]{Name: name} + local.key.owner = local + return local +} + +func (l *LocalContext[T]) Use(ctx context.Context) (T, error) { + value, ok := ctx.Value(l.key).(T) + if !ok { + var zero T + return zero, &ContextNotFound{Name: l.Name} + } + return value, nil +} + +func (l *LocalContext[T]) Provide(ctx context.Context, value T) context.Context { + return context.WithValue(ctx, l.key, value) +} + +func (l *LocalContext[T]) MustUse(ctx context.Context) T { + value, err := l.Use(ctx) + if err != nil { + panic(fmt.Sprint(err)) + } + return value +} diff --git a/internal/seniordev/util/namederror.go b/internal/seniordev/util/namederror.go new file mode 100644 index 000000000..0f60f73e6 --- /dev/null +++ b/internal/seniordev/util/namederror.go @@ -0,0 +1,43 @@ +//go:build !windows + +// Named schema error +package util + +type NamedSchemaError struct { + Name string `json:"name"` + Data map[string]any `json:"data"` + Cause error `json:"-"` +} + +func (e *NamedSchemaError) Error() string { return e.Name } +func (e *NamedSchemaError) Unwrap() error { return e.Cause } + +func (e *NamedSchemaError) ToObject() map[string]any { + return map[string]any{"name": e.Name, "data": e.Data} +} + +type NamedErrorFactory struct { + Tag string +} + +func NamedSchemaErrorFactory(tag string) NamedErrorFactory { return NamedErrorFactory{Tag: tag} } + +func (f NamedErrorFactory) New(data map[string]any, cause ...error) *NamedSchemaError { + var err error + if len(cause) > 0 { + err = cause[0] + } + return &NamedSchemaError{Name: f.Tag, Data: data, Cause: err} +} + +func (f NamedErrorFactory) IsInstance(value any) bool { + switch typed := value.(type) { + case *NamedSchemaError: + return typed != nil && typed.Name == f.Tag + case map[string]any: + name, _ := typed["name"].(string) + return name == f.Tag + default: + return false + } +} diff --git a/internal/seniordev/util/process.go b/internal/seniordev/util/process.go new file mode 100644 index 000000000..c46aa607f --- /dev/null +++ b/internal/seniordev/util/process.go @@ -0,0 +1,334 @@ +//go:build !windows + +// Process helpers +package util + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "syscall" + "time" +) + +type ProcessOptions struct { + Cwd string + Env map[string]string + // ClearEnv starts the child with an empty environment instead of + // inheriting the parent's. + ClearEnv bool + Stdin string + Stdout string + Stderr string + Shell string + Kill os.Signal + Timeout time.Duration +} + +type RunOptions struct { + ProcessOptions + NoThrow bool +} + +type ProcessResult struct { + Code int `json:"code"` + Stdout []byte `json:"-"` + Stderr []byte `json:"-"` +} + +type TextResult struct { + ProcessResult + Text string `json:"text"` +} + +type RunFailedError struct { + Cmd []string + Code int + Stdout []byte + Stderr []byte +} + +func (e *RunFailedError) Error() string { + message := fmt.Sprintf("Command failed with code %d: %s", e.Code, strings.Join(e.Cmd, " ")) + if text := strings.TrimSpace(string(e.Stderr)); text != "" { + message += "\n" + text + } + return message +} + +func (e *RunFailedError) ErrorName() string { return "ProcessRunFailedError" } + +type Child struct { + Cmd *exec.Cmd + Stdin io.WriteCloser + Stdout io.ReadCloser + Stderr io.ReadCloser + Exited <-chan int + + exit chan int + done chan struct{} + mu sync.Mutex + closed bool +} + +func SpawnProcess(ctx context.Context, command []string, options ...ProcessOptions) (*Child, error) { + if len(command) == 0 { + return nil, errors.New("Command is required") + } + opt := ProcessOptions{} + if len(options) > 0 { + opt = options[0] + } + name := command[0] + args := command[1:] + if opt.Shell != "" { + shell := opt.Shell + if shell == "true" { + if runtime.GOOS == "windows" { + shell = "cmd.exe" + } else { + shell = "/bin/sh" + } + } + line := strings.Join(command, " ") + name, args = shell, []string{"-c", line} + if runtime.GOOS == "windows" { + args = []string{"/d", "/s", "/c", line} + } + } + cmd := exec.Command(name, args...) + cmd.Dir = opt.Cwd + switch { + case opt.ClearEnv: + cmd.Env = []string{} + case opt.Env != nil: + values := map[string]string{} + order := []string{} + for _, item := range os.Environ() { + key, value, _ := strings.Cut(item, "=") + if _, ok := values[key]; !ok { + order = append(order, key) + } + values[key] = value + } + for key, value := range opt.Env { + if _, ok := values[key]; !ok { + order = append(order, key) + } + values[key] = value + } + for _, key := range order { + cmd.Env = append(cmd.Env, key+"="+values[key]) + } + } + child := &Child{Cmd: cmd, exit: make(chan int, 1), done: make(chan struct{})} + child.Exited = child.exit + var err error + child.Stdin, err = configureInput(cmd, opt.Stdin) + if err != nil { + return nil, err + } + var outWrite, errWrite *os.File + child.Stdout, outWrite, err = configureOutput(cmd, opt.Stdout, os.Stdout) + if err != nil { + return nil, err + } + child.Stderr, errWrite, err = configureOutput(cmd, opt.Stderr, os.Stderr) + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + for _, f := range []*os.File{outWrite, errWrite} { + if f != nil { + _ = f.Close() + } + } + close(child.done) + close(child.exit) + return nil, err + } + for _, f := range []*os.File{outWrite, errWrite} { + if f != nil { + _ = f.Close() + } + } + go func() { + err := cmd.Wait() + code := 0 + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } else if err != nil { + code = 1 + } + if code < 0 { + code = 1 + } + child.exit <- code + close(child.exit) + close(child.done) + }() + go func() { + select { + case <-ctx.Done(): + child.abort(opt) + case <-child.done: + } + }() + return child, nil +} + +func configureInput(cmd *exec.Cmd, mode string) (io.WriteCloser, error) { + switch mode { + case "inherit": + cmd.Stdin = os.Stdin + return nil, nil + case "pipe": + return cmd.StdinPipe() + default: + cmd.Stdin = strings.NewReader("") + return nil, nil + } +} + +func configureOutput(cmd *exec.Cmd, mode string, inherit io.Writer) (io.ReadCloser, *os.File, error) { + switch mode { + case "inherit": + if inherit == os.Stdout { + cmd.Stdout = inherit + } else { + cmd.Stderr = inherit + } + return nil, nil, nil + case "pipe": + // Explicit os.Pipe, not StdoutPipe/StderrPipe: the exit goroutine calls + // cmd.Wait immediately after Start, and Wait auto-closes exec-managed + // pipes while consumers may still be draining them (truncating output). + // The caller closes the parent's write-end copy after Start so readers + // see EOF when the child exits. + pr, pw, err := os.Pipe() + if err != nil { + return nil, nil, err + } + if inherit == os.Stdout { + cmd.Stdout = pw + } else { + cmd.Stderr = pw + } + return pr, pw, nil + default: + if inherit == os.Stdout { + cmd.Stdout = io.Discard + } else { + cmd.Stderr = io.Discard + } + return nil, nil, nil + } +} + +func (c *Child) abort(options ProcessOptions) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return + } + c.closed = true + c.mu.Unlock() + signal := options.Kill + if signal == nil { + signal = syscall.SIGTERM + } + _ = c.Cmd.Process.Signal(signal) + timeout := options.Timeout + if timeout == 0 { + timeout = 5 * time.Second + } + if timeout <= 0 { + return + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-c.done: + case <-timer.C: + _ = c.Cmd.Process.Kill() + } +} + +func RunProcess(ctx context.Context, command []string, options ...RunOptions) (ProcessResult, error) { + opt := RunOptions{} + if len(options) > 0 { + opt = options[0] + } + spawnOptions := opt.ProcessOptions + spawnOptions.Stdout = "pipe" + spawnOptions.Stderr = "pipe" + child, err := SpawnProcess(ctx, command, spawnOptions) + if err != nil { + if !opt.NoThrow { + return ProcessResult{}, err + } + return ProcessResult{Code: 1, Stdout: []byte{}, Stderr: []byte(ErrorMessage(err))}, nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); _, _ = io.Copy(&stdout, child.Stdout) }() + go func() { defer wg.Done(); _, _ = io.Copy(&stderr, child.Stderr) }() + code := <-child.Exited + wg.Wait() + result := ProcessResult{Code: code, Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} + if code == 0 || opt.NoThrow { + return result, nil + } + return ProcessResult{}, &RunFailedError{ + Cmd: append([]string(nil), command...), Code: code, + Stdout: result.Stdout, Stderr: result.Stderr, + } +} + +func TextProcess(ctx context.Context, command []string, options ...RunOptions) (TextResult, error) { + result, err := RunProcess(ctx, command, options...) + if err != nil { + return TextResult{}, err + } + return TextResult{ProcessResult: result, Text: string(result.Stdout)}, nil +} + +func ProcessLines(ctx context.Context, command []string, options ...RunOptions) ([]string, error) { + result, err := TextProcess(ctx, command, options...) + if err != nil { + return nil, err + } + lines := []string{} + for _, line := range strings.Split(strings.ReplaceAll(result.Text, "\r\n", "\n"), "\n") { + if line != "" { + lines = append(lines, line) + } + } + return lines, nil +} + +func StopProcess(ctx context.Context, child *Child) { + select { + case <-child.done: + return + default: + } + if runtime.GOOS != "windows" || child.Cmd.Process == nil { + _ = child.Cmd.Process.Kill() + return + } + result, _ := RunProcess(ctx, []string{"taskkill", "/pid", fmt.Sprint(child.Cmd.Process.Pid), "/T", "/F"}, + RunOptions{NoThrow: true}) + if result.Code != 0 { + _ = child.Cmd.Process.Kill() + } +} diff --git a/internal/seniordev/util/process_test.go b/internal/seniordev/util/process_test.go new file mode 100644 index 000000000..79cf45ecd --- /dev/null +++ b/internal/seniordev/util/process_test.go @@ -0,0 +1,71 @@ +//go:build !windows + +package util + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" +) + +func TestUtilProcessHelper(t *testing.T) { + if os.Getenv("GO_UTIL_HELPER") != "1" { + return + } + separator := 0 + for i, arg := range os.Args { + if arg == "--" { + separator = i + 1 + break + } + } + _ = json.NewEncoder(os.Stdout).Encode(os.Args[separator:]) + _, _ = os.Stderr.WriteString("warning\n") + if os.Getenv("GO_UTIL_FAIL") == "1" { + os.Exit(7) + } + os.Exit(0) +} + +func TestRunTextLinesAndFailure(t *testing.T) { + command := []string{os.Args[0], "-test.run=TestUtilProcessHelper", "--", "a b", "", "c"} + options := RunOptions{ProcessOptions: ProcessOptions{Env: map[string]string{"GO_UTIL_HELPER": "1"}}} + result, err := TextProcess(context.Background(), command, options) + if err != nil { + t.Fatal(err) + } + var args []string + if err := json.Unmarshal([]byte(result.Text), &args); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(args, []string{"a b", "", "c"}) || string(result.Stderr) != "warning\n" { + t.Fatalf("result: args=%v stderr=%q", args, result.Stderr) + } + + options.Env["GO_UTIL_FAIL"] = "1" + _, err = RunProcess(context.Background(), command, options) + failed, ok := err.(*RunFailedError) + if !ok || failed.Code != 7 || + failed.Error() != "Command failed with code 7: "+strings.Join(command, " ")+"\nwarning" { + t.Fatalf("failure: %#v %v", failed, err) + } + options.NoThrow = true + nothrow, err := RunProcess(context.Background(), command, options) + if err != nil || nothrow.Code != 7 { + t.Fatalf("nothrow: %+v %v", nothrow, err) + } +} + +func TestProcessLinesFiltersOnlyEmptyLines(t *testing.T) { + command := []string{"printf", "a\\n\\nb\\r\\n"} + lines, err := ProcessLines(context.Background(), command) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(lines, []string{"a", "b"}) { + t.Fatalf("lines: %#v", lines) + } +} diff --git a/internal/seniordev/util/record.go b/internal/seniordev/util/record.go new file mode 100644 index 000000000..4a7e9e0cf --- /dev/null +++ b/internal/seniordev/util/record.go @@ -0,0 +1,20 @@ +//go:build !windows + +// Record predicate. +package util + +import "reflect" + +func IsRecord(value any) bool { + if value == nil { + return false + } + v := reflect.ValueOf(value) + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return false + } + v = v.Elem() + } + return v.Kind() == reflect.Map || v.Kind() == reflect.Struct +} diff --git a/internal/seniordev/util/util_test.go b/internal/seniordev/util/util_test.go new file mode 100644 index 000000000..c52aed7ab --- /dev/null +++ b/internal/seniordev/util/util_test.go @@ -0,0 +1,50 @@ +//go:build !windows + +package util + +import ( + "context" + "errors" + "path/filepath" + "reflect" + "testing" +) + +func TestLocalContext(t *testing.T) { + local := CreateLocalContext[string]("test") + if _, err := local.Use(context.Background()); err == nil || err.Error() != "No context found for test" { + t.Fatalf("missing context: %v", err) + } + ctx := local.Provide(context.Background(), "value") + if got := local.MustUse(ctx); got != "value" { + t.Fatalf("context = %q", got) + } +} + +func TestFindUpRootFirst(t *testing.T) { + root := t.TempDir() + for _, relative := range []string{"root.txt", "a/one.txt", "a/b/two.txt"} { + path := filepath.Join(root, filepath.FromSlash(relative)) + if err := WriteText(path, relative); err != nil { + t.Fatal(err) + } + } + start := filepath.Join(root, "a", "b") + got := FindUp([]string{"root.txt", "one.txt", "two.txt"}, start, "", FindUpOptions{RootFirst: true}) + want := []string{ + filepath.Join(root, "root.txt"), + filepath.Join(root, "a", "one.txt"), + filepath.Join(root, "a", "b", "two.txt"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("findUp = %v, want %v", got, want) + } +} + +func TestNamedSchemaErrorFactory(t *testing.T) { + factory := NamedSchemaErrorFactory("Boom") + err := factory.New(map[string]any{"message": "x"}, errors.New("cause")) + if !factory.IsInstance(err) || err.Error() != "Boom" || !errors.Is(err, err.Cause) { + t.Fatalf("named error: %+v", err) + } +} From 790ee07c944ec859367ca1ec623c8cae9878d12c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:18:51 -0400 Subject: [PATCH 025/195] seniordev: senior-dev's commits survive a symlinked folder and its own folder stays out of a worktree's git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of senior-dev's tests failed on macOS, all on the same fact: every temporary folder there is /var/folders/…, a link to /private/var/folders/…. Three were tests comparing two spellings of one folder. The fourth was real: the per-file commit measured the written file against git's resolved top level, so in any workspace reached through a link the path walked out of the repository, `git add` refused it, and every `wip(write)`/`wip(edit)` commit stopped without a word. Both sides are now resolved before they are compared, and the three tests compare resolved folders. senior-dev also wrote its `.senior-dev/` exclude into /info/exclude. In a linked worktree, which is what codeaf cuts for a task, git ignores that folder in favour of the common dir's, so `.senior-dev/` stayed untracked and a landing that stages the tree's status would have committed senior-dev's database and spec into the person's branch. The exclude now goes where `git rev-parse --git-path info/exclude` says git reads it. Its commit identity came from the AgentField attribution package, which is not carried; senior-dev's own commits now carry `senior-dev ` as -c overrides (GIT_AUTHOR_*/GIT_COMMITTER_* still win), and no AgentField trailer. Those commits never reach a person's branch as they are: codeaf squashes a run into one commit under its own identity. Co-Authored-By: Claude Opus 5.5 --- internal/seniordev/core/spawner_test.go | 15 ++++- .../seniordev/tool/instance_context_test.go | 5 +- .../seniordev/tool/shell_feedback_test.go | 2 +- internal/seniordev/tool/tool_test.go | 11 ++++ internal/seniordev/util/eagercommit.go | 47 +++++++++++--- internal/seniordev/util/gitexclude.go | 27 +++++--- internal/seniordev/util/gitidentity.go | 29 +++++++++ internal/seniordev/util/gitutils_test.go | 63 +++++++++++++++++++ 8 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 internal/seniordev/util/gitidentity.go diff --git a/internal/seniordev/core/spawner_test.go b/internal/seniordev/core/spawner_test.go index bc08cd01b..caa0b4200 100644 --- a/internal/seniordev/core/spawner_test.go +++ b/internal/seniordev/core/spawner_test.go @@ -62,11 +62,24 @@ func TestSpawnerArgvEnvAndCwd(t *testing.T) { if !reflect.DeepEqual(got.Args, []string{"space arg", "", "🙂"}) { t.Fatalf("args: %#v", got.Args) } - if got.Cwd != root || got.Env != "value" { + // 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") diff --git a/internal/seniordev/tool/instance_context_test.go b/internal/seniordev/tool/instance_context_test.go index f3966432e..ce4988487 100644 --- a/internal/seniordev/tool/instance_context_test.go +++ b/internal/seniordev/tool/instance_context_test.go @@ -60,7 +60,10 @@ func TestConcurrentLeafContextsResolveTheirOwnToolCWD(t *testing.T) { errors <- err return } - if strings.TrimSpace(result.Output) != item.directory { + // The shell prints the folder the kernel resolved, and a temporary + // folder on macOS is reached through a symlink, so the same folder + // can come back spelled /private/var/…; it is compared resolved. + if !sameFolder(strings.TrimSpace(result.Output), item.directory) { errors <- &cwdError{got: strings.TrimSpace(result.Output), want: item.directory} } }() diff --git a/internal/seniordev/tool/shell_feedback_test.go b/internal/seniordev/tool/shell_feedback_test.go index d65f28ec7..f55c5001a 100644 --- a/internal/seniordev/tool/shell_feedback_test.go +++ b/internal/seniordev/tool/shell_feedback_test.go @@ -120,7 +120,7 @@ func TestBashWorkdirRunsThereAndRejectsEscapes(t *testing.T) { if err != nil { t.Fatal(err) } - if strings.TrimSpace(result.Output) != nested { + if !sameFolder(strings.TrimSpace(result.Output), nested) { t.Fatalf("pwd output = %q, want %q", result.Output, nested) } _, err = execute(t, registry, "bash", map[string]any{"command": "pwd", "workdir": "../outside"}) diff --git a/internal/seniordev/tool/tool_test.go b/internal/seniordev/tool/tool_test.go index 8733f7f4e..c083ff5d1 100644 --- a/internal/seniordev/tool/tool_test.go +++ b/internal/seniordev/tool/tool_test.go @@ -410,3 +410,14 @@ func TestExecuteHonorsCanceledContext(t *testing.T) { t.Fatalf("error = %v", err) } } + +// sameFolder answers whether two spellings name one folder once their +// symlinks are resolved. A shell reports the folder it runs in as the kernel +// resolved it, and on macOS every temporary folder is reached through one +// (/var/folders is /private/var/folders), so a test that compared spellings +// failed there while the command ran exactly where it should. +func sameFolder(got, want string) bool { + resolvedGot, errGot := filepath.EvalSymlinks(got) + resolvedWant, errWant := filepath.EvalSymlinks(want) + return errGot == nil && errWant == nil && resolvedGot == resolvedWant +} diff --git a/internal/seniordev/util/eagercommit.go b/internal/seniordev/util/eagercommit.go index 1aafc1470..64fb1b86e 100644 --- a/internal/seniordev/util/eagercommit.go +++ b/internal/seniordev/util/eagercommit.go @@ -9,8 +9,6 @@ import ( "path/filepath" "strings" "sync/atomic" - - "github.com/Agent-Field/codeaf/internal/seniordev/attribution" ) // skipEagerCommit is read on every write and set from two places: the @@ -51,10 +49,7 @@ func EagerCommit(ctx context.Context, options EagerCommitOptions) { if rootResult.Code == 0 { root = strings.TrimSpace(string(rootResult.Stdout)) } - relative, _ := filepath.Rel(root, options.FilePath) - if relative == "" { - relative = options.FilePath - } + relative := repositoryRelative(root, options.Cwd, options.FilePath) add, _ := RunProcess(ctx, []string{"git", "add", "--", relative}, RunOptions{ ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, }) @@ -67,8 +62,44 @@ func EagerCommit(ctx context.Context, options EagerCommitOptions) { if diff.Code == 0 { return } - message := attribution.AppendCommitTrailer("wip(" + options.Label + "): " + relative) - _, _ = RunProcess(ctx, attribution.GitArgv( + message := "wip(" + options.Label + "): " + relative + _, _ = RunProcess(ctx, GitArgv( "commit", "-m", message, "--no-verify", "--only", "--", relative, ), RunOptions{ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true}) } + +// repositoryRelative names a written file inside the repository whose top +// level git reported as root. +// +// GIT REPORTS ITS TOP LEVEL WITH EVERY SYMLINK RESOLVED, and the path a tool +// hands in need not be. On macOS every temporary folder is /var/folders/…, +// which is a link to /private/var/folders/…, so a file under the one measured +// against a root under the other walked out of the repository +// ("../../../var/folders/…"), `git add` refused it, and every per-file commit +// in such a workspace stopped without a word while the run went on believing +// it was checkpointing. Both sides are resolved before they are compared. +func repositoryRelative(root, cwd, path string) string { + if !filepath.IsAbs(path) { + path = filepath.Join(cwd, path) + } + relative, err := filepath.Rel(resolveExisting(root), resolveExisting(path)) + if err != nil || relative == "" { + return path + } + return relative +} + +// resolveExisting resolves the symlinks in path, or in its nearest ancestor +// that exists when the path itself does not (a file just deleted still has a +// folder, and the folder is what carries the link). +func resolveExisting(path string) string { + path = filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + parent := filepath.Dir(path) + if parent == path { + return path + } + return filepath.Join(resolveExisting(parent), filepath.Base(path)) +} diff --git a/internal/seniordev/util/gitexclude.go b/internal/seniordev/util/gitexclude.go index 0539f2285..193beb882 100644 --- a/internal/seniordev/util/gitexclude.go +++ b/internal/seniordev/util/gitexclude.go @@ -17,25 +17,34 @@ var ExcludedPaths = []string{ const excludeSentinel = "# senior-dev: workflow artifacts (managed by senior-dev)" +// EnsureSeniorDevExcluded adds senior-dev's own folder to the exclude file git +// reads for workspace, once. +// +// THE FILE IS THE ONE GIT READS, which is not always /info/exclude. +// In a linked worktree — and the working copy codeaf cuts for a task is one — +// the git dir is .git/worktrees/, and git ignores an info/ folder there +// in favour of the common dir's. An exclude written beside the git dir changed +// nothing: `.senior-dev/` stayed untracked, and a landing that stages the +// tree's own status would have committed senior-dev's database, spec and tool +// logs into the person's branch. `rev-parse --git-path` names the file git +// actually consults, which for a linked worktree is the repository's shared +// one. func EnsureSeniorDevExcluded(ctx context.Context, workspace string) (bool, error) { - result, err := RunProcess(ctx, []string{"git", "rev-parse", "--git-dir"}, RunOptions{ + result, err := RunProcess(ctx, []string{"git", "rev-parse", "--git-path", "info/exclude"}, RunOptions{ ProcessOptions: ProcessOptions{Cwd: workspace}, NoThrow: true, }) if err != nil || result.Code != 0 { return false, nil } - gitDirText := strings.TrimSpace(string(result.Stdout)) - if gitDirText == "" { + excludePath := strings.TrimSpace(string(result.Stdout)) + if excludePath == "" { return false, nil } - gitDir := gitDirText - if !filepath.IsAbs(gitDir) { - gitDir, _ = filepath.Abs(filepath.Join(workspace, gitDir)) + if !filepath.IsAbs(excludePath) { + excludePath, _ = filepath.Abs(filepath.Join(workspace, excludePath)) } - infoDir := filepath.Join(gitDir, "info") - _ = os.MkdirAll(infoDir, 0o777) - excludePath := filepath.Join(infoDir, "exclude") + _ = os.MkdirAll(filepath.Dir(excludePath), 0o777) currentBytes, err := os.ReadFile(excludePath) if err != nil && !errors.Is(err, os.ErrNotExist) { currentBytes = nil diff --git a/internal/seniordev/util/gitidentity.go b/internal/seniordev/util/gitidentity.go new file mode 100644 index 000000000..35fc4b51b --- /dev/null +++ b/internal/seniordev/util/gitidentity.go @@ -0,0 +1,29 @@ +//go:build !windows + +package util + +// The identity senior-dev's own commits carry. +// +// senior-dev commits as it works: its exact starting tree, every file its +// model writes, each coherent checkpoint and the candidate it submits. A +// working copy on a machine that has never been told who is committing (a +// fresh container, a hermetic HOME) refuses every one of those commits, and a +// refused candidate commit is a submission the run cannot make. So each git +// command that can commit carries an identity of its own, as `-c` overrides, +// which GIT_AUTHOR_* and GIT_COMMITTER_* in the environment still win over. +// +// None of these commits is what a person keeps. When codeaf runs senior-dev on +// a task, the run's commits are squashed into the one commit that lands, and +// that commit carries codeaf's identity rather than this one. The address is +// therefore a local one: it names the program that made a commit and no +// account anywhere. +const ( + CommitterName = "senior-dev" + CommitterEmail = "senior-dev@localhost" +) + +// GitArgv is a git command line that carries senior-dev's commit identity. +func GitArgv(args ...string) []string { + argv := []string{"git", "-c", "user.name=" + CommitterName, "-c", "user.email=" + CommitterEmail} + return append(argv, args...) +} diff --git a/internal/seniordev/util/gitutils_test.go b/internal/seniordev/util/gitutils_test.go index 816ceb630..43688ac5f 100644 --- a/internal/seniordev/util/gitutils_test.go +++ b/internal/seniordev/util/gitutils_test.go @@ -76,4 +76,67 @@ func TestEagerCommit(t *testing.T) { if subject != "wip(write): file.txt" { t.Fatalf("subject = %q", subject) } + // The commit carries senior-dev's own identity, so a machine that was + // never told who commits can still take it. + author := strings.TrimSpace(gitTestRun(t, dir, "log", "-1", "--pretty=%cn <%ce>")) + if author != CommitterName+" <"+CommitterEmail+">" { + t.Fatalf("committer = %q", author) + } +} + +// A path spelled through a symlink still commits: the per-file commit is +// measured against git's resolved top level, and a workspace reached through a +// link (every temporary folder on macOS) used to walk out of the repository. +func TestEagerCommitThroughASymlinkedWorkspace(t *testing.T) { + dir := initGitRepo(t) + file := filepath.Join(dir, "file.txt") + if err := os.WriteFile(file, []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTestRun(t, dir, "add", "file.txt") + gitTestRun(t, dir, "commit", "-qm", "initial") + link := filepath.Join(t.TempDir(), "workspace-link") + if err := os.Symlink(dir, link); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(link, "file.txt"), []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + + previous := skipEagerCommit.Load() + skipEagerCommit.Store(false) + defer func() { skipEagerCommit.Store(previous) }() + EagerCommit(context.Background(), EagerCommitOptions{ + Cwd: link, FilePath: filepath.Join(link, "file.txt"), Label: "edit", + }) + subject := strings.TrimSpace(gitTestRun(t, dir, "log", "-1", "--pretty=%s")) + if subject != "wip(edit): file.txt" { + t.Fatalf("subject = %q, want the per-file commit through the link", subject) + } +} + +// A task's working copy is a linked worktree, whose own info/ folder git does +// not read. The exclude has to land where git looks, or `.senior-dev/` is +// untracked work that a landing would commit. +func TestEnsureSeniorDevExcludedReachesALinkedWorktree(t *testing.T) { + dir := initGitRepo(t) + gitTestRun(t, dir, "commit", "-q", "--allow-empty", "-m", "base") + copyDir := filepath.Join(t.TempDir(), "copy") + gitTestRun(t, dir, "worktree", "add", "-q", "--detach", copyDir, "HEAD") + if err := os.MkdirAll(filepath.Join(copyDir, ".senior-dev"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(copyDir, ".senior-dev", "spec.md"), []byte("brief\n"), 0o644); err != nil { + t.Fatal(err) + } + if status := gitTestRun(t, copyDir, "status", "--porcelain", "--untracked-files=all"); !strings.Contains(status, ".senior-dev/") { + t.Fatalf("the fixture is wrong: .senior-dev is not untracked before the exclude:\n%s", status) + } + ok, err := EnsureSeniorDevExcluded(context.Background(), copyDir) + if err != nil || !ok { + t.Fatalf("ensure = %v, %v", ok, err) + } + if status := gitTestRun(t, copyDir, "status", "--porcelain", "--untracked-files=all"); strings.Contains(status, ".senior-dev") { + t.Fatalf("senior-dev's folder is still untracked in the linked worktree:\n%s", status) + } } From 4a5b8720c2c7070d035e9f76860b4a724c802bec Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:19:20 -0400 Subject: [PATCH 026/195] seniordev: senior-dev runs on the model API codeaf serves it and reports only through the host senior-dev was a binary of its own. It read OPENROUTER_API_KEY and OPENROUTER_BASE_URL (or a config's apiKey/baseURL), called OpenRouter directly with attribution headers and an optional provider-routing block, printed every bus payload and a running `spend` on stdout as NDJSON, mirrored itself onto an AgentField control plane, and ended with a terminal event its CLI layer wrote. Now it is the program internal/seniordev.Program: one command, `run`, whose body takes a delegate.Host. Every model call goes to modelapi.ChatURL(host.Models().BaseURL) and leaves by one door, the backend's fetch, which puts the run's token on it over the one http.Client. No key or base URL is read anywhere, and a senior-dev.json that sets a service's apiKey, baseURL or any providerRouting block is refused by name; an ad-hoc `provider` option is dropped. prompt_cache_key, x-session-affinity, reasoning, usage.include and the tools are kept. The one "openrouter" spelling it needs is modelsource.DefaultID, read through orclient.Service. On stdout it writes only the protocol's records, through the host: a hello naming its thirteen stages in the order a run reaches them (a law in stages_test.go holds the list to the source), a stage per phase change, a step per finished tool call, and exactly one terminal, written by the body so a panic is an ending too. The terminal keeps the model's claim (its submission reason) and what senior-dev itself observed of the project's build and tests apart, in words a person reads. Bus payloads and spend stay in the process; each stage's data goes to stderr, which codeaf keeps beside the task. A stop is the truth, not a crash: SIGTERM ends the run's context, it starts nothing new, ships what it has, and says `stopped before it finished` (or budget-exhausted past a ceiling, or pass for a frozen candidate). A check cut by the stop is recorded as incomplete rather than failed, so it neither fails a submitted candidate nor restores an unsubmitted tree. Every file carries a !windows constraint, and a test holds the tree to it. Left behind with the command line: --format, --tui, serve, the control plane and the stderr trace. Co-Authored-By: Claude Opus 5.5 --- internal/seniordev/absentonwindows_test.go | 53 +++ internal/seniordev/app/args.go | 41 ++ internal/seniordev/app/catalog_test.go | 4 +- internal/seniordev/app/compaction_pin.go | 10 +- internal/seniordev/app/compaction_pin_test.go | 11 +- .../seniordev/app/compaction_policy_test.go | 2 +- internal/seniordev/app/config.go | 85 ++-- internal/seniordev/app/config_live_test.go | 68 ++- .../seniordev/app/durable_sessions_test.go | 10 +- internal/seniordev/app/engine_backend.go | 11 +- internal/seniordev/app/engine_client.go | 73 ++- internal/seniordev/app/engine_compaction.go | 6 +- .../seniordev/app/engine_contract_test.go | 8 +- internal/seniordev/app/engine_prompt_test.go | 2 +- internal/seniordev/app/engine_router.go | 2 +- internal/seniordev/app/events.go | 166 ++++--- .../seniordev/app/events_contract_test.go | 84 ++-- .../seniordev/app/full_verification_run.go | 10 + .../app/model_request_events_test.go | 8 +- internal/seniordev/app/pipeline.go | 26 +- internal/seniordev/app/pipeline_smoke_test.go | 14 +- internal/seniordev/app/run.go | 330 +++++++++++++ .../seniordev/app/run_error_classify_test.go | 43 +- internal/seniordev/app/runtime.go | 57 ++- .../seniordev/app/runtime_compaction_test.go | 50 +- internal/seniordev/app/runtime_retry_test.go | 10 +- internal/seniordev/app/runtime_test.go | 53 ++- internal/seniordev/app/solo_finalize.go | 2 +- internal/seniordev/app/solo_ship.go | 24 +- internal/seniordev/app/stages_test.go | 89 ++++ internal/seniordev/app/step_records.go | 36 ++ internal/seniordev/app/stop_test.go | 64 +++ internal/seniordev/app/testsupport_test.go | 59 ++- internal/seniordev/app/tier_test.go | 2 +- .../seniordev/app/workspace_recorder_git.go | 3 +- .../seniordev/app/workspace_recorder_test.go | 51 +- .../engine/orclient/cancellation_test.go | 8 +- internal/seniordev/engine/orclient/client.go | 29 +- .../seniordev/engine/orclient/client_test.go | 12 +- internal/seniordev/engine/orclient/convert.go | 6 +- .../seniordev/engine/orclient/helpers_test.go | 5 + internal/seniordev/engine/orclient/jsonval.go | 19 + .../engine/orclient/modelapi_test.go | 59 +++ internal/seniordev/engine/orclient/parts.go | 4 +- internal/seniordev/engine/orclient/routing.go | 331 ------------- .../seniordev/engine/orclient/routing_test.go | 108 ----- internal/seniordev/engine/orclient/service.go | 17 + .../seniordev/engine/orclient/transform.go | 12 +- internal/seniordev/netpolicy/netpolicy.go | 16 +- internal/seniordev/seniordev.go | 95 ++++ internal/seniordev/seniordev_test.go | 436 ++++++++++++++++++ .../session/llmcall/cancellation_test.go | 2 +- 52 files changed, 1835 insertions(+), 891 deletions(-) create mode 100644 internal/seniordev/absentonwindows_test.go create mode 100644 internal/seniordev/app/args.go create mode 100644 internal/seniordev/app/run.go create mode 100644 internal/seniordev/app/stages_test.go create mode 100644 internal/seniordev/app/stop_test.go create mode 100644 internal/seniordev/engine/orclient/modelapi_test.go delete mode 100644 internal/seniordev/engine/orclient/routing.go delete mode 100644 internal/seniordev/engine/orclient/routing_test.go create mode 100644 internal/seniordev/engine/orclient/service.go create mode 100644 internal/seniordev/seniordev.go create mode 100644 internal/seniordev/seniordev_test.go diff --git a/internal/seniordev/absentonwindows_test.go b/internal/seniordev/absentonwindows_test.go new file mode 100644 index 000000000..259c4bb4d --- /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/app/args.go b/internal/seniordev/app/args.go new file mode 100644 index 000000000..e94c953f8 --- /dev/null +++ b/internal/seniordev/app/args.go @@ -0,0 +1,41 @@ +//go:build !windows + +package app + +import "strings" + +// 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 selects the snapshot recorder: senior-dev edits the workspace + // without requiring a repository and without writing to one. + InPlace bool + MaxCost *float64 + MaxHours *float64 +} + +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/catalog_test.go b/internal/seniordev/app/catalog_test.go index 165b96489..bbe4fbed0 100644 --- a/internal/seniordev/app/catalog_test.go +++ b/internal/seniordev/app/catalog_test.go @@ -13,7 +13,7 @@ import ( func seniorDevCatalogFixture(t *testing.T) modelsdev.Catalog { t.Helper() client, err := modelsdev.New(modelsdev.Options{ - CatalogPath: "../../internal/modelsdev/testdata/catalog.json", + CatalogPath: "../modelsdev/testdata/catalog.json", CacheDir: t.TempDir(), DisableFetch: true, }) @@ -29,7 +29,7 @@ func seniorDevCatalogFixture(t *testing.T) modelsdev.Catalog { func TestSeniorDevCatalogMetadataReachesSessionModel(t *testing.T) { models := seniorDevModels{ - backend: &openRouterBackend{catalog: seniorDevCatalogFixture(t)}, + backend: &modelAPIBackend{catalog: seniorDevCatalogFixture(t)}, sessionID: "ses_catalog", agent: "coder", } diff --git a/internal/seniordev/app/compaction_pin.go b/internal/seniordev/app/compaction_pin.go index dd879bc9a..498a8bb43 100644 --- a/internal/seniordev/app/compaction_pin.go +++ b/internal/seniordev/app/compaction_pin.go @@ -73,7 +73,7 @@ func overflowText(err error) string { } // pinnedCapacityFor is the session's pinned capacity, if a rejection set one. -func (backend *openRouterBackend) pinnedCapacityFor(sessionID string) (float64, bool) { +func (backend *modelAPIBackend) pinnedCapacityFor(sessionID string) (float64, bool) { if backend == nil { return 0, false } @@ -85,7 +85,7 @@ func (backend *openRouterBackend) pinnedCapacityFor(sessionID string) (float64, // overflowConfigFor is the compaction config a session runs under: the project // config, with a pinned capacity folded in as a minimum. -func (backend *openRouterBackend) overflowConfigFor(sessionID string) (overflow.Config, error) { +func (backend *modelAPIBackend) overflowConfigFor(sessionID string) (overflow.Config, error) { cfg, err := backend.config.overflowConfig() if err != nil { return cfg, err @@ -95,7 +95,7 @@ func (backend *openRouterBackend) overflowConfigFor(sessionID string) (overflow. // withPinnedCapacity folds the session's pin into a compaction config as a // capacity_tokens minimum. Unpinned sessions get cfg back as is. -func (backend *openRouterBackend) withPinnedCapacity(cfg overflow.Config, sessionID string) overflow.Config { +func (backend *modelAPIBackend) withPinnedCapacity(cfg overflow.Config, sessionID string) overflow.Config { pinned, ok := backend.pinnedCapacityFor(sessionID) if !ok { return cfg @@ -116,7 +116,7 @@ func (backend *openRouterBackend) withPinnedCapacity(cfg overflow.Config, sessio // 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 *openRouterBackend) pinCapacityOnOverflow( +func (backend *modelAPIBackend) pinCapacityOnOverflow( sessionID, agent, providerID, modelID string, err error, ) { if backend == nil || err == nil { @@ -184,7 +184,7 @@ func (backend *openRouterBackend) pinCapacityOnOverflow( backend.emitStage("compaction-capacity", "pinned", data) } -func (backend *openRouterBackend) emitStage(stage, status string, data map[string]any) { +func (backend *modelAPIBackend) emitStage(stage, status string, data map[string]any) { if backend == nil || backend.events == nil { return } diff --git a/internal/seniordev/app/compaction_pin_test.go b/internal/seniordev/app/compaction_pin_test.go index 2a2c49a4d..6a9f5cb46 100644 --- a/internal/seniordev/app/compaction_pin_test.go +++ b/internal/seniordev/app/compaction_pin_test.go @@ -36,15 +36,15 @@ func TestParseContextLimitReadsTheNumberedOverflowMessages(t *testing.T) { // 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) (*openRouterBackend, *bytes.Buffer) { +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 := &openRouterBackend{ - apiKey: "mock-only", catalog: seniorDevCatalogFixture(t), config: cfg, + 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 @@ -53,7 +53,7 @@ func overflowBackend(t *testing.T, info configpkg.Info, status int, body string) return backend, &events } -func overflowStream(t *testing.T, backend *openRouterBackend, session string) error { +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 { @@ -62,7 +62,8 @@ func overflowStream(t *testing.T, backend *openRouterBackend, session string) er client := seniorDevStreamClient{ backend: backend, sessionID: session, agent: "coder", model: projection, client: &orclient.Client{ - Fetcher: backend.client.Do, Compatibility: orclient.CompatibilityCompatible, + BaseURL: backend.api.BaseURL, Fetcher: backend.fetch, + Compatibility: orclient.CompatibilityCompatible, }, } _, err = client.DoStream(context.Background(), orclient.RequestParams{ diff --git a/internal/seniordev/app/compaction_policy_test.go b/internal/seniordev/app/compaction_policy_test.go index f66a2d79e..814dd8865 100644 --- a/internal/seniordev/app/compaction_policy_test.go +++ b/internal/seniordev/app/compaction_policy_test.go @@ -75,7 +75,7 @@ func TestConfiguredTurnProvenanceCarriesCompactionBudget(t *testing.T) { } // 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 := &openRouterBackend{catalog: seniorDevCatalogFixture(t)} + fixture := &modelAPIBackend{catalog: seniorDevCatalogFixture(t)} t.Run("no backend records no budget", func(t *testing.T) { record := emit(t, configpkg.Info{}, nil) diff --git a/internal/seniordev/app/config.go b/internal/seniordev/app/config.go index 7628ee121..0e6d5c53b 100644 --- a/internal/seniordev/app/config.go +++ b/internal/seniordev/app/config.go @@ -55,7 +55,7 @@ func newSeniorDevConfig(info configpkg.Info) (*seniorDevConfig, error) { if err != nil { return nil, fmt.Errorf("permission config: %w", err) } - if err := validateConfiguredRouting(info); err != nil { + if err := refuseRetiredModelKnobs(info); err != nil { return nil, err } // The compaction block is parsed once here so a malformed block fails the @@ -289,62 +289,37 @@ func (cfg *seniorDevConfig) options(agent, providerID, modelID string) *orclient return result } -// providerRouting resolves the OpenRouter `provider` routing block for one -// turn: the provider-wide `providerRouting`, then the model's, then the -// agent's, each level overriding only the fields it sets. Nil when nothing is -// configured, so the request carries no `provider` key at all and OpenRouter -// applies its own default routing. -func (cfg *seniorDevConfig) providerRouting(agent, providerID, modelID string) (*orclient.ProviderRouting, error) { - if cfg == nil { - return nil, nil - } - var merged *orclient.ProviderRouting - for _, level := range []struct { - name string - value any - }{ - {"provider." + providerID, cfg.provider(providerID)["providerRouting"]}, - {"provider." + providerID + ".models." + modelID, cfg.model(providerID, modelID)["providerRouting"]}, - {"agent." + agent, cfg.agent(agent)["providerRouting"]}, - } { - parsed, err := parseConfiguredRouting(level.value) - if err != nil { - return nil, fmt.Errorf("%s.providerRouting: %w", level.name, err) - } - merged = merged.Merge(parsed) - } - return merged, nil -} - -func parseConfiguredRouting(value any) (*orclient.ProviderRouting, error) { - if value == nil { - return nil, nil - } - data, err := json.Marshal(value) - if err != nil { - return nil, err - } - return orclient.ParseProviderRouting(data) -} - -// validateConfiguredRouting parses every providerRouting block at load time -// so a misspelled or out-of-range rule fails the run up front instead of -// silently routing with OpenRouter's defaults. -func validateConfiguredRouting(info configpkg.Info) error { +// 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) - if _, err := parseConfiguredRouting(provider["providerRouting"]); err != nil { - return fmt.Errorf("provider %q providerRouting: %w", providerID, err) + 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 _, err := parseConfiguredRouting(objectValue(rawModel)["providerRouting"]); err != nil { - return fmt.Errorf("provider %q model %q providerRouting: %w", providerID, modelID, err) + 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 _, err := parseConfiguredRouting(objectValue(raw)["providerRouting"]); err != nil { - return fmt.Errorf("agent %q providerRouting: %w", name, err) + 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 @@ -389,18 +364,12 @@ func (cfg *seniorDevConfig) headers(providerID, modelID string) []orclient.Heade return result } -func (cfg *seniorDevConfig) applyBackend(backend *openRouterBackend) { +func (cfg *seniorDevConfig) applyBackend(backend *modelAPIBackend) { if cfg == nil || backend == nil { return } backend.config = cfg - options := objectValue(cfg.provider("openrouter")["options"]) - if value, ok := options["apiKey"].(string); ok && value != "" { - backend.apiKey = value - } - if value, ok := options["baseURL"].(string); ok && value != "" { - backend.endpoint = openRouterEndpoint(value) - } + options := objectValue(cfg.provider(orclient.Service)["options"]) if value, exists := options["timeout"]; exists { if disabled, ok := value.(bool); ok && !disabled { backend.totalTimeoutMS = -1 @@ -453,7 +422,7 @@ func configNumber(value any) (float64, bool) { func splitConfiguredModel(value string) (string, string) { providerID, modelID, found := strings.Cut(value, "/") if !found { - return "openrouter", value + 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 index 2cba1ede1..245a9a6a6 100644 --- a/internal/seniordev/app/config_live_test.go +++ b/internal/seniordev/app/config_live_test.go @@ -51,8 +51,6 @@ func TestProjectConfigChangesLiveRuntimePermissionsAndInstructions(t *testing.T) "provider": { "openrouter": { "options": { - "apiKey": "configured-key", - "baseURL": "https://router.example/api/v1", "timeout": false, "chunkTimeout": 45000, "headers": {"X-Config": "provider", "X-Provider": "yes"} @@ -128,7 +126,7 @@ func TestProjectConfigChangesLiveRuntimePermissionsAndInstructions(t *testing.T) } } - backend := &openRouterBackend{} + backend := &modelAPIBackend{} cfg.applyBackend(backend) model, err := (seniorDevModels{ backend: backend, sessionID: "ses", agent: "coder", @@ -137,16 +135,13 @@ func TestProjectConfigChangesLiveRuntimePermissionsAndInstructions(t *testing.T) t.Fatal(err) } options, _ := model.Params.OpenRouterOptions.MarshalJSON() - if backend.apiKey != "configured-key" || backend.baseURL() != "https://router.example/api/v1" || - backend.totalTimeoutMS != -1 || backend.chunkTimeoutMS != 45000 || + 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 := seniorDevOpenRouterHeadersWithConfig( - backend.apiKey, "ses", cfg.headers("openrouter", "vendor/configured-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"`) { @@ -154,6 +149,63 @@ func TestProjectConfigChangesLiveRuntimePermissionsAndInstructions(t *testing.T) } } +// 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. diff --git a/internal/seniordev/app/durable_sessions_test.go b/internal/seniordev/app/durable_sessions_test.go index a8df688c1..a2e0924d4 100644 --- a/internal/seniordev/app/durable_sessions_test.go +++ b/internal/seniordev/app/durable_sessions_test.go @@ -58,7 +58,7 @@ func TestDurablePromptPersistsAndProjectsBeforeFirstModelCall(t *testing.T) { request, http.StatusOK, "text/event-stream", chatReply("finished", 10), ), nil })} - runtime = newRuntime(workspace, &openRouterBackend{apiKey: "test", client: client}) + runtime = newRuntime(workspace, &modelAPIBackend{api: testModelAPI, client: client}) defer runtime.Close() rootID, err := runtime.Create(context.Background(), "", "coder") if err != nil { @@ -727,8 +727,8 @@ func TestDurableHistoryPreservesInstructionDedup(t *testing.T) { firstTransport := &scriptedRoundTripper{replies: []string{ toolCallReply("read", string(arguments)), chatReply("first done", 10), }} - firstRuntime := newRuntime(workspace, &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: firstTransport}, + firstRuntime := newRuntime(workspace, &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: firstTransport}, }) t.Cleanup(firstRuntime.Close) rootID, err := firstRuntime.Create(context.Background(), "", "coder") @@ -750,8 +750,8 @@ func TestDurableHistoryPreservesInstructionDedup(t *testing.T) { secondTransport := &scriptedRoundTripper{replies: []string{ toolCallReply("read", string(arguments)), chatReply("second done", 10), }} - secondRuntime := newRuntime(workspace, &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: secondTransport}, + 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 diff --git a/internal/seniordev/app/engine_backend.go b/internal/seniordev/app/engine_backend.go index fd9b16ed6..c34fbf636 100644 --- a/internal/seniordev/app/engine_backend.go +++ b/internal/seniordev/app/engine_backend.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/Agent-Field/codeaf/internal/seniordev/attribution" "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" @@ -22,7 +21,7 @@ import ( type turnToolExecutor struct{ request turn } -func (backend *openRouterBackend) Run( +func (backend *modelAPIBackend) Run( ctx context.Context, request turn, ) (turnResult, error) { return backend.runEngine(ctx, request) @@ -34,12 +33,9 @@ func (executor turnToolExecutor) Execute( return executeAdvertisedTool(ctx, executor.request, call) } -func (backend *openRouterBackend) runEngine( +func (backend *modelAPIBackend) runEngine( ctx context.Context, request turn, ) (turnResult, error) { - if backend.apiKey == "" { - return turnResult{}, errors.New("OPENROUTER_API_KEY is not set in the environment") - } sessionID := request.SessionID if sessionID == "" { sessionID = steploop.NewAscendingID("ses") @@ -156,9 +152,6 @@ func composeTurnSystem( // network instead of discovering it one failed command at a time. parts = append(parts, netpolicy.Current().EnvironmentNotice()) parts = append(parts, instructions...) - if instruction := attribution.CommitPromptInstruction(); instruction != "" { - parts = append(parts, instruction) - } return strings.Join(nonEmpty(parts...), "\n"), nil } diff --git a/internal/seniordev/app/engine_client.go b/internal/seniordev/app/engine_client.go index d2c36df01..0146b8494 100644 --- a/internal/seniordev/app/engine_client.go +++ b/internal/seniordev/app/engine_client.go @@ -9,7 +9,6 @@ import ( "strings" "sync" - "github.com/Agent-Field/codeaf/internal/seniordev/attribution" "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" @@ -22,7 +21,7 @@ import ( ) type seniorDevModels struct { - backend *openRouterBackend + backend *modelAPIBackend sessionID string agent string variant string @@ -41,14 +40,11 @@ func (models seniorDevModels) GetModel( Model: projection, SessionID: models.sessionID, }) options = orclient.MergeOptions(options, models.backend.config.options(models.agent, providerID, modelID)) - routing, err := models.backend.config.providerRouting(models.agent, providerID, modelID) - if err != nil { - return llmcall.Model{}, err - } - if object := routing.Object(); object != nil { - // The typed block is authoritative over any ad-hoc `options.provider`. - options.SetObject("provider", object) - } + // 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 @@ -187,23 +183,26 @@ func (models seniorDevModels) catalogModel(providerID, modelID string) (calc.Mod }, 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 == "openrouter" { + if before, after, ok := strings.Cut(modelID, "/"); ok && before == orclient.Service { providerID, modelID = before, after } } if providerID == "" { - providerID = "openrouter" + providerID = orclient.Service } - if providerID == "openrouter" { - modelID = strings.TrimPrefix(modelID, "openrouter/") + if providerID == orclient.Service { + modelID = strings.TrimPrefix(modelID, orclient.Service+"/") } return providerID, modelID } type seniorDevClientFactory struct { - backend *openRouterBackend + backend *modelAPIBackend sessionID string models seniorDevModels ledger *turnLedger @@ -223,19 +222,18 @@ func (factory seniorDevClientFactory) Client( } factory.ledger.setModel(model.ProviderID + "/" + model.ID) client := &orclient.Client{ - BaseURL: factory.backend.baseURL(), - Headers: seniorDevOpenRouterHeadersWithConfig( - factory.backend.apiKey, factory.sessionID, - factory.backend.config.headers(model.ProviderID, model.ID), + 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, - } - if factory.backend.client != nil { - client.Fetcher = factory.backend.client.Do + // 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, @@ -245,7 +243,7 @@ func (factory seniorDevClientFactory) Client( } type seniorDevStreamClient struct { - backend *openRouterBackend + backend *modelAPIBackend sessionID string client *orclient.Client model orclient.Model @@ -292,16 +290,14 @@ func (client seniorDevStreamClient) visibleTools(tools []orclient.Tool) []orclie return out } -func seniorDevOpenRouterHeadersWithConfig( - apiKey, sessionID string, configured []orclient.HeaderPair, -) []orclient.HeaderPair { - provider := []orclient.HeaderPair{{Name: "Authorization", Value: "Bearer " + apiKey}} - for _, pair := range attribution.OpenRouterHeaderPairs() { - provider = append(provider, orclient.HeaderPair{Name: pair[0], Value: pair[1]}) - } - provider = append(provider, configured...) +// 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: provider, + Provider: configured, ProviderUserAgentSuffix: "ai-sdk/openrouter/2.8.1", Call: []orclient.HeaderPair{ {Name: "x-session-affinity", Value: sessionID}, @@ -360,7 +356,7 @@ func (ledger *turnLedger) snapshot() []turnCall { } type seniorDevLLM struct { - backend *openRouterBackend + backend *modelAPIBackend models seniorDevModels service *llmcall.Service ledger *turnLedger @@ -373,7 +369,7 @@ type seniorDevLLM struct { } func newSeniorDevLLM( - backend *openRouterBackend, + backend *modelAPIBackend, sessionID, providerID, modelID, agent, variant string, system func(context.Context) string, ledger *turnLedger, @@ -500,15 +496,6 @@ func finishCost(finish orclient.FinishPart) float64 { return cost } -func (backend *openRouterBackend) baseURL() string { - endpoint := strings.TrimRight(backend.endpoint, "/") - if endpoint == "" { - return "https://openrouter.ai/api/v1" - } - endpoint = strings.TrimSuffix(endpoint, "/chat/completions") - return strings.TrimRight(endpoint, "/") -} - var _ llmcall.ModelResolver = seniorDevModels{} var _ steploop.ModelResolver = seniorDevModels{} var _ llmcall.ClientFactory = seniorDevClientFactory{} diff --git a/internal/seniordev/app/engine_compaction.go b/internal/seniordev/app/engine_compaction.go index e533f045f..866d28982 100644 --- a/internal/seniordev/app/engine_compaction.go +++ b/internal/seniordev/app/engine_compaction.go @@ -11,13 +11,13 @@ import ( "strings" "unicode/utf16" - "github.com/Agent-Field/codeaf/internal/seniordev/attribution" "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 { @@ -129,7 +129,7 @@ func newSeniorDevCompactionController( summaryClient steploop.LLMClient, resolver steploop.ModelResolver, workspace string, - backend *openRouterBackend, + backend *modelAPIBackend, system func(context.Context) string, tools []steploop.ToolDefinition, decisions compaction.DecisionSink, @@ -190,7 +190,7 @@ func seniorDevChangedFiles(ctx context.Context, workspace string) []string { return nil } git := func(args ...string) ([]string, bool) { - argv := attribution.GitArgv(args...) + argv := util.GitArgv(args...) command := exec.CommandContext(ctx, argv[0], argv[1:]...) command.Dir = workspace out, err := command.Output() diff --git a/internal/seniordev/app/engine_contract_test.go b/internal/seniordev/app/engine_contract_test.go index 708a1c3cd..0144d3920 100644 --- a/internal/seniordev/app/engine_contract_test.go +++ b/internal/seniordev/app/engine_contract_test.go @@ -54,7 +54,7 @@ func TestSeniorDevEngineStreamsShapesAndRepairsMisCasedToolCall(t *testing.T) { request, http.StatusOK, "text/event-stream", chatReply("done", 10), ), nil })} - backend := &openRouterBackend{apiKey: "test", client: client, variant: "high"} + 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, @@ -146,7 +146,7 @@ func TestSeniorDevAdaptiveRouterFailsOverAndRegistersOutcomes(t *testing.T) { request, http.StatusOK, "text/event-stream", chatReply("recovered", 10), ), nil })} - backend := &openRouterBackend{apiKey: "test", client: client, router: router} + 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, @@ -185,7 +185,7 @@ func TestSeniorDevCostCapTripsFromEngineLedger(t *testing.T) { responses = responses[1:] return recordedResponse(request, http.StatusOK, "text/event-stream", response), nil })} - runtime := newRuntime(t.TempDir(), &openRouterBackend{apiKey: "test", client: client}) + runtime := newRuntime(t.TempDir(), &modelAPIBackend{api: testModelAPI, client: client}) t.Cleanup(runtime.Close) result, err := runTestTurn(t, runtime, testTurn{ Agent: "coder", ProviderID: "openrouter", @@ -229,7 +229,7 @@ func TestSeniorDevDeadlineCancelsMidStream(t *testing.T) { Body: body, Request: request, }, nil })} - backend := &openRouterBackend{apiKey: "test", client: client} + backend := &modelAPIBackend{api: testModelAPI, client: client} ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() started := time.Now() diff --git a/internal/seniordev/app/engine_prompt_test.go b/internal/seniordev/app/engine_prompt_test.go index 86bc1418d..c5ed21799 100644 --- a/internal/seniordev/app/engine_prompt_test.go +++ b/internal/seniordev/app/engine_prompt_test.go @@ -68,7 +68,7 @@ func TestCoderRequestSystemPromptOrderAndEnvironment(t *testing.T) { } return recordedResponse(request, http.StatusOK, "text/event-stream", chatReply("done", 10)), nil })} - backend := &openRouterBackend{apiKey: "test", client: client} + backend := &modelAPIBackend{api: testModelAPI, client: client} vcs := "git" ctx := project.WithContext(context.Background(), project.InstanceContext{ Directory: active, Worktree: workspace, diff --git a/internal/seniordev/app/engine_router.go b/internal/seniordev/app/engine_router.go index 5ad215df7..5f58a380f 100644 --- a/internal/seniordev/app/engine_router.go +++ b/internal/seniordev/app/engine_router.go @@ -11,7 +11,7 @@ type adaptiveRouterBackend interface { setAdaptiveRouter(*adaptive.AdaptiveModelRouter) } -func (backend *openRouterBackend) setAdaptiveRouter(router *adaptive.AdaptiveModelRouter) { +func (backend *modelAPIBackend) setAdaptiveRouter(router *adaptive.AdaptiveModelRouter) { backend.router = router } diff --git a/internal/seniordev/app/events.go b/internal/seniordev/app/events.go index 8c3caaa0c..936fd7ab8 100644 --- a/internal/seniordev/app/events.go +++ b/internal/seniordev/app/events.go @@ -1,11 +1,13 @@ //go:build !windows -// This file is the NDJSON event stream: the stage events senior-dev emits on -// stdout and the bus payloads it forwards there unchanged. +// 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" @@ -14,18 +16,13 @@ import ( ) 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"` - TraceID string `json:"trace_id,omitempty"` - Step uint64 `json:"step,omitempty"` - Occurrence uint64 `json:"occurrence,omitempty"` - Title string `json:"title,omitempty"` - ElapsedMS int64 `json:"elapsed_ms,omitempty"` + 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"` @@ -34,17 +31,50 @@ type event struct { Observation string `json:"observation,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, status string) + Step(command, observation string) +} + +// 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 + 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 - hook func(event) - trace *runTrace + // 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 only the stage and its status. + 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{} } +// 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), @@ -53,68 +83,67 @@ func newEventWriter(output io.Writer) *eventWriter { } } -func (writer *eventWriter) setHook(hook func(event)) { - if writer == nil { - return - } - writer.mu.Lock() - writer.hook = hook - writer.mu.Unlock() -} - -// enableTrace mirrors semantic run events as structured records on notes. -// stdout remains the exhaustive NDJSON event stream; notes is stderr in the -// shipped binary, so the readable trace goes wherever stderr goes. -func (writer *eventWriter) enableTrace(notes io.Writer, runID string) { - if writer == nil || notes == nil { - return +// 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{}{}, } - writer.mu.Lock() - writer.trace = newRunTrace(notes, runID) - writer.mu.Unlock() } func (writer *eventWriter) emit(value event) { - if writer == nil || writer.encoder == nil { + 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() - if writer.trace != nil { - value = writer.trace.event(value) + defer writer.mu.Unlock() + if writer.encoder != nil { + _ = writer.encoder.Encode(value) } - _ = writer.encoder.Encode(value) - if writer.hook != nil { - writer.hook(value) + switch value.Type { + case "stage": + if writer.records != nil { + writer.records.Stage(value.Stage, value.Status) + } + writer.noteStage(value) + case "step": + if writer.records != nil { + writer.records.Step(value.Command, value.Observation) + } } - writer.mu.Unlock() } -// emitUntraced writes a record to stdout without mirroring it into the stderr -// trace. `spend` and `step` exist for a reader consuming stdout; the trace -// already carries its own tool and cost records, and duplicating them there -// would bury the semantic trace under one entry per tool call. -func (writer *eventWriter) emitUntraced(value event) { - if writer == nil || writer.encoder == nil { +// 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 } - if value.Timestamp == 0 { - value.Timestamp = time.Now().UnixMilli() - } - writer.mu.Lock() - _ = writer.encoder.Encode(value) - if writer.hook != nil { - writer.hook(value) + line := "[senior-dev] " + value.Stage + " · " + value.Status + if len(value.Data) > 0 { + if data, err := json.Marshal(value.Data); err == nil { + line += " " + string(data) + } } - writer.mu.Unlock() + _, _ = fmt.Fprintln(writer.notes, line) } -// busEvent writes the instance-bus payload without wrapping or renaming it: -// every such line has exactly the Bus.Payload shape {id,type,properties}. +// 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 || writer.encoder == nil { + if writer == nil { return } // Observed OUTSIDE the writer lock: the summary keeps its own mutex, so @@ -122,9 +151,8 @@ func (writer *eventWriter) busEvent(value bus.Payload) { spend, completed := writer.summary.observeBus(value) step, isStep := toolStepRecord(value) writer.mu.Lock() - _ = writer.encoder.Encode(value) - if writer.trace != nil { - writer.trace.busEvent(value) + if writer.encoder != nil { + _ = writer.encoder.Encode(value) } if isStep { if _, seen := writer.steps[step.key]; seen { @@ -136,18 +164,18 @@ func (writer *eventWriter) busEvent(value bus.Payload) { 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 stream already published. + // the bus already published. if isStep { - writer.emitUntraced(event{ + writer.emit(event{ Type: "step", Command: step.command, Observation: step.observation, }) } - // The running total, after the message that moved it. A reader enforcing a - // dollar ceiling while the run is alive reads this and nothing else: the - // agent-summary and terminal totals arrive only once the run is over. - if completed { + // 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.emitUntraced(event{Type: "spend", CostUSD: &total}) + writer.emit(event{Type: "spend", CostUSD: &total}) } } diff --git a/internal/seniordev/app/events_contract_test.go b/internal/seniordev/app/events_contract_test.go index 52c5bf38d..95fb4353c 100644 --- a/internal/seniordev/app/events_contract_test.go +++ b/internal/seniordev/app/events_contract_test.go @@ -6,7 +6,6 @@ import ( "bytes" "context" "encoding/json" - "errors" "strings" "testing" @@ -14,7 +13,10 @@ import ( "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore" ) -func TestQuestionToolEventsReachStdoutAsBusPayloads(t *testing.T) { +// 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{ @@ -57,7 +59,7 @@ func TestQuestionToolEventsReachStdoutAsBusPayloads(t *testing.T) { } } -func TestPipelineStreamsBusEventsToStdout(t *testing.T) { +func TestPipelineStreamsBusEventsToTheLog(t *testing.T) { workspace := testRepoWithEntrypoints(t) var output bytes.Buffer runner := newPipeline(cliArgs{High: "provider/high"}, workspace, pipelineDeps{ @@ -95,53 +97,45 @@ func TestPipelineStreamsBusEventsToStdout(t *testing.T) { } } -func TestRunFormatAcceptsDefaultAndJSONOnly(t *testing.T) { - for _, format := range []string{"default", "json"} { - t.Run("accept_"+format, func(t *testing.T) { - args, err := parseArgs([]string{"run", "--format", format, "work"}) - if err != nil { - t.Fatalf("parseArgs rejected format %q: %v", format, err) - } - if args.Format != format { - t.Fatalf("format = %q, want %q", args.Format, format) - } - }) - } +// recordedHost is the part of a delegate host the event writer reports to. +type recordedHost struct { + stages []string + steps []string +} - for _, format := range []string{"ndjson", "text", "pretty", "yaml"} { - t.Run("reject_"+format, func(t *testing.T) { - _, err := parseArgs([]string{"run", "--format", format, "work"}) - if err == nil || !strings.Contains(err.Error(), "default or json") { - t.Fatalf("parseArgs format %q error = %v, want default/json rejection", format, err) - } - }) - } +func (host *recordedHost) Stage(stage, status string) { + host.stages = append(host.stages, stage+"/"+status) +} - args, err := parseArgs([]string{"run", "work"}) - if err != nil { - t.Fatal(err) - } - if args.Format != "json" { - t.Fatalf("default format = %q, want json", args.Format) - } +func (host *recordedHost) Step(command, observation string) { + host.steps = append(host.steps, command) } -func TestTUIFailsLoudlyBeforePipelineStartup(t *testing.T) { - var stdout, stderr bytes.Buffer - err := runCLI( - context.Background(), []string{"run", "--tui", "work"}, nil, - &stdout, &stderr, - ) - var exit *cliExitError - if !errors.As(err, &exit) || exit.code != 1 { - t.Fatalf("--tui error = %#v, want cli exit code 1", err) +// 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 to +// the notes, which are stderr, for a person. +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 ./..."}, "", "")) + writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok", "")) + writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok", "")) + 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 len(host.steps) != 1 || host.steps[0] != "bash: go test ./..." { + t.Fatalf("steps = %v, want the one finished call, once", host.steps) } - if stdout.Len() != 0 { - t.Fatalf("--tui stdout = %q, want empty", stdout.String()) + if !strings.Contains(notes.String(), `implement · running {"attempt":0}`) { + t.Fatalf("notes = %q, want the stage and its data for a person", notes.String()) } - const message = "--tui is not supported" - if !strings.Contains(stderr.String(), message) || - !strings.Contains(stderr.String(), "headless NDJSON event stream") { - t.Fatalf("--tui stderr = %q, want clear unsupported/headless message", stderr.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_run.go b/internal/seniordev/app/full_verification_run.go index 8ed8db204..4dbed6795 100644 --- a/internal/seniordev/app/full_verification_run.go +++ b/internal/seniordev/app/full_verification_run.go @@ -137,6 +137,16 @@ func (run *projectVerificationRun) execute(observation *verificationObservation) 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() diff --git a/internal/seniordev/app/model_request_events_test.go b/internal/seniordev/app/model_request_events_test.go index cfa3f3b11..11fcb24f3 100644 --- a/internal/seniordev/app/model_request_events_test.go +++ b/internal/seniordev/app/model_request_events_test.go @@ -149,7 +149,7 @@ func TestModelRequestTelemetryLeavesWireAndResultsUnchanged(t *testing.T) { var body []byte var header http.Header var events []modelRequestEvent - backend := &openRouterBackend{apiKey: "not-a-real-key", variant: "high", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + 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. @@ -218,7 +218,7 @@ func TestModelRequestTelemetryLeavesWireAndResultsUnchanged(t *testing.T) { func TestModelRequestBeginFailureAndCancellation(t *testing.T) { for _, failure := range []error{errors.New("PRIVATE HTTP FAILURE"), context.Canceled, context.DeadlineExceeded} { var events []modelRequestEvent - backend := &openRouterBackend{apiKey: "test", client: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, failure })}} + 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{}) @@ -262,7 +262,7 @@ func TestModelRequestCanceledBeforeReadAndNilSinkClose(t *testing.T) { } func TestModelRequestRuntimeWiring(t *testing.T) { - backend := &openRouterBackend{apiKey: "test", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + 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) @@ -294,7 +294,7 @@ func TestModelRequestBusSink(t *testing.T) { func TestModelRequestResolutionFailure(t *testing.T) { var events []modelRequestEvent - backend := &openRouterBackend{catalog: seniorDevCatalogFixture(t)} + 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{}) diff --git a/internal/seniordev/app/pipeline.go b/internal/seniordev/app/pipeline.go index fbdef6e49..45afd5c9f 100644 --- a/internal/seniordev/app/pipeline.go +++ b/internal/seniordev/app/pipeline.go @@ -24,13 +24,12 @@ import ( ) type pipelineDeps struct { - Backend backend - Config *seniorDevConfig - Events *eventWriter - Notes io.Writer - CPBridge *cpBridge - Now func() time.Time - Sleep func(context.Context, time.Duration) error + Backend backend + Config *seniorDevConfig + Events *eventWriter + Notes io.Writer + Now func() time.Time + Sleep func(context.Context, time.Duration) error } type pipeline struct { @@ -41,9 +40,6 @@ type pipeline struct { pool poolResolver events *eventWriter notes io.Writer - cpBridge *cpBridge - cpURL string - cpEnabled bool // recorder identifies, compares, freezes and restores the tree. Set in // prepareWorkspace, once the workspace path is absolute. recorder workspaceRecorder @@ -142,7 +138,7 @@ func newPipeline(args cliArgs, workspace string, deps pipelineDeps) *pipeline { _, _ = io.WriteString(notes, message) }), sessionID: runtime.nextID("session"), runtime: runtime, pool: pool, - events: deps.Events, notes: notes, cpBridge: deps.CPBridge, + events: deps.Events, notes: notes, now: now, sleep: sleep, wallStart: now(), budget: runbudget.ResolveRunBudget(&runbudget.RunBudgetFlags{ MaxCost: args.MaxCost, MaxHours: args.MaxHours, @@ -187,14 +183,6 @@ func (runner *pipeline) run( "frontier_models": runner.pool.values(baked.TierFrontier), "entry_agent": "coder", "senior_dev_environment": safeSeniorDevEnvironment(), - // Whether this run mirrored onto a control plane, and the URL it - // probed to decide. A standalone run is a legitimate shape, so the - // contract says which one happened rather than leaving it inferable - // only from the absence of other evidence. - "control_plane": map[string]any{ - "enabled": runner.cpEnabled, - "url": runner.cpURL, - }, // 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(), diff --git a/internal/seniordev/app/pipeline_smoke_test.go b/internal/seniordev/app/pipeline_smoke_test.go index 18d6ab712..fdd51e451 100644 --- a/internal/seniordev/app/pipeline_smoke_test.go +++ b/internal/seniordev/app/pipeline_smoke_test.go @@ -15,6 +15,7 @@ import ( "strings" "testing" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" ) @@ -296,7 +297,7 @@ func TestBudgetExhaustedRunStillShipsAndReportsWhy(t *testing.T) { } // TestTerminalEventCarriesTheRunsAccount pins the contract at the boundary an -// external reader sees: the type=="terminal" event -- not a stage named +// 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) { @@ -306,14 +307,9 @@ func TestTerminalEventCarriesTheRunsAccount(t *testing.T) { Terminal: map[string]any{"submitted": false, "nudges": 2, "reason": "no submission"}, } var out bytes.Buffer - invocation := &cliInvocation{ - events: newEventWriter(&out), - runner: newPipeline(cliArgs{}, t.TempDir(), pipelineDeps{ - Events: newEventWriter(io.Discard), Notes: io.Discard, - }), - } - defer invocation.runner.runtime.Close() - invocation.persistTerminalResult(result) + 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")) { diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go new file mode 100644 index 000000000..e86d41e02 --- /dev/null +++ b/internal/seniordev/app/run.go @@ -0,0 +1,330 @@ +//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/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 and router-cancellation happen inside +// a model turn, only when a rejection pins a window or a call is withdrawn, so +// they sit where the turns are. +var Stages = []string{ + "bootstrap", + "run-contract", + "intake", + "landing", + "implement", + "agent-runtime", + "compaction-capacity", + "router-cancellation", + "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 +} + +// 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 + } + + 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 +} + +// 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. +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 index 161ef4065..b9157dade 100644 --- a/internal/seniordev/app/run_error_classify_test.go +++ b/internal/seniordev/app/run_error_classify_test.go @@ -3,6 +3,7 @@ package app import ( + "context" "errors" "fmt" "io" @@ -22,7 +23,7 @@ func TestClassifyRunErrorMapsBudgetSentinelFromAnyPhase(t *testing.T) { wrapped := fmt.Errorf("landing turn: %w", fmt.Errorf( "%w: cost $0.6172 >= budget $0.6000", errRunBudget, )) - result, err := classifyRunError(runner, pipelineResult{Status: "crashed"}, wrapped) + 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) } @@ -31,15 +32,51 @@ func TestClassifyRunErrorMapsBudgetSentinelFromAnyPhase(t *testing.T) { } infrastructure := errors.New("provider wiring exploded") - result, err = classifyRunError(runner, pipelineResult{Status: "crashed"}, infrastructure) + 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(runner, passResult, nil) + 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 index 55a39c155..70f19ff60 100644 --- a/internal/seniordev/app/runtime.go +++ b/internal/seniordev/app/runtime.go @@ -16,6 +16,7 @@ import ( "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" @@ -138,13 +139,6 @@ func (runtime *runtimeAdapter) emitTurnProvenance(configured turn) { if configured.Variant != "" { data["reasoning_effort"] = configured.Variant } - // Provider routing changes which upstream serves the turn, so a run - // that sets it must be readable from the stream alone. The block has - // already been validated at config load, so the error is spent. - routing, err := runtime.config.providerRouting(configured.Agent, configured.ProviderID, configured.ModelID) - if err == nil && !routing.IsZero() { - data["provider_routing"] = routing - } data["compaction"] = runtime.compactionProvenance(configured) runtime.events.stage("agent-runtime", "configured", data) } @@ -156,7 +150,7 @@ func (runtime *runtimeAdapter) emitTurnProvenance(configured turn) { // 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.(*openRouterBackend); ok && concrete != nil && err == nil { + 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 @@ -177,7 +171,7 @@ func (runtime *runtimeAdapter) compactionProvenance(configured turn) map[string] record["configured_preserve_recent_fraction"] = *cfg.Compaction.PreserveRecentFraction } } - concrete, ok := runtime.backend.(*openRouterBackend) + concrete, ok := runtime.backend.(*modelAPIBackend) if !ok || concrete == nil { return record } @@ -273,7 +267,7 @@ func (runtime *runtimeAdapter) runTurn(ctx context.Context, request turn) (turnR return turnResult{}, errors.New("senior-dev runtime: backend is required") } if request.Variant == "" { - if concrete, ok := runtime.backend.(*openRouterBackend); ok { + if concrete, ok := runtime.backend.(*modelAPIBackend); ok { request.Variant = concrete.variant } } @@ -508,11 +502,18 @@ func (resolver poolResolver) values(tier baked.Tier) []string { return append([]string{}, pool...) } -type openRouterBackend struct { - apiKey string +// 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 - endpoint string contextLimit float64 outputLimit float64 totalTimeoutMS float64 @@ -550,14 +551,10 @@ func executeAdvertisedTool( return steploop.ToolResult{}, errors.New(message) } -func defaultBackend(variant string) backend { - endpoint := "" - if base := os.Getenv("OPENROUTER_BASE_URL"); base != "" { - endpoint = openRouterEndpoint(base) - } - return &openRouterBackend{ - apiKey: os.Getenv("OPENROUTER_API_KEY"), variant: variant, - endpoint: endpoint, +// 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. @@ -565,8 +562,18 @@ func defaultBackend(variant string) backend { } } -func openRouterEndpoint(base string) string { - base = strings.TrimRight(base, "/") - base = strings.TrimSuffix(base, "/api/v1") - return base + "/api/v1/chat/completions" +// 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 index 825a6aad7..23f452366 100644 --- a/internal/seniordev/app/runtime_compaction_test.go +++ b/internal/seniordev/app/runtime_compaction_test.go @@ -151,8 +151,8 @@ func TestOpenRouterRejectsToolOmittedFromRequestDefinitions(t *testing.T) { toolCallReply("write", string(arguments)), chatReply("continued after rejection", 10), }} - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, } runtime := newRuntime(workspace, backend) t.Cleanup(runtime.Close) @@ -217,8 +217,8 @@ func TestOpenRouterSystemIncludesRootInstructionsAndReadOnlyInjectsNestedRules(t transport := &scriptedRoundTripper{replies: []string{ toolCallReply("read", string(arguments)), chatReply("done", 10), }} - runtime := newRuntime(workspace, &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + runtime := newRuntime(workspace, &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, }) if _, err := runTestTurn(t, runtime, testTurn{ Agent: "coder", ModelID: "vendor/model", @@ -253,8 +253,8 @@ func TestOpenRouterCompactsContextAndContinues(t *testing.T) { chatReply(validCompactionSummary("anchored summary for the original task"), 10), chatReply("finished after compaction", 10), }} - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, contextLimit: 128_000, outputLimit: 32_768, } summaryPathConfig(t).applyBackend(backend) @@ -346,8 +346,8 @@ func TestProjectConfigDisablesAutoCompactionOnLiveTurn(t *testing.T) { transport := &scriptedRoundTripper{replies: []string{ chatReply("finished without compaction", 70_000), }} - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, } loaded.applyBackend(backend) result, err := backend.Run(context.Background(), turn{ @@ -375,8 +375,8 @@ func TestOpenRouterCompactionHarvestsEvidenceByCodeAlone(t *testing.T) { chatReply(validCompactionSummary("fix the widget"), 10), chatReply("finished", 10), }} - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, contextLimit: 128_000, outputLimit: 32_768, } summaryPathConfig(t).applyBackend(backend) @@ -420,8 +420,8 @@ func TestOpenRouterCompactionResetsTheObservationWindow(t *testing.T) { chatReply(validCompactionSummary("summary after rejected stale call"), 10), chatReply("finished in fresh window", 10), }} - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, contextLimit: 128_000, outputLimit: 32_768, } summaryPathConfig(t).applyBackend(backend) @@ -466,8 +466,8 @@ func TestOpenRouterSummaryFailureInstallsRecordAndContinues(t *testing.T) { }, statuses: []int{http.StatusOK, http.StatusBadGateway, http.StatusOK}, } - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, contextLimit: 128_000, outputLimit: 32_768, } summaryPathConfig(t).applyBackend(backend) @@ -507,8 +507,8 @@ func TestOpenRouterHardOverflowCompactsAndRetries(t *testing.T) { }, statuses: []int{http.StatusBadRequest, http.StatusOK, http.StatusOK}, } - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, } summaryPathConfig(t).applyBackend(backend) result, err := backend.Run(context.Background(), turn{ @@ -536,8 +536,8 @@ func TestOpenRouterAllowsMoreThanThreeSuccessfulCompactions(t *testing.T) { } replies = append(replies, chatReply("natural stop", 10)) transport := &scriptedRoundTripper{replies: replies} - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, contextLimit: 128_000, outputLimit: 32_768, } summaryPathConfig(t).applyBackend(backend) @@ -574,8 +574,8 @@ func TestOpenRouterStopsWhenAuthoritativeTaskCannotFitAfterRebuild(t *testing.T) chatReply("overflow", 70_000), chatReply(validCompactionSummary("task"), 10), }} - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, contextLimit: 128_000, outputLimit: 32_768, } summaryPathConfig(t).applyBackend(backend) @@ -601,7 +601,7 @@ func TestOpenRouterEngineHasNoUnconditionalSixtyFourTurnCap(t *testing.T) { } replies = append(replies, chatReply("natural stop", 10)) transport := &scriptedRoundTripper{replies: replies} - backend := &openRouterBackend{apiKey: "test", client: &http.Client{Transport: transport}} + 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, @@ -631,8 +631,8 @@ func TestOpenRouterReloadsRootInstructionsEachTurn(t *testing.T) { transport := &scriptedRoundTripper{replies: []string{ toolCallReply("write", string(arguments)), chatReply("done", 10), }} - runtime := newRuntime(workspace, &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + runtime := newRuntime(workspace, &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, }) t.Cleanup(runtime.Close) if _, err := runTestTurn(t, runtime, testTurn{ @@ -660,8 +660,8 @@ func TestOpenRouterEmptyBodyOverflowCompacts(t *testing.T) { }, statuses: []int{http.StatusBadRequest, http.StatusOK, http.StatusOK}, } - backend := &openRouterBackend{ - apiKey: "test", client: &http.Client{Transport: transport}, + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, } summaryPathConfig(t).applyBackend(backend) result, err := backend.Run(context.Background(), turn{ diff --git a/internal/seniordev/app/runtime_retry_test.go b/internal/seniordev/app/runtime_retry_test.go index d455ac5e0..81f025041 100644 --- a/internal/seniordev/app/runtime_retry_test.go +++ b/internal/seniordev/app/runtime_retry_test.go @@ -48,7 +48,7 @@ func TestModelCallNeverRetriesInsideTheEngine(t *testing.T) { return recordedResponse(request, status, "application/json", string(encoded)), nil })} - backend := &openRouterBackend{apiKey: "test", client: client} + backend := &modelAPIBackend{api: testModelAPI, client: client} _, err := backend.Run(context.Background(), retryTurn()) if err == nil { t.Fatal("provider failure returned nil") @@ -73,7 +73,7 @@ func TestInBandProviderFailureReachesRunClassifierWithStatus(t *testing.T) { requests++ return recordedResponse(request, http.StatusOK, "text/event-stream", body), nil })} - backend := &openRouterBackend{apiKey: "test", client: client} + 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) @@ -136,7 +136,7 @@ func TestFailureAfterToolCallDoesNotReplayRequestOrTool(t *testing.T) { Request: request, }, nil })} - backend := &openRouterBackend{apiKey: "test", client: client, chunkTimeoutMS: -1} + 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(), @@ -208,8 +208,8 @@ func TestSoloRecoveryCrossesThePersistedEngineBoundaryWithoutReplayingToolEffect Request: request, }, nil })} - backend := &openRouterBackend{ - apiKey: "test", client: client, totalTimeoutMS: -1, chunkTimeoutMS: -1, + 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{}, diff --git a/internal/seniordev/app/runtime_test.go b/internal/seniordev/app/runtime_test.go index ab3f98b23..2ecdc31df 100644 --- a/internal/seniordev/app/runtime_test.go +++ b/internal/seniordev/app/runtime_test.go @@ -4,8 +4,11 @@ package app import ( "context" + "io" + "net/http" "reflect" "slices" + "strings" "testing" "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" @@ -20,36 +23,36 @@ func (backend *capturingBackend) Run(_ context.Context, request turn) (turnResul return turnResult{}, nil } -func TestOpenRouterEndpoint(t *testing.T) { - // Both bare proxy roots and already-versioned roots produce one - // /api/v1 segment before chat/completions. - for _, test := range []struct { - input string - want string - }{ - {input: "http://proxy", want: "http://proxy/api/v1/chat/completions"}, - {input: "http://proxy/", want: "http://proxy/api/v1/chat/completions"}, - {input: "http://proxy/api/v1", want: "http://proxy/api/v1/chat/completions"}, - {input: "http://proxy/api/v1/", want: "http://proxy/api/v1/chat/completions"}, - } { - t.Run(test.input, func(t *testing.T) { - if got := openRouterEndpoint(test.input); got != test.want { - t.Fatalf("openRouterEndpoint(%q) = %q, want %q", test.input, got, test.want) - } - }) +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) } } -func TestDefaultBackendHasNoHTTPClientWallClockTimeout(t *testing.T) { - configured, ok := defaultBackend("").(*openRouterBackend) - if !ok { - t.Fatalf("defaultBackend type = %T, want *openRouterBackend", defaultBackend("")) +// 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) } - if configured.client == nil { - t.Fatal("default backend has no HTTP client") + request.Header.Set("Authorization", "Bearer somebody-elses-key") + if _, err := backend.fetch(request); err != nil { + t.Fatal(err) } - if configured.client.Timeout != 0 { - t.Fatalf("default HTTP client timeout = %s, want disabled", configured.client.Timeout) + if seen != "Bearer "+testModelAPI.Token { + t.Fatalf("Authorization = %q, want the model API's token", seen) } } diff --git a/internal/seniordev/app/solo_finalize.go b/internal/seniordev/app/solo_finalize.go index 5cf678c08..dfc08e183 100644 --- a/internal/seniordev/app/solo_finalize.go +++ b/internal/seniordev/app/solo_finalize.go @@ -126,7 +126,7 @@ func (runner *pipeline) soloCheckUnsubmitted( runner.rememberVerifiedTree(result) command, dead := verificationShowsDeadTree(result) _, unsafe := verificationShowsSafetyRegression(result) - runner.events.stage("landing", "verified", map[string]any{ + 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, diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go index 9d5259a9f..140ed9349 100644 --- a/internal/seniordev/app/solo_ship.go +++ b/internal/seniordev/app/solo_ship.go @@ -44,7 +44,7 @@ func (runner *pipeline) soloShip( if reason, blocked := runner.verificationUnaffordable(ctx); blocked { outcome.Status = "pass-unverified" runner.soloTerminal(outcome, fmt.Sprintf( - "%s; shipping the submitted candidate unverified: %s", + "%s; shipping the submitted candidate, which nothing checked: %s", reason, candidate.describe(), )) runner.soloRestoreIfDiverged(state, outcome) @@ -59,6 +59,16 @@ func (runner *pipeline) soloShip( 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. @@ -75,7 +85,7 @@ func (runner *pipeline) soloShip( // verified pass. outcome.Status = "pass" runner.soloTerminal(outcome, fmt.Sprintf( - "submitted and verified: %s (%s)", candidate.describe(), candidate.Reason, + "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 @@ -185,9 +195,17 @@ func (runner *pipeline) soloTerminal(outcome *soloOutcome, reason string) { data["frozen_commit"] = candidate.CommitSHA } if verification := outcome.Verification; verification != nil { - data["verification_failing"] = countFailingEntrypoints(*verification) + 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 diff --git a/internal/seniordev/app/stages_test.go b/internal/seniordev/app/stages_test.go new file mode 100644 index 000000000..e95599d06 --- /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/step_records.go b/internal/seniordev/app/step_records.go index 758462a8d..2f8ffc64b 100644 --- a/internal/seniordev/app/step_records.go +++ b/internal/seniordev/app/step_records.go @@ -3,6 +3,7 @@ package app import ( + "encoding/json" "sort" "strings" "unicode/utf8" @@ -117,3 +118,38 @@ func clipBytes(text string, max int) string { } 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/stop_test.go b/internal/seniordev/app/stop_test.go new file mode 100644 index 000000000..1e9f2d3b6 --- /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 index 0cd7cd664..5fd345883 100644 --- a/internal/seniordev/app/testsupport_test.go +++ b/internal/seniordev/app/testsupport_test.go @@ -7,12 +7,13 @@ package app 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" ) @@ -21,6 +22,62 @@ import ( // 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, status string) { + host.mu.Lock() + defer host.mu.Unlock() + host.stages = append(host.stages, stage+"/"+status) +} + +func (host *testHost) Step(command, observation string) { + host.mu.Lock() + defer host.mu.Unlock() + host.steps = append(host.steps, 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) diff --git a/internal/seniordev/app/tier_test.go b/internal/seniordev/app/tier_test.go index 2e80924ca..5600a6697 100644 --- a/internal/seniordev/app/tier_test.go +++ b/internal/seniordev/app/tier_test.go @@ -85,7 +85,7 @@ func TestASingleHighPoolRoutesEveryTierIdentically(t *testing.T) { // 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} + args := cliArgs{High: DefaultHighModels} tiered, flat := tierRouter(args), tierRouter(args) for round := 0; round < 6; round++ { for _, agent := range []string{"coder", "compaction"} { diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go index c6f021859..2f79b7d98 100644 --- a/internal/seniordev/app/workspace_recorder_git.go +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -15,7 +15,6 @@ import ( "strings" "time" - "github.com/Agent-Field/codeaf/internal/seniordev/attribution" "github.com/Agent-Field/codeaf/internal/seniordev/util" ) @@ -36,7 +35,7 @@ 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 := attribution.GitArgv(args...) + argv := util.GitArgv(args...) cmd := exec.Command(argv[0], argv[1:]...) cmd.Dir = recorder.workspace out, err := cmd.CombinedOutput() diff --git a/internal/seniordev/app/workspace_recorder_test.go b/internal/seniordev/app/workspace_recorder_test.go index b02bdc899..ba8d600c3 100644 --- a/internal/seniordev/app/workspace_recorder_test.go +++ b/internal/seniordev/app/workspace_recorder_test.go @@ -237,16 +237,14 @@ func TestInPlaceRunLeavesGitHistoryAlone(t *testing.T) { before := gitOutput(context.Background(), workspace, "rev-parse", "HEAD") beforeLog := gitOutput(context.Background(), workspace, "log", "--oneline") - t.Setenv("SENIOR_DEV_CP_URL", deadControlPlaneURL(t)) t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) - args := []string{ - "run", "--in-place", "--dir", workspace, "--high", "provider/high", - "Implement the thing.", - } - backend := &coderOnlyBackend{} - var stdout, stderr strings.Builder - if err := runCLI(context.Background(), args, backend, &stdout, &stderr); err != nil { - t.Fatalf("in-place run failed: %v\n%s", err, stderr.String()) + 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") @@ -256,7 +254,7 @@ func TestInPlaceRunLeavesGitHistoryAlone(t *testing.T) { 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(stdout.String(), `"workspace_recorder":"snapshot"`) { + if !strings.Contains(notes.String(), `"workspace_recorder":"snapshot"`) { t.Fatal("the run contract does not record the snapshot recorder") } } @@ -276,16 +274,13 @@ func TestInPlaceRunNeedsNoRepository(t *testing.T) { t.Fatal("the fixture is a repository; this test needs one that is not") } - t.Setenv("SENIOR_DEV_CP_URL", deadControlPlaneURL(t)) t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) - args := []string{ - "run", "--in-place", "--dir", workspace, "--high", "provider/high", - "Implement the thing.", - } - backend := &coderOnlyBackend{} - var stdout, stderr strings.Builder - if err := runCLI(context.Background(), args, backend, &stdout, &stderr); err != nil { - t.Fatalf("run without a repository failed: %v\n%s", err, stderr.String()) + 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") @@ -296,16 +291,14 @@ func TestInPlaceRunNeedsNoRepository(t *testing.T) { // path is unchanged, and this is what says so. func TestDefaultRunStillRequiresARepository(t *testing.T) { workspace := t.TempDir() - t.Setenv("SENIOR_DEV_CP_URL", deadControlPlaneURL(t)) - args := []string{ - "run", "--dir", workspace, "--high", "provider/high", "Implement the thing.", - } - err := runCLI(context.Background(), args, &coderOnlyBackend{}, &strings.Builder{}, &strings.Builder{}) - if err == nil { - t.Fatal("a non-repository workspace was accepted without --in-place") - } - if !strings.Contains(err.Error(), "not a git repository") { - t.Fatalf("error does not name the cause: %v", err) + ending := runWith(context.Background(), &testHost{workspace: workspace}, Options{ + Goal: "Implement the thing.", High: "provider/high", + }, &strings.Builder{}, &coderOnlyBackend{}) + if ending.Status != "crashed" { + t.Fatalf("a non-repository workspace was accepted without --in-place: %+v", ending) + } + if !strings.Contains(ending.Message, "not a git repository") { + t.Fatalf("the ending does not name the cause: %q", ending.Message) } } diff --git a/internal/seniordev/engine/orclient/cancellation_test.go b/internal/seniordev/engine/orclient/cancellation_test.go index 58488377a..676368c64 100644 --- a/internal/seniordev/engine/orclient/cancellation_test.go +++ b/internal/seniordev/engine/orclient/cancellation_test.go @@ -21,7 +21,7 @@ func TestCallerCancellationNeutralBeforeHeadersAndOnUndrainedClose(t *testing.T) defer cancel(nil) cause := errors.New("caller gave up") router := &spyRouter{inflight: 1} - client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { if !closeOnly { cancel(cause) return nil, context.Cause(req.Context()) @@ -58,7 +58,7 @@ func TestProviderFailureIsNotHiddenByLaterCallerCancellation(t *testing.T) { router := &spyRouter{inflight: 1} var fire func() t.Cleanup(SetTimerFactoryForTesting(func(_ float64, fn func()) Timer { fire = fn; return &cancellationTestTimer{} })) - client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { if watchdog { fire() cancel(errors.New("caller gave up")) @@ -88,7 +88,7 @@ func TestCompletedSuccessWinsOverLaterCancellation(t *testing.T) { ctx, cancel := context.WithCancelCause(context.Background()) defer cancel(nil) router := &spyRouter{inflight: 1} - client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(*http.Request) (*http.Response, error) { + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(*http.Request) (*http.Response, error) { return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n"))}, nil }} stream, err := client.DoStream(ctx, RequestParams{ModelID: "test"}) @@ -116,7 +116,7 @@ func TestActualParentDeadlineAndMidstreamAbortAreNeutral(t *testing.T) { ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Millisecond, errors.New("caller deadline")) defer cancel() router := &spyRouter{inflight: 1} - client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { if midstream { return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil } diff --git a/internal/seniordev/engine/orclient/client.go b/internal/seniordev/engine/orclient/client.go index 998b3a44d..f1f33bd75 100644 --- a/internal/seniordev/engine/orclient/client.go +++ b/internal/seniordev/engine/orclient/client.go @@ -55,6 +55,7 @@ import ( "sync/atomic" "time" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" "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/router/adaptive" @@ -70,6 +71,9 @@ const ( DefaultChunkTimeoutMS float64 = 120_000 ) +// errNoModelAPI is a request made with no model API to send it to. +var errNoModelAPI = errors.New("senior-dev has no model API to call: codeaf serves one to every run it starts") + // Abort cause messages. Both are matched by `adaptive.IsLikelyTimeout` and // `retrysched.IsTimeoutError`, which is the whole reason they are literals. var ( @@ -178,9 +182,12 @@ type RouterRegistrar interface { // ── client ──────────────────────────────────────────────────────────────── -// Client is one configured OpenRouter endpoint. +// Client is one configured model API: an endpoint that answers in +// OpenRouter's chat-completions shape. type Client struct { - // BaseURL defaults to https://openrouter.ai/api/v1. + // BaseURL is the API's OpenAI-style base URL, the one codeaf serves this + // run. It has no default: a client with none has nowhere to send a request, + // and the only road senior-dev has to a model is the one codeaf hands it. BaseURL string // Headers is BuildHeaders' output. Headers []HeaderPair @@ -259,12 +266,6 @@ func (c *Client) DoStream(ctx context.Context, params RequestParams) (*Stream, e return nil, err } - base := c.BaseURL - if base == "" { - base = "https://openrouter.ai/api/v1" - } - base = strings.TrimRight(base, "/") - routeStart := currentNow()() stream := &Stream{ translator: NewTranslator(), @@ -299,7 +300,17 @@ func (c *Client) DoStream(ctx context.Context, params RequestParams) (*Stream, e stream.ctx = ctxChunk stream.cancel = cancel - req, err := http.NewRequestWithContext(ctxChunk, http.MethodPost, base+"/chat/completions", bytes.NewReader(body)) + // THE ROUTE IS CODEAF'S TO SPELL (modelapi.ChatURL): internal/provider is + // the one package a model route may be written in, and senior-dev's calls + // go to the model API codeaf serves this run and nowhere else. A client + // with no API fails here rather than earlier, so the route lease the router + // took for this call is settled on the same path every other failure takes. + var req *http.Request + if strings.TrimSpace(c.BaseURL) == "" { + err = errNoModelAPI + } else { + req, err = http.NewRequestWithContext(ctxChunk, http.MethodPost, modelapi.ChatURL(c.BaseURL), bytes.NewReader(body)) + } if err != nil { stream.teardown() stream.registerOnce.Do(func() { stream.register(0, err) }) diff --git a/internal/seniordev/engine/orclient/client_test.go b/internal/seniordev/engine/orclient/client_test.go index 9a4c27fd9..d879af588 100644 --- a/internal/seniordev/engine/orclient/client_test.go +++ b/internal/seniordev/engine/orclient/client_test.go @@ -256,7 +256,7 @@ func TestClientDefaultHasNoTotalDeadlineAndKeepsProgressWatchdog(t *testing.T) { }) defer restoreTimer() - client := &Client{Fetcher: func(req *http.Request) (*http.Response, error) { + client := &Client{BaseURL: testBaseURL, Fetcher: func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), @@ -294,7 +294,7 @@ func TestClientProgressWatchdogCoversResponseHeaders(t *testing.T) { }) defer restoreTimer() - client := &Client{Fetcher: func(req *http.Request) (*http.Response, error) { + client := &Client{BaseURL: testBaseURL, Fetcher: func(req *http.Request) (*http.Response, error) { if fire == nil { t.Fatal("reader watchdog was not armed before request dispatch") } @@ -489,7 +489,7 @@ func TestWireCommentKeepalivesResetTheReadWatchdog(t *testing.T) { }) defer restoreFetcher() - c := &Client{Compatibility: CompatibilityCompatible} + c := &Client{BaseURL: testBaseURL, Compatibility: CompatibilityCompatible} c.ChunkTimeoutMS = 30 stream, err := c.DoStream(context.Background(), minimalParams()) if err != nil { @@ -812,7 +812,7 @@ func TestWireErrorPartPreservesStatusForRouterCooldown(t *testing.T) { if err != nil { t.Fatal(err) } - c := &Client{Fetcher: func(request *http.Request) (*http.Response, error) { + c := &Client{BaseURL: testBaseURL, Fetcher: func(request *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, @@ -1074,7 +1074,7 @@ func TestClientTotalTimeoutEmitsAbortWithoutSocket(t *testing.T) { }) defer restore() - client := &Client{TotalTimeoutMS: 5, ChunkTimeoutMS: -1} + client := &Client{BaseURL: testBaseURL, TotalTimeoutMS: 5, ChunkTimeoutMS: -1} stream, err := client.DoStream(context.Background(), minimalParams()) if err != nil { t.Fatalf("DoStream: %v", err) @@ -1102,7 +1102,7 @@ func TestClientCloseCancelsRequestContextWithoutSocket(t *testing.T) { }) defer restore() - client := &Client{TotalTimeoutMS: -1, ChunkTimeoutMS: -1} + client := &Client{BaseURL: testBaseURL, TotalTimeoutMS: -1, ChunkTimeoutMS: -1} stream, err := client.DoStream(context.Background(), minimalParams()) if err != nil { t.Fatalf("DoStream: %v", err) diff --git a/internal/seniordev/engine/orclient/convert.go b/internal/seniordev/engine/orclient/convert.go index 99d6b3ce4..b2e72b764 100644 --- a/internal/seniordev/engine/orclient/convert.go +++ b/internal/seniordev/engine/orclient/convert.go @@ -37,7 +37,7 @@ func getCacheControl(providerOptions json.RawMessage) json.RawMessage { if err != nil { return nil } - for _, ns := range []string{"openrouter", "anthropic"} { + for _, ns := range []string{Service, "anthropic"} { nsRaw, ok := obj.Get(ns) if !ok { continue @@ -385,7 +385,7 @@ func convertUserPart(part any, cacheControl json.RawMessage) (jsonValue, error) } fileName := "" if opts, err := ParseObject(p.ProviderOptions); err == nil { - if nsRaw, ok := opts.Get("openrouter"); ok { + if nsRaw, ok := opts.Get(Service); ok { if ns, err := ParseObject(nsRaw); err == nil { if v, ok := ns.Get("filename"); ok { fileName = textOf(rawJSONValue(v)) @@ -578,7 +578,7 @@ func openrouterNamespaceField(providerOptions json.RawMessage, field string) (js if err != nil { return nil, false } - nsRaw, ok := obj.Get("openrouter") + nsRaw, ok := obj.Get(Service) if !ok { return nil, false } diff --git a/internal/seniordev/engine/orclient/helpers_test.go b/internal/seniordev/engine/orclient/helpers_test.go index 8cf1ee57d..4c0ecbf02 100644 --- a/internal/seniordev/engine/orclient/helpers_test.go +++ b/internal/seniordev/engine/orclient/helpers_test.go @@ -10,6 +10,11 @@ import ( // Shared helpers for the tests in this package. +// testBaseURL stands in for the model API codeaf serves a run. The tests that +// use it answer every request through their own fetcher, so nothing is ever +// sent to it; a client without one has nowhere to send a request at all. +const testBaseURL = "http://model-api.invalid/v1" + // runSSE drives the translator over a raw SSE body the way Stream.Next does: // decode a frame, drop `[DONE]`, parse, transform; flush at end of stream. func runSSE(raw string, seed uint32) (parts []StreamPart, thrown error) { diff --git a/internal/seniordev/engine/orclient/jsonval.go b/internal/seniordev/engine/orclient/jsonval.go index 9dbcd1b84..8599247d9 100644 --- a/internal/seniordev/engine/orclient/jsonval.go +++ b/internal/seniordev/engine/orclient/jsonval.go @@ -403,6 +403,25 @@ func (o *Object) Clone() *Object { return out } +// Without is a copy of the object with the named keys left out, in the order +// the rest were set. +func (o *Object) Without(keys ...string) *Object { + drop := make(map[string]bool, len(keys)) + for _, key := range keys { + drop[key] = true + } + out := NewObject() + if o == nil { + return out + } + for _, m := range o.members { + if !drop[m.Key] { + out.set(m.Key, m.Value) + } + } + return out +} + // MergeOptions deep-merges source into target: target's keys come first in // their own order, source-only keys are appended in source order, and a key // whose value is an object on both sides is merged recursively in place. diff --git a/internal/seniordev/engine/orclient/modelapi_test.go b/internal/seniordev/engine/orclient/modelapi_test.go new file mode 100644 index 000000000..784ce97cd --- /dev/null +++ b/internal/seniordev/engine/orclient/modelapi_test.go @@ -0,0 +1,59 @@ +//go:build !windows + +package orclient + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// Every request goes to the route codeaf spells for the base URL it hands the +// run, whatever that base carries at its end: the client appends nothing of +// its own. +func TestARequestGoesToTheModelAPIsOwnRoute(t *testing.T) { + for _, base := range []string{"http://127.0.0.1:4100/v1", "http://127.0.0.1:4100/v1/"} { + var sent string + client := &Client{BaseURL: base, Fetcher: func(req *http.Request) (*http.Response, error) { + sent = req.URL.String() + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil + }} + stream, err := client.DoStream(context.Background(), RequestParams{ModelID: "vendor/model"}) + if err != nil { + t.Fatal(err) + } + _ = stream.Close() + if want := modelapi.ChatURL(base); sent != want { + t.Fatalf("base %q sent the request to %q, want %q", base, sent, want) + } + } +} + +// A client with no model API has nowhere to go, and says so rather than +// reaching for a default service. The route lease the router took for the +// call is settled on the way out, so a refused call never leaks an in-flight +// count. +func TestAClientWithNoModelAPIRefusesAndSettlesItsLease(t *testing.T) { + router := &spyRouter{inflight: 1} + fetched := false + client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(*http.Request) (*http.Response, error) { + fetched = true + return nil, errors.New("unreachable") + }} + _, err := client.DoStream(context.Background(), RequestParams{ModelID: "vendor/model"}) + if !errors.Is(err, errNoModelAPI) { + t.Fatalf("err = %v, want the missing model API", err) + } + if fetched { + t.Fatal("a client with no model API sent a request anyway") + } + if router.inflight != 0 || len(router.calls) != 1 { + t.Fatalf("the lease was not settled: inflight=%d calls=%d", router.inflight, len(router.calls)) + } +} diff --git a/internal/seniordev/engine/orclient/parts.go b/internal/seniordev/engine/orclient/parts.go index 6614c43ab..810555f6d 100644 --- a/internal/seniordev/engine/orclient/parts.go +++ b/internal/seniordev/engine/orclient/parts.go @@ -300,7 +300,7 @@ func (p SourcePart) MarshalJSON() ([]byte, error) { return nil, err } outer := newObjectWriter() - outer.raw("openrouter", innerRaw) + outer.raw(Service, innerRaw) outerRaw, err := outer.done() if err != nil { return nil, err @@ -555,7 +555,7 @@ func (p FinishPart) MarshalJSON() ([]byte, error) { w.marshal("finishReason", p.FinishReason) w.marshal("usage", p.Usage) inner := newObjectWriter() - inner.marshal("openrouter", p.Metadata) + inner.marshal(Service, p.Metadata) innerRaw, err := inner.done() if err != nil { return nil, err diff --git a/internal/seniordev/engine/orclient/routing.go b/internal/seniordev/engine/orclient/routing.go deleted file mode 100644 index e59723a71..000000000 --- a/internal/seniordev/engine/orclient/routing.go +++ /dev/null @@ -1,331 +0,0 @@ -//go:build !windows - -package orclient - -import ( - "bytes" - "encoding/json" - "fmt" - "slices" - "strings" -) - -// ProviderRouting is OpenRouter's request-level `provider` object: the -// preferences that decide which upstream endpoint serves a model. Field names -// and enums follow https://openrouter.ai/docs/features/provider-routing -// exactly so a config author can paste from the OpenRouter docs. -// -// Every field is optional. A nil pointer or empty slice means "not set" and -// is omitted from the wire, so an all-empty value sends no `provider` key at -// all — routing is off unless something is configured. Parsing is strict: an -// unknown key or an out-of-range enum is an error, never a silent no-op, so a -// misspelled rule cannot look configured while the call routes on defaults. -type ProviderRouting struct { - // Order lists provider slugs to try in sequence. - Order []string `json:"order,omitempty"` - // AllowFallbacks lets OpenRouter fall back to other providers when the - // preferred ones are unavailable. OpenRouter's default is true. - AllowFallbacks *bool `json:"allow_fallbacks,omitempty"` - // RequireParameters excludes providers that do not support every - // parameter in the request (tools, temperature, top_k, ...). - RequireParameters *bool `json:"require_parameters,omitempty"` - // DataCollection is "allow" or "deny" for providers that may train on - // inputs. - DataCollection string `json:"data_collection,omitempty"` - // ZDR restricts routing to zero-data-retention endpoints. - ZDR *bool `json:"zdr,omitempty"` - // EnforceDistillableText restricts routing to endpoints whose model - // author permits distillation. - EnforceDistillableText *bool `json:"enforce_distillable_text,omitempty"` - // Only is an allowlist of provider slugs; Ignore is a blocklist. - Only []string `json:"only,omitempty"` - Ignore []string `json:"ignore,omitempty"` - // Quantizations filters endpoints by weight precision. - Quantizations []string `json:"quantizations,omitempty"` - // Sort orders the candidate endpoints by price, throughput or latency. - // Setting it disables OpenRouter's default load balancing. - Sort *RoutingSort `json:"sort,omitempty"` - // MaxPrice is a hard cap in $/million tokens (or $/request); endpoints - // above it are excluded. - MaxPrice *RoutingMaxPrice `json:"max_price,omitempty"` - // PreferredMinThroughput (tokens/s) and PreferredMaxLatency (seconds) - // are soft preferences: endpoints outside them are deprioritised, not - // excluded. - PreferredMinThroughput *RoutingThreshold `json:"preferred_min_throughput,omitempty"` - PreferredMaxLatency *RoutingThreshold `json:"preferred_max_latency,omitempty"` -} - -// RoutingSort is the `sort` field, which OpenRouter accepts either as a bare -// strategy string or as `{"by": ..., "partition": ...}`. It marshals back to -// whichever form the config used so the wire matches the docs example. -type RoutingSort struct { - By string `json:"by"` - Partition string `json:"partition,omitempty"` -} - -// RoutingMaxPrice is the `max_price` object. -type RoutingMaxPrice struct { - Prompt *float64 `json:"prompt,omitempty"` - Completion *float64 `json:"completion,omitempty"` - Image *float64 `json:"image,omitempty"` - Audio *float64 `json:"audio,omitempty"` - Request *float64 `json:"request,omitempty"` -} - -// RoutingThreshold is a performance preference, accepted either as a single -// number or as per-percentile values. -type RoutingThreshold struct { - Value *float64 `json:"-"` - P50 *float64 `json:"p50,omitempty"` - P75 *float64 `json:"p75,omitempty"` - P90 *float64 `json:"p90,omitempty"` - P99 *float64 `json:"p99,omitempty"` -} - -var ( - routingSortStrategies = []string{"price", "throughput", "latency"} - routingSortPartitions = []string{"model", "none"} - routingDataCollection = []string{"allow", "deny"} - routingQuantizations = []string{ - "int4", "int8", "fp4", "mxfp4", "nvfp4", "fp6", "fp8", "mxfp8", - "fp16", "bf16", "fp32", "unknown", - } -) - -// ParseProviderRouting decodes a config value strictly: unknown keys, wrong -// shapes and out-of-range enums are errors. Empty input and `null` mean -// "nothing configured" and return nil. -func ParseProviderRouting(raw []byte) (*ProviderRouting, error) { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { - return nil, nil - } - var routing ProviderRouting - if err := decodeStrict(trimmed, &routing); err != nil { - return nil, fmt.Errorf("provider routing: %w", err) - } - if err := routing.Validate(); err != nil { - return nil, fmt.Errorf("provider routing: %w", err) - } - return &routing, nil -} - -func decodeStrict(raw []byte, target any) error { - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - if decoder.More() { - return fmt.Errorf("trailing data after value") - } - return nil -} - -// Validate checks enums and value ranges without touching the wire shape. -func (r *ProviderRouting) Validate() error { - if r == nil { - return nil - } - if r.DataCollection != "" && !slices.Contains(routingDataCollection, r.DataCollection) { - return fmt.Errorf("data_collection must be one of %s, got %q", - strings.Join(routingDataCollection, "|"), r.DataCollection) - } - for _, q := range r.Quantizations { - if !slices.Contains(routingQuantizations, q) { - return fmt.Errorf("quantizations: unknown level %q (want one of %s)", - q, strings.Join(routingQuantizations, "|")) - } - } - for name, values := range map[string][]string{"order": r.Order, "only": r.Only, "ignore": r.Ignore} { - for _, slug := range values { - if strings.TrimSpace(slug) == "" { - return fmt.Errorf("%s: provider slugs must be non-empty strings", name) - } - } - } - if r.Sort != nil { - if !slices.Contains(routingSortStrategies, r.Sort.By) { - return fmt.Errorf("sort must be one of %s, got %q", - strings.Join(routingSortStrategies, "|"), r.Sort.By) - } - if r.Sort.Partition != "" && !slices.Contains(routingSortPartitions, r.Sort.Partition) { - return fmt.Errorf("sort.partition must be one of %s, got %q", - strings.Join(routingSortPartitions, "|"), r.Sort.Partition) - } - } - if r.MaxPrice != nil { - for name, value := range map[string]*float64{ - "prompt": r.MaxPrice.Prompt, "completion": r.MaxPrice.Completion, - "image": r.MaxPrice.Image, "audio": r.MaxPrice.Audio, "request": r.MaxPrice.Request, - } { - if value != nil && *value < 0 { - return fmt.Errorf("max_price.%s must not be negative", name) - } - } - } - for name, threshold := range map[string]*RoutingThreshold{ - "preferred_min_throughput": r.PreferredMinThroughput, - "preferred_max_latency": r.PreferredMaxLatency, - } { - if err := threshold.validate(name); err != nil { - return err - } - } - return nil -} - -func (t *RoutingThreshold) validate(name string) error { - if t == nil { - return nil - } - if t.Value == nil && t.P50 == nil && t.P75 == nil && t.P90 == nil && t.P99 == nil { - return fmt.Errorf("%s must be a number or an object with at least one of p50/p75/p90/p99", name) - } - for _, value := range []*float64{t.Value, t.P50, t.P75, t.P90, t.P99} { - if value != nil && *value < 0 { - return fmt.Errorf("%s must not be negative", name) - } - } - return nil -} - -// IsZero reports whether nothing is configured, in which case no `provider` -// key is sent. -func (r *ProviderRouting) IsZero() bool { - return r == nil || (len(r.Order) == 0 && r.AllowFallbacks == nil && r.RequireParameters == nil && - r.DataCollection == "" && r.ZDR == nil && r.EnforceDistillableText == nil && - len(r.Only) == 0 && len(r.Ignore) == 0 && len(r.Quantizations) == 0 && - r.Sort == nil && r.MaxPrice == nil && - r.PreferredMinThroughput == nil && r.PreferredMaxLatency == nil) -} - -// Merge returns a copy of r with every field that override sets replacing -// r's value. Lists replace wholesale rather than concatenating, so a -// narrower level (model, then agent) can drop a provider the broader level -// allowed. Nested objects (sort, max_price, the thresholds) also replace -// wholesale: they are single settings, not bags. -func (r *ProviderRouting) Merge(override *ProviderRouting) *ProviderRouting { - if r == nil && override == nil { - return nil - } - out := ProviderRouting{} - if r != nil { - out = *r - } - if override == nil { - return &out - } - if len(override.Order) > 0 { - out.Order = slices.Clone(override.Order) - } - if override.AllowFallbacks != nil { - out.AllowFallbacks = override.AllowFallbacks - } - if override.RequireParameters != nil { - out.RequireParameters = override.RequireParameters - } - if override.DataCollection != "" { - out.DataCollection = override.DataCollection - } - if override.ZDR != nil { - out.ZDR = override.ZDR - } - if override.EnforceDistillableText != nil { - out.EnforceDistillableText = override.EnforceDistillableText - } - if len(override.Only) > 0 { - out.Only = slices.Clone(override.Only) - } - if len(override.Ignore) > 0 { - out.Ignore = slices.Clone(override.Ignore) - } - if len(override.Quantizations) > 0 { - out.Quantizations = slices.Clone(override.Quantizations) - } - if override.Sort != nil { - out.Sort = override.Sort - } - if override.MaxPrice != nil { - out.MaxPrice = override.MaxPrice - } - if override.PreferredMinThroughput != nil { - out.PreferredMinThroughput = override.PreferredMinThroughput - } - if override.PreferredMaxLatency != nil { - out.PreferredMaxLatency = override.PreferredMaxLatency - } - return &out -} - -// Object renders the routing as the ordered `provider` value for the request -// body, or nil when nothing is configured. Key order is the struct's field -// order, which mirrors the OpenRouter docs. -func (r *ProviderRouting) Object() *Object { - if r.IsZero() { - return nil - } - raw, err := json.Marshal(r) - if err != nil { - return nil - } - object, err := ParseObject(raw) - if err != nil { - return nil - } - return object -} - -// MarshalJSON emits the bare string form when no partition was given. -func (s RoutingSort) MarshalJSON() ([]byte, error) { - if s.Partition == "" { - return json.Marshal(s.By) - } - type plain RoutingSort - return json.Marshal(plain(s)) -} - -// UnmarshalJSON accepts `"throughput"` or `{"by": "throughput", "partition": "none"}`. -func (s *RoutingSort) UnmarshalJSON(raw []byte) error { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) > 0 && trimmed[0] == '"' { - s.Partition = "" - return json.Unmarshal(trimmed, &s.By) - } - type plain RoutingSort - var parsed plain - if err := decodeStrict(trimmed, &parsed); err != nil { - return fmt.Errorf("sort: %w", err) - } - *s = RoutingSort(parsed) - return nil -} - -// MarshalJSON emits the bare number when the config gave one. -func (t RoutingThreshold) MarshalJSON() ([]byte, error) { - if t.Value != nil { - return json.Marshal(*t.Value) - } - type plain RoutingThreshold - return json.Marshal(plain(t)) -} - -// UnmarshalJSON accepts `50` or `{"p50": 100, "p90": 50}`. -func (t *RoutingThreshold) UnmarshalJSON(raw []byte) error { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) > 0 && trimmed[0] != '{' { - var value float64 - if err := json.Unmarshal(trimmed, &value); err != nil { - return fmt.Errorf("threshold must be a number or a percentile object: %w", err) - } - *t = RoutingThreshold{Value: &value} - return nil - } - type plain RoutingThreshold - var parsed plain - if err := decodeStrict(trimmed, &parsed); err != nil { - return fmt.Errorf("threshold: %w", err) - } - *t = RoutingThreshold(parsed) - return nil -} diff --git a/internal/seniordev/engine/orclient/routing_test.go b/internal/seniordev/engine/orclient/routing_test.go deleted file mode 100644 index e373880b0..000000000 --- a/internal/seniordev/engine/orclient/routing_test.go +++ /dev/null @@ -1,108 +0,0 @@ -//go:build !windows - -package orclient - -import ( - "strings" - "testing" -) - -func TestParseProviderRoutingAcceptsEveryDocumentedField(t *testing.T) { - routing, err := ParseProviderRouting([]byte(`{ - "order": ["anthropic", "amazon-bedrock"], - "allow_fallbacks": true, - "require_parameters": true, - "data_collection": "deny", - "zdr": false, - "enforce_distillable_text": false, - "only": ["anthropic"], - "ignore": ["gmicloud"], - "quantizations": ["fp8", "bf16"], - "sort": {"by": "throughput", "partition": "none"}, - "max_price": {"prompt": 1, "completion": 2}, - "preferred_min_throughput": {"p50": 100, "p90": 50}, - "preferred_max_latency": 3 - }`)) - if err != nil { - t.Fatal(err) - } - got, _ := routing.Object().MarshalJSON() - want := `{"order":["anthropic","amazon-bedrock"],"allow_fallbacks":true,"require_parameters":true,` + - `"data_collection":"deny","zdr":false,"enforce_distillable_text":false,"only":["anthropic"],` + - `"ignore":["gmicloud"],"quantizations":["fp8","bf16"],"sort":{"by":"throughput","partition":"none"},` + - `"max_price":{"prompt":1,"completion":2},"preferred_min_throughput":{"p50":100,"p90":50},` + - `"preferred_max_latency":3}` - if string(got) != want { - t.Fatalf("wire =\n%s\nwant\n%s", got, want) - } -} - -func TestParseProviderRoutingKeepsBareSortString(t *testing.T) { - routing, err := ParseProviderRouting([]byte(`{"sort": "throughput"}`)) - if err != nil { - t.Fatal(err) - } - got, _ := routing.Object().MarshalJSON() - if string(got) != `{"sort":"throughput"}` { - t.Fatalf("wire = %s", got) - } -} - -func TestParseProviderRoutingRejectsWhatOpenRouterWouldIgnore(t *testing.T) { - // A misspelled or out-of-range rule must fail at config time; a rule that - // parses and does nothing would route on defaults while looking set. - for name, raw := range map[string]string{ - "unknown key": `{"sort": "price", "prefered_max_latency": 3}`, - "bad sort": `{"sort": "fastest"}`, - "bad partition": `{"sort": {"by": "price", "partition": "provider"}}`, - "bad data_collection": `{"data_collection": "never"}`, - "bad quantization": `{"quantizations": ["fp8", "q4_k_m"]}`, - "empty slug": `{"only": [""]}`, - "negative price": `{"max_price": {"prompt": -1}}`, - "empty threshold": `{"preferred_max_latency": {}}`, - "unknown percentile": `{"preferred_max_latency": {"p95": 3}}`, - "threshold wrong type": `{"preferred_min_throughput": "fast"}`, - "not an object": `["sort"]`, - } { - if _, err := ParseProviderRouting([]byte(raw)); err == nil { - t.Errorf("%s: %s parsed without error", name, raw) - } - } -} - -func TestParseProviderRoutingEmptyMeansOff(t *testing.T) { - for _, raw := range []string{``, `null`, `{}`} { - routing, err := ParseProviderRouting([]byte(raw)) - if err != nil { - t.Fatalf("%q: %v", raw, err) - } - if !routing.IsZero() || routing.Object() != nil { - t.Fatalf("%q: routing=%+v object=%v, want nothing", raw, routing, routing.Object()) - } - } -} - -func TestProviderRoutingMergeLaterLevelWins(t *testing.T) { - base, _ := ParseProviderRouting([]byte(`{ - "sort": "throughput", "require_parameters": true, - "ignore": ["a", "b"], "max_price": {"prompt": 1, "completion": 2} - }`)) - override, _ := ParseProviderRouting([]byte(`{ - "sort": {"by": "price"}, "ignore": ["c"], "max_price": {"completion": 5}, "zdr": true - }`)) - got, _ := base.Merge(override).Object().MarshalJSON() - // Lists and nested objects replace wholesale; untouched scalars survive. - want := `{"require_parameters":true,"zdr":true,"ignore":["c"],"sort":"price","max_price":{"completion":5}}` - if string(got) != want { - t.Fatalf("merged = %s\nwant %s", got, want) - } - if unchanged, _ := base.Object().MarshalJSON(); !strings.Contains(string(unchanged), `"ignore":["a","b"]`) { - t.Fatalf("Merge mutated its receiver: %s", unchanged) - } - if base.Merge(nil).IsZero() || (*ProviderRouting)(nil).Merge(override).IsZero() { - t.Fatal("merging with nil lost the configured side") - } - if (*ProviderRouting)(nil).Merge(nil) != nil { - t.Fatal("nil merged with nil must stay nil") - } -} diff --git a/internal/seniordev/engine/orclient/service.go b/internal/seniordev/engine/orclient/service.go new file mode 100644 index 000000000..8aeb911b9 --- /dev/null +++ b/internal/seniordev/engine/orclient/service.go @@ -0,0 +1,17 @@ +//go:build !windows + +package orclient + +import "github.com/Agent-Field/codeaf/internal/modelsource" + +// Service is the identity of the model service whose wire this client speaks: +// OpenRouter's, which is the shape codeaf's model API answers in. It is the +// provider every model senior-dev asks for is filed under, the key a request's +// service options are kept under, and the namespace the service's reasoning +// details and finish metadata come back in. +// +// CODEAF SPELLS THAT IDENTITY ONCE, as modelsource.DefaultID, and a law holds +// the whole module to it (internal/modelsource/purity_law_test.go). Every use +// in senior-dev reads it from here, so the word is written in one place in +// codeaf and in none in this program. +const Service = modelsource.DefaultID diff --git a/internal/seniordev/engine/orclient/transform.go b/internal/seniordev/engine/orclient/transform.go index a8a8c239c..4e7ed5121 100644 --- a/internal/seniordev/engine/orclient/transform.go +++ b/internal/seniordev/engine/orclient/transform.go @@ -408,7 +408,7 @@ func Options(input OptionsInput) *Object { usage.SetBool("include", true) result.SetObject("usage", usage) } - if input.Model.ProviderID == "openrouter" { + if input.Model.ProviderID == Service { result.SetString("prompt_cache_key", input.SessionID) } return result @@ -425,11 +425,9 @@ func ProviderOptions(model Model, options *Object) *Object { return out } +// sdkKeyFor is the key a model's options are kept under. Every model this +// client serves speaks OpenRouter's wire, whatever package name it came with, +// so the answer is always the one service identity. func sdkKeyFor(npm string) string { - switch npm { - case "@openrouter/ai-sdk-provider": - return "openrouter" - } - // Every model this client serves is an OpenRouter model. - return "openrouter" + return Service } diff --git a/internal/seniordev/netpolicy/netpolicy.go b/internal/seniordev/netpolicy/netpolicy.go index e03a3c5b8..854d99831 100644 --- a/internal/seniordev/netpolicy/netpolicy.go +++ b/internal/seniordev/netpolicy/netpolicy.go @@ -3,10 +3,9 @@ // Package netpolicy makes senior-dev aware of runs where agent-initiated network // access is unavailable, so agents stop wasting cycles attempting it. It // governs the builtin web tools (webfetch, websearch) and the environment -// handed to bash children. The model plane (the LLM client) and the -// AgentField control-plane reporter are deliberately outside its scope: that -// traffic is senior-dev's own infrastructure, not agent-initiated, and a run -// cannot function without it. +// handed to bash children. The model plane (the model API codeaf serves the +// run) is deliberately outside its scope: that traffic is senior-dev's own +// road to a model, not agent-initiated, and a run cannot function without it. // // The policy is read from the environment, following the pipeline's existing // SENIOR_DEV_* precedent: @@ -16,13 +15,12 @@ // // A value of SENIOR_DEV_NET that parses to neither fails CLOSED to off: a typo in // a flag that exists to forbid network access must not silently grant it. The -// parse problem is preserved on the Policy so callers can surface it; the -// senior-dev binary refuses to start on it, so a run is never silently degraded -// by a typo either. +// parse problem is preserved on the Policy so callers can surface it; a run +// refuses to start on it, so a run is never silently degraded by a typo either. // // Containment is not this package's job - that belongs to the environment the -// run executes in (for example a sandbox that only lets the LLM backend and -// control plane through). What this package delivers under off is legibility +// run executes in (for example a sandbox that only lets the model API +// through). What this package delivers under off is legibility // and economy: // the web tools disappear from the model's tool list, in-process HTTP fails // instantly with an explicit no-retry policy error instead of a sandbox diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go new file mode 100644 index 000000000..650d2e153 --- /dev/null +++ b/internal/seniordev/seniordev.go @@ -0,0 +1,95 @@ +//go:build !windows + +// Package seniordev is senior-dev: an autonomous coding agent codeaf carries +// and runs, and nothing else can. It takes one brief, works in a working copy +// under a model it reaches only through codeaf, submits a frozen candidate, +// checks it with the project's own build and tests, and ends with one record +// that keeps what its model claimed apart from what it saw +// (internal/seniordev/app; its own account of the run is ARCHITECTURE.md in +// the repository it came from, swe-pro-go at the tag codeaf-absorb). +// +// IT HAS NO ENTRY POINT OF ITS OWN. What codeaf needs of it is a +// delegate.Delegate value, and its one command's body takes a delegate.Host, +// which only codeaf makes: `/senior-dev ` in the chat, and +// `codeaf senior-dev ` at a shell. There is no binary, no key it reads +// and no stdout it writes to but the host's records. +// +// ON WINDOWS IT IS ABSENT. Its engine leans on process groups, file locks and +// a bash shell it has never had a Windows form of, so every file under this +// tree carries a !windows constraint and the build's list carries nothing +// there (internal/delegate/builtin/carried_windows.go). +package seniordev + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "runtime/debug" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// Program is senior-dev as codeaf carries it. +var Program = delegate.Delegate{ + Name: "senior-dev", + Summary: "an autonomous agent for one large, well-specified code change", + Lands: delegate.LandsTree, + Default: "run", + Page: "senior-dev", + Commands: []delegate.Command{runCommand}, +} + +// runCommand is senior-dev's one verb: the whole run, from the brief to the +// terminal record. codeaf owns --dir, --max-cost, --max-hours and --json; the +// flags here are senior-dev's own. +var runCommand = delegate.Command{ + Name: "run", + Usage: "[flags] -- ", + Summary: "does one change start to finish: works, submits, checks its work", + Bind: bindRun, +} + +// bindRun declares the run's own flags and answers the body that reads them. +func bindRun(fs *flag.FlagSet) delegate.Body { + variant := fs.String("variant", "", "reasoning effort per call: low, medium, high or xhigh") + inPlace := fs.Bool("in-place", false, "work without git: no commits; checkpoints kept outside") + high := fs.String("high", app.DefaultHighModels, "models the coder routes among, comma-separated") + low := fs.String("low", "", "models for the history summary (default: --high)") + frontier := fs.String("frontier", "", "models for the frontier tier (default: --high)") + return func(ctx context.Context, host delegate.Host, args []string) error { + run(ctx, host, app.Options{ + Goal: strings.Join(args, " "), + High: *high, + Low: *low, + Frontier: *frontier, + Variant: *variant, + InPlace: *inPlace, + }, os.Stderr) + return nil + } +} + +// run is the body: hello first, the run, and exactly one terminal. +// +// A PANIC IS AN ENDING TOO. The host reads a missing terminal as work that did +// not finish and can say nothing more; a panic in the run's own goroutine is +// caught here and written as the crash it is, with its stack on stderr for +// whoever opens the task. (A panic on another of the run's goroutines ends the +// process, and the missing terminal says so.) +func run(ctx context.Context, host delegate.Host, options app.Options, notes io.Writer) { + host.Hello(app.Stages) + defer func() { + if recovered := recover(); recovered != nil { + _, _ = fmt.Fprintf(notes, "[senior-dev] panic: %v\n%s", recovered, debug.Stack()) + host.Terminal(delegate.Ending{ + Status: delegate.StatusCrashed, + Message: fmt.Sprintf("senior-dev broke: %v", recovered), + }) + } + }() + host.Terminal(app.Run(ctx, host, options, notes)) +} diff --git a/internal/seniordev/seniordev_test.go b/internal/seniordev/seniordev_test.go new file mode 100644 index 000000000..3b2eae1fa --- /dev/null +++ b/internal/seniordev/seniordev_test.go @@ -0,0 +1,436 @@ +//go:build !windows + +package seniordev + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// hostRecord is one record a run wrote, in the order it wrote them. +type hostRecord struct { + kind string + stages []string + stage string + step string + ending delegate.Ending +} + +// recordingHost is codeaf as a run meets it: a workspace, ceilings, a model +// API, and the four records, kept in order. +type recordingHost struct { + mu sync.Mutex + workspace string + ceilings delegate.Ceilings + api delegate.ModelAPI + records []hostRecord +} + +func (h *recordingHost) Workspace() string { return h.workspace } +func (h *recordingHost) Ceilings() delegate.Ceilings { return h.ceilings } +func (h *recordingHost) Models() delegate.ModelAPI { return h.api } +func (h *recordingHost) Hello(stages []string) { h.add(hostRecord{kind: "hello", stages: stages}) } +func (h *recordingHost) Stage(stage, status string) { + h.add(hostRecord{kind: "stage", stage: stage + "/" + status}) +} +func (h *recordingHost) Step(command, _ string) { h.add(hostRecord{kind: "step", step: command}) } +func (h *recordingHost) Terminal(end delegate.Ending) { + h.add(hostRecord{kind: "terminal", ending: end}) +} + +func (h *recordingHost) add(r hostRecord) { + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, r) +} + +func (h *recordingHost) snapshot() []hostRecord { + h.mu.Lock() + defer h.mu.Unlock() + return append([]hostRecord(nil), h.records...) +} + +// A key and an address senior-dev must never read. Before codeaf carried it, +// senior-dev took both from these two variables; a run that still did would +// send the one to the other. +const ( + keyNobodyMayRead = "sk-or-v1-a-key-senior-dev-must-never-read" + runToken = "codeaf-run-token-for-this-run-only" +) + +// modelAPIServer answers like codeaf's model API: OpenRouter's streamed +// chat-completions shape, a keepalive comment before the first chunk, and +// usage.cost in the last. It plays one scripted conversation — write the +// feature, write the checklist, submit, and stop — and keeps every request it +// was sent. +type modelAPIServer struct { + mu sync.Mutex + requests []seenRequest + // hold, when set, blocks each request until the caller gives up, and says + // so on the channel first. + hold chan struct{} +} + +type seenRequest struct { + path string + authorization string + affinity string + referer string + title string + body map[string]any + raw string +} + +func (s *modelAPIServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + s.mu.Lock() + s.requests = append(s.requests, seenRequest{ + path: r.URL.Path, + authorization: r.Header.Get("Authorization"), + affinity: r.Header.Get("x-session-affinity"), + referer: r.Header.Get("HTTP-Referer"), + title: r.Header.Get("X-Title"), + body: body, + raw: string(raw) + fmt.Sprint(r.Header), + }) + call := len(s.requests) + hold := s.hold + s.mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + // The model API keeps a waiting stream alive with SSE comments. + _, _ = io.WriteString(w, ": keepalive\n\n") + if flusher != nil { + flusher.Flush() + } + if hold != nil { + select { + case hold <- struct{}{}: + default: + } + <-r.Context().Done() + return + } + _, _ = io.WriteString(w, scriptedReply(call)) +} + +func (s *modelAPIServer) seen() []seenRequest { + s.mu.Lock() + defer s.mu.Unlock() + return append([]seenRequest(nil), s.requests...) +} + +// scriptedReply is the model's side of the conversation, one reply per call. +func scriptedReply(call int) string { + switch call { + case 1: + return toolCall(call, "write", map[string]any{"filePath": "feature.txt", "content": "implemented\n"}) + case 2: + return toolCall(call, "write", map[string]any{"filePath": ".senior-dev/checklist.md", "content": "- [x] the feature is implemented\n"}) + case 3: + return toolCall(call, "submit", map[string]any{ + "reason": "feature.txt now holds the feature", "evidence": "make test exits 0", + "checklist_satisfied": true, + }) + default: + return `data: {"id":"gen-text","choices":[{"delta":{"content":"Done."}}]}` + "\n\n" + + `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"cost":0.001,"prompt_tokens":40,"completion_tokens":3,"total_tokens":43}}` + "\n\n" + + "data: [DONE]\n\n" + } +} + +func toolCall(call int, name string, arguments map[string]any) string { + encodedArguments, _ := json.Marshal(arguments) + chunk := map[string]any{ + "id": fmt.Sprintf("gen-%d", call), + "choices": []any{map[string]any{ + "delta": map[string]any{"tool_calls": []any{map[string]any{ + "index": 0, "id": fmt.Sprintf("call-%d", call), "type": "function", + "function": map[string]any{"name": name, "arguments": string(encodedArguments)}, + }}}, + "finish_reason": "tool_calls", + }}, + "usage": map[string]any{"cost": 0.002, "prompt_tokens": 30, "completion_tokens": 10, "total_tokens": 40}, + } + encoded, _ := json.Marshal(chunk) + return "data: " + string(encoded) + "\n\ndata: [DONE]\n\n" +} + +// hermeticRun is the environment a run meets in these tests: nothing of the +// machine's own configuration, a catalog on disk, no fetch, and the two old +// provider variables set to values senior-dev must not read. It answers the +// workspace, a git repository with a build and a test that pass. +func hermeticRun(t *testing.T) (workspace string, trap *atomic.Bool) { + 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("modelsdev", "testdata", "catalog.json")) + if err != nil { + t.Fatal(err) + } + t.Setenv("SENIOR_DEV_MODELS_PATH", catalog) + hit := &atomic.Bool{} + trapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hit.Store(true) + w.WriteHeader(http.StatusTeapot) + })) + t.Cleanup(trapServer.Close) + t.Setenv("OPENROUTER_API_KEY", keyNobodyMayRead) + t.Setenv("OPENROUTER_BASE_URL", trapServer.URL) + + workspace = t.TempDir() + files := map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + } + for name, content := range files { + 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, hit +} + +// runBody runs the run command's body the way codeaf's command line does: +// bound on a fresh flag set, handed the line after the flags as the brief. +func runBody(t *testing.T, ctx context.Context, host delegate.Host, line ...string) error { + t.Helper() + command, ok := Program.Command(Program.Default) + if !ok { + t.Fatalf("senior-dev has no %q command", Program.Default) + } + fs := flag.NewFlagSet("senior-dev run", flag.ContinueOnError) + body := command.Bind(fs) + if err := fs.Parse(line); err != nil { + t.Fatal(err) + } + return body(ctx, host, fs.Args()) +} + +// THE WHOLE ROAD, AGAINST A MODEL API THAT ANSWERS LIKE CODEAF'S. The run is +// given a host whose model API is a local server, works a scripted task in a +// real repository, and is held to the protocol: hello first with its stages, +// a step per finished tool call, exactly one terminal and nothing after it; +// every model call on the route codeaf spells, carrying the run's token; and +// no read of the key or the address senior-dev used to take from its +// environment. +func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { + workspace, trapHit := hermeticRun(t) + server := &modelAPIServer{} + api := httptest.NewServer(server) + t.Cleanup(api.Close) + host := &recordingHost{ + workspace: workspace, + api: delegate.ModelAPI{BaseURL: api.URL + "/v1", Token: runToken}, + } + + err := runBody(t, context.Background(), host, + "--high", "openrouter/fixture/vendor-model", "--", "Add", "the", "feature.") + if err != nil { + t.Fatalf("the body answered an error: %v", err) + } + + records := host.snapshot() + if len(records) == 0 || records[0].kind != "hello" { + t.Fatalf("the first record is not hello: %+v", records) + } + if !slices.Equal(records[0].stages, app.Stages) || len(records[0].stages) != 13 { + t.Fatalf("hello names %v, want the run's thirteen stages %v", records[0].stages, app.Stages) + } + var steps, terminals int + for at, record := range records { + switch record.kind { + case "hello": + if at != 0 { + t.Fatalf("a second hello at record %d", at) + } + case "step": + steps++ + case "terminal": + terminals++ + if at != len(records)-1 { + t.Fatalf("records follow the terminal: %+v", records[at:]) + } + } + } + if steps < 1 { + t.Fatalf("no step records: %+v", records) + } + if terminals != 1 { + t.Fatalf("%d terminal records, want exactly one", terminals) + } + stages := map[string]bool{} + for _, record := range records { + if record.kind == "stage" { + stages[strings.SplitN(record.stage, "/", 2)[0]] = true + if !slices.Contains(app.Stages, strings.SplitN(record.stage, "/", 2)[0]) { + t.Fatalf("stage %q is not one the hello named", record.stage) + } + } + } + for _, want := range []string{"bootstrap", "intake", "implement", "submit", "verification", "ship"} { + if !stages[want] { + t.Errorf("no %s stage was reported: %v", want, stages) + } + } + + ending := records[len(records)-1].ending + if ending.Status != delegate.StatusPass { + t.Fatalf("ending = %+v, want pass", ending) + } + if ending.Claim != "feature.txt now holds the feature" { + t.Errorf("claim = %q, want the model's submission reason", ending.Claim) + } + if !strings.Contains(ending.Observed, "passed") { + t.Errorf("observed = %q, want what senior-dev saw its build and tests do", ending.Observed) + } + if ending.CostUSD <= 0 { + t.Errorf("cost = %v, want the calls' own usage.cost summed", ending.CostUSD) + } + for _, banned := range []string{"verified", "verdict", "auditor", "refuted"} { + for _, said := range []string{ending.Message, ending.Claim, ending.Observed, ending.Reason} { + if strings.Contains(said, banned) { + t.Errorf("the ending says %q, a word no person reads from codeaf: %q", banned, said) + } + } + } + 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) + } + + requests := server.seen() + if len(requests) < 4 { + t.Fatalf("%d model requests, want the scripted four", len(requests)) + } + route, err := url.Parse(modelapi.ChatURL(host.api.BaseURL)) + if err != nil { + t.Fatal(err) + } + for at, request := range requests { + if request.path != route.Path { + t.Errorf("request %d went to %q, want the model API's route %q", at, request.path, route.Path) + } + if request.authorization != "Bearer "+runToken { + t.Errorf("request %d carried Authorization %q, want the run's token", at, request.authorization) + } + if strings.Contains(request.raw, keyNobodyMayRead) { + t.Errorf("request %d carried the provider key from the environment", at) + } + if request.affinity == "" { + t.Errorf("request %d lost its x-session-affinity header", at) + } + if request.referer != "" || request.title != "" { + t.Errorf("request %d carried attribution headers: %q %q", at, request.referer, request.title) + } + if request.body["prompt_cache_key"] == nil { + t.Errorf("request %d lost its prompt_cache_key", at) + } + if usage, _ := request.body["usage"].(map[string]any); usage["include"] != true { + t.Errorf("request %d did not ask for usage: %v", at, request.body["usage"]) + } + if _, routed := request.body["provider"]; routed { + t.Errorf("request %d carried a provider-routing block", at) + } + if len(request.body["tools"].([]any)) == 0 { + t.Errorf("request %d carried no tools", at) + } + } + if trapHit.Load() { + t.Fatal("a request went to OPENROUTER_BASE_URL") + } +} + +// SIGTERM IS codeaf's STOP. The run's context ends while a model call is in +// flight; the run starts nothing new, ships what it has and writes its one +// terminal quickly, saying the work did not finish — not that it crashed. +func TestAStoppedRunEndsWithItsOneTerminalAndTheTruth(t *testing.T) { + workspace, _ := hermeticRun(t) + server := &modelAPIServer{hold: make(chan struct{}, 1)} + api := httptest.NewServer(server) + t.Cleanup(api.Close) + host := &recordingHost{ + workspace: workspace, + api: delegate.ModelAPI{BaseURL: api.URL + "/v1", Token: runToken}, + } + ctx, stop := context.WithCancel(context.Background()) + defer stop() + done := make(chan error, 1) + go func() { + done <- runBody(t, ctx, host, "--high", "openrouter/fixture/vendor-model", "--", "Add the feature.") + }() + select { + case <-server.hold: + case err := <-done: + t.Fatalf("the run ended before its first model call: %v, %+v", err, host.snapshot()) + case <-time.After(30 * time.Second): + t.Fatal("the run never made a model call") + } + stopped := time.Now() + stop() + select { + case <-done: + case <-time.After(delegate.DefaultGrace): + t.Fatalf("the run outlived the grace a stop gives it") + } + if took := time.Since(stopped); took > 10*time.Second { + t.Errorf("the run took %s to end after the stop", took) + } + records := host.snapshot() + var terminals []delegate.Ending + for _, record := range records { + if record.kind == "terminal" { + terminals = append(terminals, record.ending) + } + } + if len(terminals) != 1 || records[len(records)-1].kind != "terminal" { + t.Fatalf("terminals = %+v, want exactly one, last", terminals) + } + if terminals[0].Status != delegate.StatusFail || !strings.HasPrefix(terminals[0].Message, "stopped before it finished") { + t.Fatalf("ending = %+v, want the stop said as unfinished work", terminals[0]) + } + if n := len(server.seen()); n != 1 { + t.Fatalf("%d model requests, want none after the stop", n) + } +} diff --git a/internal/seniordev/session/llmcall/cancellation_test.go b/internal/seniordev/session/llmcall/cancellation_test.go index db30d8540..52f872eaf 100644 --- a/internal/seniordev/session/llmcall/cancellation_test.go +++ b/internal/seniordev/session/llmcall/cancellation_test.go @@ -29,7 +29,7 @@ func TestWorkDeadlineLeavesLandingRouteImmediatelyUsable(t *testing.T) { workCause := errors.New("work budget exhausted") fetches := 0 service := &Service{Router: router, Clients: ClientFactoryFunc(func(_ context.Context, _ Model, choice *adaptive.RouteChoice, r *adaptive.AdaptiveModelRouter) (StreamClient, error) { - return concreteClient{client: &orclient.Client{Router: r, RouteChoice: choice, + return concreteClient{client: &orclient.Client{BaseURL: "http://model-api.invalid/v1", Router: r, RouteChoice: choice, Fetcher: func(request *http.Request) (*http.Response, error) { fetches++ if fetches == 1 { From 0bae9077fe0dd6b324a1c26ca4060534a5c670c2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:19:30 -0400 Subject: [PATCH 027/195] seniordev: codeaf carries senior-dev, and the chat's manual knows it The build's list of carried programs was empty on every platform, so there was no /senior-dev row and `codeaf senior-dev` was an unknown command. Now the unix list carries internal/seniordev.Program: `/senior-dev ` in the chat and `codeaf senior-dev ` at a shell, `run` its one command, landing a tree. Windows still carries nothing. In the same change, the chat's manual gains internal/manual/chat/senior-dev.md, written from what the code does: what it does and when to use it, how to write the brief, what it cannot do (it cannot ask you anything, has no step cap, reaches a model only through codeaf, needs git unless --in-place, is absent on Windows), where its work lands (one squashed commit, the claim and what it observed kept apart, .senior-dev/ never lands), what a run costs (every call through codeaf, the dollar ceiling refuses the call that would cross it, its landing reserve), its flags, and how a run ends. Six questions in a person's words hold it reachable; two older questions that the first draft crowded out ("what flags does codeaf do take", "is my task stuck while it waits for its test suite") were fixed in the page, not the test. Co-Authored-By: Claude Opus 5.5 --- internal/delegate/builtin/carried_unix.go | 11 +- internal/manual/chat/senior-dev.md | 133 ++++++++++++++++++++++ internal/manual/chat_test.go | 9 ++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 internal/manual/chat/senior-dev.md diff --git a/internal/delegate/builtin/carried_unix.go b/internal/delegate/builtin/carried_unix.go index 4c01bca3b..52e54d097 100644 --- a/internal/delegate/builtin/carried_unix.go +++ b/internal/delegate/builtin/carried_unix.go @@ -2,8 +2,11 @@ package builtin -import "github.com/Agent-Field/codeaf/internal/delegate" +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 joins it -// when its engine lands in internal/seniordev. -var carried = []delegate.Delegate{} +// 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/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md new file mode 100644 index 000000000..eabbe5377 --- /dev/null +++ b/internal/manual/chat/senior-dev.md @@ -0,0 +1,133 @@ +# 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 a copy of your folder. 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. + +Use it for one change big enough to want an agent of its own for an hour, and specified +well enough that nobody will be asked anything: a rewrite across a package, a migration, +a feature with its tests. A change you would make in a few steps is not worth it. + +## 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 its copy 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. + +## What senior-dev cannot do — it cannot ask you anything, 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 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. + +**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 needs a git repository with at least one commit**, unless it runs `--in-place`, which +edits a plain folder without committing anything. + +**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. + +## Where senior-dev's work lands — one squashed commit on your branch + +senior-dev commits every file it writes inside its copy (`wip(write): `, +`wip(edit): `), which is how it keeps a record to restore from. None of those +commits reaches your branch. When the run ends, they are squashed into **one commit** +whose subject is `task:` and the task's title, and whose body is senior-dev's own ending; +that commit comes home the way every task's work does. + +The ending keeps two witnesses apart: what senior-dev's model said it did when it +submitted (`senior-dev's model said: …`) and what senior-dev itself saw when it ran the +project's build and tests (`senior-dev observed: …`). Read the second for "did it work". + +Its own notes live in `.senior-dev/` in the copy: the brief, its checklist, the command +it pinned and its session database. That folder is kept out of git, so it never lands. + +When a run changed nothing, there is nothing to land and the task says so. + +## 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 and in `/cost`, and is held to the run's dollar ceiling: **codeaf refuses the call +that would cross it**, before it is made. A refused call ends senior-dev's turn; it runs +the project's build and tests on the tree it has, and ends there. + +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. + +senior-dev picks its model call by call from its own list of open models, and avoids one +for a while after it fails. `--high` replaces the list, and `--variant` sets the +reasoning effort every call asks for. + +## 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); +- `--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 in a folder without git: no commits, and its checkpoints kept + outside the folder; +- `--high`, `--low`, `--frontier` — comma-separated models it routes among; `--low` + (its history summaries) and `--frontier` fall back to `--high`. + +`codeaf senior-dev help` describes it and its one command, `run`; +`codeaf senior-dev run --help` prints all of them, codeaf's four included. + +## 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; +- `senior-dev stopped on its own ceiling: …` — it crossed the dollar or time ceiling; +- `senior-dev crashed: …` — the program itself broke, or could not start (no brief, a + refused `senior-dev.json`, no git repository); +- `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`. + +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. + +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. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index a9f8be409..6c0c50cbe 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -916,6 +916,15 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"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 From 6a8daf484d2fc052ecbf8e6f064a3b36082d7d63 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:26:19 -0400 Subject: [PATCH 028/195] modelapi: every run of a carried program gets a model API of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: internal/provider/modelapi held only the route (ChatURL). A program codeaf carries had an address and a token in its environment and nothing answering at them, so no call it made could reach a model. Now: modelapi.Open serves one run's OpenAI-style chat-completions API on 127.0.0.1, opened by one minted token and closed with the run, so the token dies with it. Every call: - is refused 401 without the token, and 402 in OpenRouter's error shape before it is made once the run's metered spend has reached its ceiling, written down as a refused turn and counted (RefusedAtCeiling); - is decoded whole into the funnel's types — messages of every role with assistant tool calls and tool results, tools and the program's own tool_choice, max_tokens/max_completion_tokens, temperature, response_format, the reasoning depth (as a configured effort), the prompt_cache_key (or the x-session-affinity header) as the cache key, and working handed back on assistant messages — while the program's provider-routing fields are dropped for codeaf's router to decide; - goes out through the completer the run hands it, armed with provider.WithBilling and WithReconcile so every billed answer and every late receipt is metered once, as it happens, to Config.Bank; - is answered in OpenRouter's own shape: one chat.completion, or an event stream ending in a usage chunk carrying cost and cached tokens and then [DONE], with `: keepalive` comments every 15 s while the funnel thinks (whitespace on a whole body), and model failures as the router's error envelope with a status that means the same thing; - is written to the task's conversation log twice under one number — at its start and at its end — with only what the thread had not sent before (Restarted when the program rewrote its history), per thread. Which model answers is one rule, Resolve: the model the program asked for when one of this person's services can take a call on it, the run's work seat when none can (Served names it), and never a refusal only because this machine does not know the id; a call the funnel itself cannot serve (no key, a withdrawn model) goes out once more on the seat. Two small seams in internal/session carry it: ServesModel exposes the account pool's own "can a service answer this model" test (and RunSpec.Serves hands it to a run, read live), so the API and the pool cannot disagree; and WithOwnCacheLineage lets a marked call keep the program's own prompt_cache_key instead of the conversation's stamp. Co-Authored-By: Claude Opus 5.5 --- internal/provider/modelapi/resolve.go | 98 +++ internal/provider/modelapi/resolve_test.go | 76 ++ internal/provider/modelapi/server.go | 863 +++++++++++++++++++++ internal/provider/modelapi/server_test.go | 673 ++++++++++++++++ internal/provider/modelapi/threads.go | 137 ++++ internal/provider/modelapi/wire.go | 515 ++++++++++++ internal/provider/modelapi/wire_test.go | 265 +++++++ internal/provider/modelapi/working.go | 118 +++ internal/session/agent.go | 32 +- internal/session/clientdoor.go | 27 + internal/session/modelapi_seams_test.go | 85 ++ internal/session/task_run_belt.go | 6 + 12 files changed, 2894 insertions(+), 1 deletion(-) create mode 100644 internal/provider/modelapi/resolve.go create mode 100644 internal/provider/modelapi/resolve_test.go create mode 100644 internal/provider/modelapi/server.go create mode 100644 internal/provider/modelapi/server_test.go create mode 100644 internal/provider/modelapi/threads.go create mode 100644 internal/provider/modelapi/wire.go create mode 100644 internal/provider/modelapi/wire_test.go create mode 100644 internal/provider/modelapi/working.go create mode 100644 internal/session/modelapi_seams_test.go diff --git a/internal/provider/modelapi/resolve.go b/internal/provider/modelapi/resolve.go new file mode 100644 index 000000000..d1e0194b1 --- /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 000000000..12d9ce39a --- /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/server.go b/internal/provider/modelapi/server.go new file mode 100644 index 000000000..4e3270663 --- /dev/null +++ b/internal/provider/modelapi/server.go @@ -0,0 +1,863 @@ +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 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/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) + // 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 +} + +// 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. +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 errors.Is(err, http.ErrServerClosed) { + err = nil + } + return err +} + +// 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, 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) + return entry, s.spent +} + +// 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 && spent >= ceiling { + // 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.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) + } + })) + + 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, and the four +// sinks that meter it, catch its working and name its server. +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) + if request.hasEffort { + // The program asked for this depth in so many words, which is what an + // operator'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. + ctx = provider.WithConfiguredReasoningEffort(ctx, request.effort) + } + 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.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() + s.log(entry.turn) +} + +// 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 + } +} + +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" +} + +// 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. +func dollars(amount float64) string { + if amount > 0 && 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 000000000..dd58bebf1 --- /dev/null +++ b/internal/provider/modelapi/server_test.go @@ -0,0 +1,673 @@ +package modelapi_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "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 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) { + sink := provider.ReconcileSinkFrom(ctx) + go func() { + 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) + } +} diff --git a/internal/provider/modelapi/threads.go b/internal/provider/modelapi/threads.go new file mode 100644 index 000000000..7a0aeb464 --- /dev/null +++ b/internal/provider/modelapi/threads.go @@ -0,0 +1,137 @@ +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" +) + +// threads is every thread's previous request, as fingerprints. It is guarded by +// the server's lock. +type threads struct { + previous map[string][]string +} + +// 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 000000000..96af6750c --- /dev/null +++ b/internal/provider/modelapi/wire.go @@ -0,0 +1,515 @@ +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/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 + effort provider.Effort + hasEffort bool +} + +// 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.effort, decoded.hasEffort = request.effort() + return decoded, nil +} + +// working is the reasoning a program handed back, under the field it arrived +// on — the provider's replay law is that working travels back unmodified under +// the name it came in with (provider.ReasoningReplayPolicy). +func (e messageExtras) working() provider.MessageReasoning { + working := provider.MessageReasoning{} + switch { + case e.Reasoning != "": + working.Field, working.Text = "reasoning", e.Reasoning + case e.ReasoningContent != "": + working.Field, working.Text = "reasoning_content", e.ReasoningContent + case e.ReasoningText != "": + working.Field, working.Text = "reasoning_text", e.ReasoningText + } + 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 + } +} + +// effort is the reasoning depth the program asked for, in codeaf's words: +// OpenRouter's `reasoning` object or OpenAI's `reasoning_effort`. A word +// codeaf's adapter does not have is not sent, because a knob a model would +// refuse must never reach the wire; `enabled: false` and `none` are the one +// request to switch the pass off. +func (r chatRequest) effort() (provider.Effort, bool) { + word := strings.TrimSpace(r.ReasoningEffort) + if r.Reasoning != nil { + if r.Reasoning.Enabled != nil && !*r.Reasoning.Enabled { + return provider.EffortOff, true + } + if said := strings.TrimSpace(r.Reasoning.Effort); said != "" { + word = said + } + } + switch strings.ToLower(word) { + case "none", "off": + return provider.EffortOff, true + case "minimal": + return provider.EffortMinimal, true + case "low": + return provider.EffortLow, true + case "medium": + return provider.EffortMedium, true + case "high": + return provider.EffortHigh, true + } + return provider.EffortNone, false +} + +// ── 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 WORKING TRAVELS UNDER +// THE FIELD IT ARRIVED ON, so a program that hands it back on its next call is +// handing back exactly what the endpoint wrote (provider.ReasoningReplayPolicy): +// OpenRouter's `reasoning`, or a direct endpoint's `reasoning_content`. +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 000000000..5d6b23414 --- /dev/null +++ b/internal/provider/modelapi/wire_test.go @@ -0,0 +1,265 @@ +package modelapi + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "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) + } + if len(decoded.reasoning) != 4 || decoded.reasoning[2].Field != "reasoning" || 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.hasEffort || decoded.effort != provider.EffortHigh { + t.Fatalf("effort = %q %v", decoded.effort, decoded.hasEffort) + } + 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) + } +} + +func TestTheWireReadsEveryReasoningSpelling(t *testing.T) { + for _, row := range []struct { + body string + effort provider.Effort + set bool + }{ + {`"reasoning_effort": "low"`, provider.EffortLow, true}, + {`"reasoning": {"effort": "medium"}`, provider.EffortMedium, true}, + {`"reasoning": {"effort": "minimal"}`, provider.EffortMinimal, true}, + {`"reasoning": {"enabled": false}`, provider.EffortOff, true}, + {`"reasoning_effort": "none"`, provider.EffortOff, true}, + {`"reasoning": {"enabled": true}`, provider.EffortNone, false}, + // A word codeaf's adapter does not have is never sent. + {`"reasoning_effort": "xhigh"`, provider.EffortNone, false}, + } { + decoded, err := decodeRequest([]byte(`{"model":"m","messages":[{"role":"user","content":"hi"}],` + row.body + `}`)) + if err != nil { + t.Fatal(err) + } + if decoded.effort != row.effort || decoded.hasEffort != row.set { + t.Fatalf("%s: effort %q %v, want %q %v", row.body, decoded.effort, decoded.hasEffort, row.effort, row.set) + } + } +} + +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)) + } + if chunks[0].Choices[0].Delta["reasoning_content"] != "thinking" || chunks[0].Choices[0].Delta["reasoning_details"] == nil { + t.Fatalf("working chunk = %+v, want the working under the field it arrived on", chunks[0].Choices[0].Delta) + } + 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) + } +} + +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 000000000..a1b3f6a0e --- /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 field it came +// in on, `reasoning` when the funnel did not say. +func (c captured) onto(target map[string]any) { + if c.text != "" { + field := c.field + if field == "" { + field = "reasoning" + } + target[field] = 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/session/agent.go b/internal/session/agent.go index a66a8d56b..d9d03d08c 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2101,7 +2101,9 @@ type sessionCompleter struct { } func (f sessionCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { - ctx = provider.WithCacheKey(ctx, f.cacheKey) + if !bringsOwnLineage(ctx) { + ctx = provider.WithCacheKey(ctx, f.cacheKey) + } if f.patient { ctx = provider.WithPatientRateLimits(ctx) } @@ -2114,6 +2116,34 @@ func (f sessionCompleter) CompleteWithMessages(ctx context.Context, messages []a return f.inner.CompleteWithMessages(ctx, messages, options...) } +// ownLineageKey marks a call that brings its own prompt-cache lineage +// ([WithOwnCacheLineage]). +type ownLineageKey struct{} + +// WithOwnCacheLineage marks a call whose context already carries the cache key +// its request must travel under, so the conversation's wrapper keeps that key +// rather than stamping its own. +// +// IT EXISTS FOR ONE CALLER AND IT IS OPT-IN. A program codeaf carries talks to +// its model through the run's model API (internal/provider/modelapi), which +// hands each call to this conversation's completer — and the program keeps +// conversations of its own, each with its own `prompt_cache_key`. Stamped with +// the conversation's key, every one of them would ask for the conversation's +// warm instance: two different prefixes on one lineage, each cold-starting the +// other, which is [unwrapCompleter]'s reason for giving a task node a lineage +// of its own. A call that is not marked keeps exactly the stamp it always had. +func WithOwnCacheLineage(ctx context.Context) context.Context { + return context.WithValue(ctx, ownLineageKey{}, true) +} + +// bringsOwnLineage reports a call marked by [WithOwnCacheLineage] that really +// does carry a key: a marked call with none is stamped like any other, so the +// mark can never send a request out unkeyed. +func bringsOwnLineage(ctx context.Context) bool { + own, _ := ctx.Value(ownLineageKey{}).(bool) + return own && provider.CacheKeyFrom(ctx) != "" +} + // ProbeLanes passes the keystroke's pre-warm through, and does nothing at all // for an inner completer that cannot buy one ([laneProber]). // diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index e10d65897..b48e8b4e9 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -75,6 +75,33 @@ func modelServiceCanAnswer(service modelsource.Connected) bool { return strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional } +// ServesModel answers whether one of these services can take a call on model: +// the service the model's id resolves to ([modelsource.Set.For], which reads a +// service prefix such as `openrouter/` off the id) holds a key, or is one that +// needs none. It is the pool's own test ([modelServiceCanAnswer]) opened to the +// run's model API (internal/provider/modelapi), which decides the same question +// for a program's call and may not answer it a second way: a program that +// names a model this machine cannot reach is answered on the run's work seat +// instead, and the pool and the API must agree about what cannot be reached. +func ServesModel(sources modelsource.Set, model string) bool { + model = strings.TrimSpace(model) + if model == "" || sources.Empty() { + return false + } + service, _ := sources.For(model) + return service.Source.ID != "" && modelServiceCanAnswer(service) +} + +// servesModel is [ServesModel] over this conversation's own services, read live +// under the lock the surface moves them under ([Agent.SetSources], +// [Agent.SetAPIKey]), so a key pasted after the run began counts for its next +// call. +func (a *Agent) servesModel(model string) bool { + a.mu.Lock() + defer a.mu.Unlock() + return ServesModel(a.config.Sources.OrDefault(a.config.APIKey, a.config.BaseURL), model) +} + // setSeat moves the one live fallback beside the source snapshot. A model // chosen after launch must carry the next turn; construction-time config is a // receipt of how the conversation opened, not an answer about where it sits. diff --git a/internal/session/modelapi_seams_test.go b/internal/session/modelapi_seams_test.go new file mode 100644 index 000000000..f859a4bba --- /dev/null +++ b/internal/session/modelapi_seams_test.go @@ -0,0 +1,85 @@ +package session + +import ( + "context" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// The two seams a delegated run's model API reaches this package through +// (internal/provider/modelapi): the account pool's own "can a service answer +// this model" test, and a call that keeps the program's own cache lineage. + +// SERVESMODEL IS THE POOL'S OWN TEST, NOT A SECOND ONE: the service a model's +// id resolves to — a prefix read off it — holds a key, or needs none. +func TestServesModelIsThePoolsOwnTest(t *testing.T) { + 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"} + local := modelsource.Source{ID: "custom-ollama", Written: "ollama", Name: "ollama", Address: "http://127.0.0.1:11434/v1", KeyOptional: true} + keyless := modelsource.NewSet( + modelsource.Connected{Source: router, Address: router.Address}, + modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address}, + modelsource.Connected{Source: local, Address: local.Address}, + ) + keyed := modelsource.NewSet(modelsource.Connected{Source: router, Key: "sk-or-v1-routerkey0000000000", Address: router.Address}) + for _, row := range []struct { + sources modelsource.Set + model string + want bool + }{ + {keyless, "deepseek/deepseek-v4-flash-0731", false}, + {keyless, "openrouter/deepseek/deepseek-v4-flash-0731", false}, + {keyless, "mybox/qwen3-coder", true}, + {keyless, "ollama/llama4", true}, + {keyed, "deepseek/deepseek-v4-flash-0731", true}, + {keyed, "openrouter/deepseek/deepseek-v4-flash-0731", true}, + {keyed, "", false}, + {modelsource.Set{}, "deepseek/deepseek-v4-flash-0731", false}, + } { + if got := ServesModel(row.sources, row.model); got != row.want { + t.Errorf("ServesModel(%q) = %v, want %v", row.model, got, row.want) + } + } + // And it agrees with the pool: a model the pool would move to the seat is + // exactly one this answers no for. + pool := &modelClientPool{config: Config{Sources: keyless}, seat: "mybox/qwen3-coder"} + if seated := pool.seatedModel("deepseek/deepseek-v4-flash-0731"); seated != "mybox/qwen3-coder" || ServesModel(keyless, "deepseek/deepseek-v4-flash-0731") { + t.Fatalf("the pool seated %q and ServesModel disagrees with it", seated) + } +} + +// keyCapture is a completer that remembers the cache key each call carried. +type keyCapture struct{ keys *[]string } + +func (c keyCapture) CompleteWithMessages(ctx context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { + *c.keys = append(*c.keys, provider.CacheKeyFrom(ctx)) + return &ai.Response{}, nil +} + +// A CALL MARKED AS BRINGING ITS OWN LINEAGE KEEPS IT, and nothing else +// changes: an unmarked call, and a marked one that carries no key, are +// stamped with the conversation's key exactly as before. +func TestAMarkedCallKeepsItsOwnCacheLineage(t *testing.T) { + var keys []string + wrapper := sessionCompleter{inner: keyCapture{keys: &keys}, cacheKey: "conversation"} + program := provider.WithCacheKey(context.Background(), "program-thread") + for _, ctx := range []context.Context{ + WithOwnCacheLineage(program), + program, + WithOwnCacheLineage(context.Background()), + context.Background(), + } { + if _, err := wrapper.CompleteWithMessages(ctx, nil); err != nil { + t.Fatal(err) + } + } + want := []string{"program-thread", "conversation", "conversation", "conversation"} + for index := range want { + if keys[index] != want[index] { + t.Fatalf("keys = %q, want %q", keys, want) + } + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index f319c3b71..07af742a4 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -112,6 +112,11 @@ type RunSpec struct { // conversation's do — and a nil one lets the engine build each worker's // client itself. CompleterFor func(model string) Completer + // Serves answers whether this conversation's services can take a call on a + // model ([ServesModel], read live). A delegated run's model API asks it of + // every model the program names, and answers a model nothing here can + // reach on the run's work seat instead. Nil answers yes for every model. + Serves func(model string) bool // OnSpend observes the reconciled cumulative run spend while work is live. OnSpend func(float64) // Delegate, when set, is the program this run's root task is handed to @@ -452,6 +457,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { WorkModel: workSeat, PlanModel: planSeat, CompleterFor: func(string) Completer { return a.beltRunCompleter() }, + Serves: a.servesModel, Delegate: run.delegate, } } From 66983f500e683ff3f3b392d95383578abb282184 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:26:34 -0400 Subject: [PATCH 029/195] run: a delegated run serves its program the model API and meters every call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: the delegate worker started the program with an empty model API, so nothing it asked a model could be answered; it banked the dollars the program reported about itself in v1 `spend` records, trusted the terminal's cost_usd when it was higher, and wrote one spend row for the whole run at the end. A program refused at the ceiling ended as whatever it said it was, `crashed` included. Now: before the program starts, the worker opens the run's model API (modelapi.Open) over the conversation's own completer, with the run's dollar ceiling, the services the conversation can reach and the run's work seat (WorkSeat, the crew's own seatModel answer), and hands the child its address and token through delegate.ChildEnv; it closes the API the moment the program exits. Every metered call reaches three books as it happens: the run's live bank (bankSpend), one spend row per call under `delegate/`, and this machine's spending ledger, one row per call, written here and nowhere else — the conversation folds the run's total without a ledger row of its own. Report.USD is the API's metered total; the terminal's own cost is never banked and there is no end-of-run row. On the child's hello the program record (delegate.WriteProgram) is written beside the conversation log in the task's own folder. A program the API refused at the ceiling is reported in the ceiling's words — " reached the run's dollar ceiling of $X" — whatever it said it was, and the run ends on its cost limit. The v1 `spend` record is gone from the protocol: RecordSpend, Sink.Spend and Reading.SpendUSD are removed, and a spend line a program still writes is one more line the reader ignores and counts. The tests run the worker against real children: this test binary started as the program (delegate_child_test.go), asking the real API over a real socket, and a shell script that curls it; they pin the three books, the turns, the dead token, the ceiling's refusal and the cost limit. Co-Authored-By: Claude Opus 5.5 --- internal/delegate/launch_test.go | 10 +- internal/delegate/protocol.go | 41 +--- internal/delegate/protocol_test.go | 50 ++--- internal/run/crew.go | 57 +++-- internal/run/delegate_child_test.go | 155 +++++++++++++ internal/run/delegateworker.go | 261 ++++++++++++++++----- internal/run/delegateworker_test.go | 337 +++++++++++++++++++++++++--- internal/run/enginewire.go | 11 +- internal/run/testmain_test.go | 8 + 9 files changed, 766 insertions(+), 164 deletions(-) create mode 100644 internal/run/delegate_child_test.go diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go index 85ed0f504..e150a0959 100644 --- a/internal/delegate/launch_test.go +++ b/internal/delegate/launch_test.go @@ -14,7 +14,8 @@ import ( // 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 -// spend and a step, then runs the body it was given. +// 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() @@ -74,9 +75,14 @@ func TestRunStartsTheProgramsLineAndReadsTheTerminal(t *testing.T) { 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.spend[0] != 0.01 || sink.steps[0] != "bash: true→ok" { + 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) { diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go index 168e3d87f..83692495e 100644 --- a/internal/delegate/protocol.go +++ b/internal/delegate/protocol.go @@ -7,6 +7,12 @@ package delegate // 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 @@ -25,12 +31,8 @@ import ( 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" - // RecordSpend is v1's cumulative cost. It is still read until codeaf's - // model API meters every call itself, which makes it the one source of - // truth for money and this record redundant. - RecordSpend = "spend" + RecordHello = "hello" + RecordStage = "stage" RecordStep = "step" RecordTerminal = "terminal" ) @@ -165,10 +167,6 @@ type Sink interface { Hello(h Hello) // Stage is a phase change: the live step. Stage(stage, status string) - // Spend is the cumulative cost so far. The reader guarantees it never - // goes down: a program that sends a lower figure is answered with the - // last high one, because the bank behind this reads deltas. - Spend(usd float64) // Step is one finished action: command and the observation head, both // already capped. Step(command, observation string) @@ -179,13 +177,14 @@ type Sink interface { } // Reading is what a reader saw, for the record the launch keeps: the last -// stage, the high-water spend, how many steps, whether a terminal arrived, and -// how many lines were not the protocol's (dropped, not failed). +// 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 - SpendUSD float64 Steps int Terminal *Terminal Ignored int @@ -240,22 +239,6 @@ func Read(r io.Reader, sink Sink) (Reading, error) { if sink != nil { sink.Stage(rec.Stage, rec.Status) } - case RecordSpend: - var rec struct { - CostUSD *float64 `json:"cost_usd"` - } - if json.Unmarshal([]byte(line), &rec) != nil || rec.CostUSD == nil { - reading.Ignored++ - continue - } - // NEVER DOWN. The bank behind the sink adds deltas, and a figure - // that fell would be a refund nobody issued. - if *rec.CostUSD > reading.SpendUSD { - reading.SpendUSD = *rec.CostUSD - } - if sink != nil { - sink.Spend(reading.SpendUSD) - } case RecordStep: var rec struct { Command string `json:"command"` diff --git a/internal/delegate/protocol_test.go b/internal/delegate/protocol_test.go index f2a9da621..3ed4f963a 100644 --- a/internal/delegate/protocol_test.go +++ b/internal/delegate/protocol_test.go @@ -17,7 +17,6 @@ type recorder struct { spoke chan struct{} hello *Hello stages []string - spend []float64 steps []string terminal *Terminal } @@ -38,11 +37,6 @@ func (r *recorder) Stage(stage, status string) { r.once.Do(func() { close(r.spoke) }) } } -func (r *recorder) Spend(usd float64) { - r.mu.Lock() - defer r.mu.Unlock() - r.spend = append(r.spend, usd) -} func (r *recorder) Step(command, observation string) { r.mu.Lock() defer r.mu.Unlock() @@ -55,9 +49,11 @@ func (r *recorder) Terminal(t Terminal) { } // 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 spend -// reaches the bank, the steps reach the page, the terminal is the result, and -// every bus payload passes through untouched. +// 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 { @@ -74,25 +70,22 @@ func TestTheReaderReplaysASeniorDevStream(t *testing.T) { if reading.LastStage != "agent-summary" { t.Fatalf("last stage = %q, want agent-summary, the stage before the terminal", reading.LastStage) } - if reading.SpendUSD != 0.0213 || reading.Steps != 2 { - t.Fatalf("spend %.4f steps %d, want 0.0213 and 2", reading.SpendUSD, reading.Steps) + if reading.Steps != 2 { + t.Fatalf("steps %d, want 2", reading.Steps) } - // Three bus payloads are on the stream; they are ignored, not failed. - if reading.Ignored != 3 { - t.Fatalf("ignored = %d, want the three bus payloads", reading.Ignored) + // 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) } - // The spend is told three times and never goes down; the repeat is told - // again at the same figure, which a bank reads as no delta. - if len(sink.spend) != 3 || sink.spend[0] != 0.0101 || sink.spend[2] != 0.0213 { - t.Fatalf("spend told = %v", sink.spend) - } 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. + // 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) @@ -105,7 +98,11 @@ func TestTheReaderReplaysASeniorDevStream(t *testing.T) { } } -func TestTheReaderKeepsSpendFromFallingAndTakesOneTerminal(t *testing.T) { +// 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}`, @@ -120,16 +117,13 @@ func TestTheReaderKeepsSpendFromFallingAndTakesOneTerminal(t *testing.T) { if err != nil { t.Fatal(err) } - if len(sink.spend) != 2 || sink.spend[1] != 0.5 { - t.Fatalf("spend told = %v, want the second reading held at the first's high water", sink.spend) - } if sink.terminal == nil || sink.terminal.Message != "first" { t.Fatalf("terminal = %+v, want the first one only", sink.terminal) } - // The second terminal, the stray line and the unknown type are the three - // ignored lines; the empty line is nothing. - if reading.Ignored != 3 { - t.Fatalf("ignored = %d", reading.Ignored) + // 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) } } diff --git a/internal/run/crew.go b/internal/run/crew.go index be4cd7ac6..0936c5a1d 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/delegate_child_test.go b/internal/run/delegate_child_test.go new file mode 100644 index 000000000..c73b65baf --- /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("implement", "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("model: ask", "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("model: ask", 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("verify", "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/delegateworker.go b/internal/run/delegateworker.go index 41e3b5d8b..b0fdec3fd 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -14,6 +14,27 @@ package run // the ending. Its stages feed the live step only; its `step` records are 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. +// +// ── 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 three books as it is metered ([delegateMeter]): +// the run's live bank, which the supervisor holds to the ceiling and the +// conversation's status line reads; 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 — the conversation folds the run's total into its own meter +// without writing a ledger row of its own (internal/session's addFoldedUsage). +// 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" @@ -24,8 +45,13 @@ import ( "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 @@ -42,18 +68,36 @@ type DelegateWorker struct { // 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. + // 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. +// 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 } // NewDelegateWorker builds the worker. cost and elapsed are the run's @@ -80,16 +124,15 @@ func DelegateFactory(store *plandb.Store, workspace string, program delegate.Del // 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 run's spend bank. Its methods run on the reader's goroutine and none of -// them waits on anything but the store's own lock. +// 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 - ctx context.Context taskID string storeDir string + taskDir string name string steps int - usd float64 lastErr error terminal *delegate.Terminal // stop ends the program early, and mismatch says why: the child spoke @@ -101,6 +144,11 @@ type delegateSink struct { func (s *delegateSink) Hello(h delegate.Hello) { if h.Protocol == delegate.ProtocolVersion { + // THE PAGE LEARNS WHOSE CONVERSATION IT IS DRAWING, and the stages the + // program will move through, the moment the program says them — 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. + _ = delegate.WriteProgram(s.taskDir, delegate.ProgramRecord{Name: s.name, Stages: h.Stages}) return } // TWO BUILDS, ONE RUN. Nothing a newer child writes can be trusted to mean @@ -124,13 +172,6 @@ func (s *delegateSink) Stage(stage, status string) { _ = s.worker.store.SetLive(s.taskID, s.steps+1, label) } -func (s *delegateSink) Spend(usd float64) { - if usd > s.usd { - s.usd = usd - } - bankSpend(s.ctx, s.usd) -} - func (s *delegateSink) Step(command, observation string) { s.steps++ if err := appendTrajectory(s.storeDir, s.taskID, Step{ @@ -145,71 +186,140 @@ func (s *delegateSink) Step(command, observation string) { func (s *delegateSink) Terminal(t delegate.Terminal) { s.terminal = &t } +// delegateMeter is where the run's model API tells each charge as it is +// metered: 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 + role string + name string + workspace string + ledger string +} + +// bank books one charge in all three places. +// +// THE LEDGER ROW IS WRITTEN HERE AND ONLY HERE. The conversation that started +// the run folds the run's total into its own meter through the fold door, +// which 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. +func (m *delegateMeter) bank(charge modelapi.Charge) { + bankSpend(m.ctx, charge.Spent) + _ = m.store.AddSpend(m.taskID, m.name, m.role, charge.CostUSD, charge.TokensIn, charge.TokensOut) + line := session.UsageLine{ + Model: charge.Model, Calls: 1, Input: charge.TokensIn, Output: charge.TokensOut, USD: charge.CostUSD, + Reconciled: charge.Late, Workspace: m.workspace, + } + 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. +func (m *delegateMeter) unbilled(model string) { + session.RecordUnbilledCall(m.ledgerPath(), session.TagUsage(session.UsageLine{Model: model, Workspace: m.workspace}, roles.RoleWorker, session.SeatWorker)) +} + +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 the higher of what it streamed and what its terminal record said. +// 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) } - launchCtx, stop := context.WithCancel(ctx) - defer stop() - sink := &delegateSink{worker: w, ctx: ctx, taskID: task.ID, storeDir: storeDir, name: w.program.Name, stop: stop} - brief := strings.TrimSpace(task.Description) - if brief == "" { - brief = strings.TrimSpace(task.Title) + end := func(steps int, reason, result string) { + _ = appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryEndKind, ExitsRecorded: true, Steps: steps, Result: result, Reason: reason}) } exe := w.setup.Exe if exe == "" { self, err := os.Executable() if err != nil { - return Report{}, fmt.Errorf("find codeaf's own executable to run %s: %w", w.program.Name, err) + 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, 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, + } + 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, + // 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} + brief := strings.TrimSpace(task.Description) + if brief == "" { + brief = strings.TrimSpace(task.Title) + } 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()}), - // NO KEY REACHES THE PROGRAM (delegate.ChildEnv). - Env: delegate.ChildEnv(delegate.ModelAPI{}), + // 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(plandb.TaskDir(storeDir, task.ID), delegateStderrName), + StderrPath: filepath.Join(taskDir, delegateStderrName), Grace: w.setup.Grace, }, sink) + // 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. + _ = 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) - usd := sink.usd - if t := result.Reading.Terminal; t != nil { - if total, ok := t.CostUSD(); ok && total > usd { - usd = total - } - } - if usd > 0 { - // The spend row is the task page's own figure. The role is read off the - // store as the bash worker reads it; the "model" column carries the - // delegate's name, because that is what spent the money. - role, err := w.store.RoleOf(task.ID) - if err != nil { - role = plandb.RoleWork - } - _ = w.store.AddSpend(task.ID, "delegate/"+w.program.Name, role, usd, 0, 0) - } - report := Report{Steps: sink.steps, USD: usd} - - end := func(reason, result string) { - _ = appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryEndKind, ExitsRecorded: true, Steps: sink.steps, Result: result, Reason: reason}) - } + report := Report{Steps: sink.steps, USD: api.Spent()} if sink.lastErr != nil { - end("the record failed: "+sink.lastErr.Error(), "") + end(sink.steps, "the record failed: "+sink.lastErr.Error(), "") return report, sink.lastErr } if sink.mismatch != "" && ctx.Err() == nil { - end(sink.mismatch, "") + end(sink.steps, sink.mismatch, "") return report, errors.New(sink.mismatch) } if result.Stopped { @@ -220,44 +330,87 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err if t := result.Reading.Terminal; t != nil && t.Message != "" { reason += ": " + w.program.Name + " said " + t.Message } - end(reason, "") + 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(reason, "") + end(sink.steps, reason, "") return report, errors.New(reason) } if err != nil { - end(err.Error(), "") + end(sink.steps, err.Error(), "") return report, err } t := *result.Reading.Terminal report.Result = delegateResult(w.program, t) switch t.Status { case delegate.StatusPass: - end("finished: "+t.Message, report.Result) + 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 - end(reason, report.Result) + end(sink.steps, reason, report.Result) return report, errors.New(reason) case delegate.StatusCrashed: reason := w.program.Name + " crashed: " + t.Message - end(reason, report.Result) + end(sink.steps, reason, report.Result) return report, errors.New(reason) 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(reason, report.Result) + end(sink.steps, reason, report.Result) return report, errors.New(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 diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go index f0abd9ecb..76a8d81a9 100644 --- a/internal/run/delegateworker_test.go +++ b/internal/run/delegateworker_test.go @@ -3,32 +3,41 @@ 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 stage, a spend, two steps, then body — and answers the program's -// definition and the setup that starts the script in codeaf's place. +// 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"}'`, - `echo '{"type":"spend","cost_usd":0.11}'`, body, }, "\n") + "\n" if err := os.WriteFile(script, []byte(program), 0o755); err != nil { @@ -41,7 +50,225 @@ 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"}}'` } -func TestDelegateWorkerRecordsStepsBanksSpendAndReportsTheEnding(t *testing.T) { +// 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" { + t.Fatalf("program record = %+v %v, want the hello's name and stages", 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") @@ -59,13 +286,11 @@ func TestDelegateWorkerRecordsStepsBanksSpendAndReportsTheEnding(t *testing.T) { if report.Steps != 2 { t.Fatalf("steps = %d, want the two step records the program sent", report.Steps) } - // The report's dollars are the terminal's total, which is higher than the - // last streamed figure; the bank saw the streamed figures as they rose. - if report.USD != 0.12 { - t.Fatalf("usd = %v, want the terminal's 0.12", report.USD) + if report.USD != 0 || len(banked) != 0 { + t.Fatalf("usd %v banked %v, want nothing: no call was metered", report.USD, banked) } - if len(banked) != 2 || banked[0] != 0.05 || banked[1] != 0.11 { - t.Fatalf("banked = %v, want the two rising spend records", 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) @@ -92,17 +317,17 @@ func TestDelegateWorkerRecordsStepsBanksSpendAndReportsTheEnding(t *testing.T) { if end.Steps != 2 || !strings.HasPrefix(end.Reason, "finished: ") { t.Fatalf("ending = %+v", end) } - // The live step was cleared with the process, the spend row names the - // delegate, and stderr went to the task's folder. + // 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) } - if _, err := os.Stat(filepath.Join(plandb.TaskDir(storeDir, store.RootID()), "delegate-stderr.log")); err != nil { + 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) } - spend := store.SpendSummary() - if got := spend.ByModel["delegate/fake"]; got.USD != 0.12 || got.Calls != 1 { - t.Fatalf("spend by model = %+v, want one row of 0.12 under the delegate's name", spend.ByModel) + if record, ok := delegate.ReadProgram(taskDir); !ok || record.Name != "fake" || len(record.Stages) != 2 { + t.Fatalf("program record = %+v %v", record, ok) } } @@ -114,8 +339,10 @@ func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "fake did not finish: unsubmitted") { t.Fatalf("err = %v", err) } - if report.USD != 0.2 || report.Steps != 2 { - t.Fatalf("report = %+v, want the money and the steps kept on a failed ending", report) + // 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) } } @@ -155,8 +382,8 @@ func TestDelegateWorkerComesHomeWithTheContextsEndingWhenTheRunStopsIt(t *testin 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.11 { - t.Fatalf("usd = %v, want what was spent before the stop", report.USD) + 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) @@ -165,11 +392,12 @@ func TestDelegateWorkerComesHomeWithTheContextsEndingWhenTheRunStopsIt(t *testin } } -// The whole road: a run of one task whose root is the delegate, driven by the -// supervisor to done, with the delegate's words as the run's result. +// 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 := fakeDelegate(t, passLine("all green")) + 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, @@ -194,16 +422,15 @@ func TestARunSeatsTheDelegateOnItsRootAndEndsDone(t *testing.T) { } } -// A run whose dollar ceiling the delegate's streamed spend crosses is ended by +// 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. +// 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) - m, setup := fakeDelegate(t, strings.Join([]string{ - `trap 'echo "{\"type\":\"terminal\",\"status\":\"budget-exhausted\",\"message\":\"stopped\",\"data\":{\"cost_usd\":0.11}}"; exit 0' TERM`, - `sleep 30 &`, - `wait $!`, - }, "\n")) + 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, @@ -216,6 +443,53 @@ func TestARunEndsADelegateThatCrossesTheCostCeiling(t *testing.T) { 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 @@ -241,4 +515,7 @@ func TestDelegateWorkerStopsAChildOfAnotherBuild(t *testing.T) { 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 5de5ab661..4f224bb5b 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -44,7 +44,16 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar // have left off, and the program's own verification is what its // terminal record reports ([DelegateWorker]). limits.ReviewRound = false - factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, DelegateSetup{}, limits, factory) + // 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), + } + factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, setup, limits, factory) } outcome, summary := Start(ctx, Spec{ Store: spec.Store, diff --git a/internal/run/testmain_test.go b/internal/run/testmain_test.go index 97a6bbc60..48055ed6a 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.Exit(m.Run()) } From 27b57ab552bd6929d552c6cdb85484c4de75e60d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:26:47 -0400 Subject: [PATCH 030/195] cli: codeaf runs a carried program from a shell, and --help lists it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: `codeaf ` from a shell refused with "runs from a shell once its model API is in this build"; `codeaf --help` never named a carried program, and a typo of one was answered with nothing. Now: a shell run is the same two processes a chat's run is. The host reads the person's own profile the way `codeaf exec` does (config.Load, the crew's work seat, one adapter per model with the service's own id put on the wire, so an `openrouter/` spelling reaches the router bare), opens a model API, and starts this executable as the program's child with the API's address and token and no key, handing it the person's own line with --json added so a command they named and its own flags arrive intact. It prints the stage as it changes, each step, each model call as `model · N in · N out · $X`, and the ending — or, with --json, passes the records through as records. Every call is written to this machine's spending ledger once; the conversation, the program record and its stderr are kept under ~/.codeaf/v3/carried///. --max-cost and --max-hours stop the program from outside (the limit rung), Ctrl-C sends SIGTERM with its grace (the incomplete rung), a run with no brief starts nothing, and the exit is carriedExit's. `codeaf --help` lists every carried program in a group of its own after the work you hand it — one synopsis line and a one-line summary each, inside the eighty-cell law, the page's cap moving by exactly that (carriedPageLines) — and prints no group in a build that carries none. A program's name joins the typo suggester (commandWords), and a test fails the build when a program's name would shadow any word run() answers. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/carried.go | 585 ++++++++++++++++++++++++++++++- cmd/codeaf/carried_child_test.go | 128 +++++++ cmd/codeaf/carried_host_test.go | 250 +++++++++++++ cmd/codeaf/carried_test.go | 268 ++++++++++++++ cmd/codeaf/logs_test.go | 7 + cmd/codeaf/main.go | 5 +- cmd/codeaf/usage.go | 25 +- 7 files changed, 1261 insertions(+), 7 deletions(-) create mode 100644 cmd/codeaf/carried_child_test.go create mode 100644 cmd/codeaf/carried_host_test.go create mode 100644 cmd/codeaf/carried_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index 6e04b6024..6c2735e85 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -10,22 +10,59 @@ package main // 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" + "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" ) +// 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 + +// 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, os.Stdout) + inv, err := delegate.Parse(program, args, carriedStdout) if errors.Is(err, delegate.ErrHelp) { return exitDone } @@ -43,11 +80,227 @@ func runCarried(program delegate.Delegate, args []string) error { return runCarriedHost(ctx, inv) } +// 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 +} + +// 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, + }, nil +} + +// 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 key and starts the program as its own child. +// with the person's own services and starts the program as its own child. func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { - fmt.Fprintf(os.Stderr, "error: codeaf %s runs from a shell once its model API is in this build; the chat's /%s is the road until then\n", inv.Program.Name, inv.Program.Name) - return exitCannotRun + // 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) + + 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() + 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, + } + 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}, roles.RoleWorker, session.SeatWorker)) + }, + 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(os.Stderr, "stopping %s: it has %s to say how it ended\n", inv.Program.Name, grace) + }) + view.begin() + result, runErr := delegate.Run(runCtx, delegate.Launch{ + Name: inv.Program.Name, + Bin: exe, + Args: carriedChildLine(inv), + // 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) + untell() + // 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. + _ = api.Close() + session.CloseUsage() + return view.end(result, runErr, limited.Load(), api.Spent()) +} + +// 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 +// 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 { + stamp := time.Now().Format("20060102-150405.000000") + return home.Join("v3", "carried", name, stamp) +} + +// 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 @@ -62,3 +315,327 @@ func carriedExit(status string) error { 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 +} + +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.inv.Workspace) +} + +func (v *carriedView) Hello(h delegate.Hello) { + if v.records != nil { + _ = v.records.Hello(v.inv.Program.Name, h.Stages) + } +} + +func (v *carriedView) Stage(stage, status string) { + if v.records != nil { + _ = v.records.Stage(stage, status) + return + } + 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(command, observation string) { + if v.records != nil { + _ = v.records.Step(command, observation) + return + } + if head := firstLineOf(observation); head != "" { + v.say(" %s · %s", command, head) + return + } + v.say(" %s", command) +} + +func (v *carriedView) Terminal(t delegate.Terminal) { + v.keep(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. +func (v *carriedView) end(result delegate.Result, runErr error, limited bool, spent float64) 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 { + 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) + } + } + if calls > 0 { + word := "calls" + if calls == 1 { + word = "call" + } + summary := fmt.Sprintf(" %d model %s", calls, word) + if spent > 0 { + summary += " · " + carriedDollars(spent) + } + v.say("%s", summary) + } + if _, err := os.Stat(v.record); err == nil { + v.say(" the run's record is in %s", v.record) + } + return carriedExit(status) +} + +// 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 000000000..750414298 --- /dev/null +++ b/cmd/codeaf/carried_child_test.go @@ -0,0 +1,128 @@ +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" + "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", + 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") + return func(ctx context.Context, host delegate.Host, args []string) error { + host.Hello([]string{"implement", "verify"}) + host.Stage("implement", "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("model: ask", "refused: "+err.Error()) + continue + } + host.Step("model: ask", reply) + } + if *wait || ctx.Err() != nil { + <-ctx.Done() + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "stopped before it finished"}) + return nil + } + host.Stage("verify", "pass") + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "submitted and verified", Claim: "the test is fixed", Observed: "pass"}) + 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 with the fake program carried, when this +// binary was started as a shell run's child, and says whether it was. +func runAsCarriedChild() (int, bool) { + if os.Getenv(carriedChildEnv) != "1" { + return 0, false + } + restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()}) + defer restore() + return execute(), true +} diff --git a/cmd/codeaf/carried_host_test.go b/cmd/codeaf/carried_host_test.go new file mode 100644 index 000000000..4693058d3 --- /dev/null +++ b/cmd/codeaf/carried_host_test.go @@ -0,0 +1,250 @@ +//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/home" + "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(home.Join("v3", "carried", 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) + } +} + +// 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_test.go b/cmd/codeaf/carried_test.go new file mode 100644 index 000000000..f225a5560 --- /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/logs_test.go b/cmd/codeaf/logs_test.go index d66b56e99..e47bed2a6 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 4ec123bda..4d997d3e4 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -711,7 +711,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 f368cbabb..3178d7303 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", From c2e5bd9bf7b157bf6d734fb8fd3a76a176970b36 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:30:18 -0400 Subject: [PATCH 031/195] modelapi: a program's reasoning depth rides codeaf's own ladder, xhigh and max included Was: the model API read a program's reasoning depth as one of the adapter's four words and dropped anything else, so senior-dev's `--variant xhigh` reached the model as no depth at all. Now: low, medium and high travel as rungs of codeaf's own ladder (internal/effort), and xhigh and max as the two rungs above them, which the adapter says with a thinking budget (provider.WithConfiguredEffortRung, its one translation). Switching the pass off and the router's `minimal` travel as the adapter's own words; a word nothing in codeaf has a place for is still never sent. Co-Authored-By: Claude Opus 5.5 --- internal/provider/modelapi/server.go | 17 ++++++--- internal/provider/modelapi/wire.go | 51 +++++++++++++++---------- internal/provider/modelapi/wire_test.go | 36 +++++++++-------- 3 files changed, 62 insertions(+), 42 deletions(-) diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go index 4e3270663..813e5a5d4 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -45,6 +45,7 @@ import ( "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" @@ -512,12 +513,16 @@ func (s *Server) settings(ctx context.Context, request *call, bill *tally, catch ctx = provider.WithCallTag(ctx, "task") ctx = provider.WithCallNode(ctx, s.config.Node) ctx = provider.WithCacheKey(ctx, request.cacheKey) - if request.hasEffort { - // The program asked for this depth in so many words, which is what an - // operator'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. - ctx = provider.WithConfiguredReasoningEffort(ctx, request.effort) + // 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) }) diff --git a/internal/provider/modelapi/wire.go b/internal/provider/modelapi/wire.go index 96af6750c..dd06eb044 100644 --- a/internal/provider/modelapi/wire.go +++ b/internal/provider/modelapi/wire.go @@ -25,6 +25,7 @@ import ( "strings" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/provider" ) @@ -82,8 +83,16 @@ type call struct { messages []ai.Message reasoning []provider.MessageReasoning options []ai.Option - effort provider.Effort - hasEffort bool + 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 @@ -143,7 +152,7 @@ func decodeRequest(body []byte) (*call, error) { return nil, err } decoded.options = options - decoded.effort, decoded.hasEffort = request.effort() + decoded.depth = request.depth() return decoded, nil } @@ -289,34 +298,34 @@ func withResponseFormat(format *ai.ResponseFormat) ai.Option { } } -// effort is the reasoning depth the program asked for, in codeaf's words: -// OpenRouter's `reasoning` object or OpenAI's `reasoning_effort`. A word -// codeaf's adapter does not have is not sent, because a knob a model would -// refuse must never reach the wire; `enabled: false` and `none` are the one -// request to switch the pass off. -func (r chatRequest) effort() (provider.Effort, bool) { +// 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 provider.EffortOff, true + return depth{word: provider.EffortOff} } if said := strings.TrimSpace(r.Reasoning.Effort); said != "" { word = said } } - switch strings.ToLower(word) { + switch word = strings.ToLower(word); word { case "none", "off": - return provider.EffortOff, true + return depth{word: provider.EffortOff} case "minimal": - return provider.EffortMinimal, true - case "low": - return provider.EffortLow, true - case "medium": - return provider.EffortMedium, true - case "high": - return provider.EffortHigh, true - } - return provider.EffortNone, false + return depth{word: provider.EffortMinimal} + } + if rung := effort.Rung(word); rung.Valid() { + return depth{rung: rung} + } + return depth{} } // ── the answer ────────────────────────────────────────────────────────────── diff --git a/internal/provider/modelapi/wire_test.go b/internal/provider/modelapi/wire_test.go index 5d6b23414..918cade18 100644 --- a/internal/provider/modelapi/wire_test.go +++ b/internal/provider/modelapi/wire_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/provider" ) @@ -74,8 +75,8 @@ func TestTheWireCarriesEveryFieldTheFunnelHasAHomeFor(t *testing.T) { !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.hasEffort || decoded.effort != provider.EffortHigh { - t.Fatalf("effort = %q %v", decoded.effort, decoded.hasEffort) + 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" { @@ -97,27 +98,32 @@ func TestTheWireCarriesEveryFieldTheFunnelHasAHomeFor(t *testing.T) { } } +// 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 - effort provider.Effort - set bool + body string + want depth }{ - {`"reasoning_effort": "low"`, provider.EffortLow, true}, - {`"reasoning": {"effort": "medium"}`, provider.EffortMedium, true}, - {`"reasoning": {"effort": "minimal"}`, provider.EffortMinimal, true}, - {`"reasoning": {"enabled": false}`, provider.EffortOff, true}, - {`"reasoning_effort": "none"`, provider.EffortOff, true}, - {`"reasoning": {"enabled": true}`, provider.EffortNone, false}, - // A word codeaf's adapter does not have is never sent. - {`"reasoning_effort": "xhigh"`, provider.EffortNone, false}, + {`"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.effort != row.effort || decoded.hasEffort != row.set { - t.Fatalf("%s: effort %q %v, want %q %v", row.body, decoded.effort, decoded.hasEffort, row.effort, row.set) + if decoded.depth != row.want { + t.Fatalf("%s: depth %+v, want %+v", row.body, decoded.depth, row.want) } } } From fd779218996cb7ce9bd34ee9c9564c96eb9b4bc5 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:30:27 -0400 Subject: [PATCH 032/195] run: the program record carries the run's ceiling Was: delegate.ProgramRecord gained CeilingUSD for the task page, and nothing wrote it, so the page drew the spend with no ceiling beside it. Now: the worker writes the run's dollar ceiling into the record with the hello, beside the program's name and stages; zero, for a run with none, is still left off. Co-Authored-By: Claude Opus 5.5 --- internal/run/delegateworker.go | 11 ++++++----- internal/run/delegateworker_test.go | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index b0fdec3fd..c34ec89b1 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -144,11 +144,12 @@ type delegateSink struct { func (s *delegateSink) Hello(h delegate.Hello) { if h.Protocol == delegate.ProtocolVersion { - // THE PAGE LEARNS WHOSE CONVERSATION IT IS DRAWING, and the stages the - // program will move through, the moment the program says them — 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. - _ = delegate.WriteProgram(s.taskDir, delegate.ProgramRecord{Name: s.name, Stages: h.Stages}) + // 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. + _ = delegate.WriteProgram(s.taskDir, delegate.ProgramRecord{Name: s.name, Stages: h.Stages, CeilingUSD: s.worker.cost}) return } // TWO BUILDS, ONE RUN. Nothing a newer child writes can be trusted to mean diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go index 76a8d81a9..0df17731f 100644 --- a/internal/run/delegateworker_test.go +++ b/internal/run/delegateworker_test.go @@ -194,8 +194,8 @@ func TestDelegateWorkerServesItsChildTheModelAPIAndMetersEveryCall(t *testing.T) // 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" { - t.Fatalf("program record = %+v %v, want the hello's name and stages", record, ok) + 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) From bf2d98b30faa79ac7f15e03f230c8f178b3d6ecc Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:33:17 -0400 Subject: [PATCH 033/195] modelapi: a model's working goes out under the router's name and comes back under its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: the API handed a thinking model's working to the program under the field it arrived on. Through a direct endpoint or a local proxy that is `reasoning_content`, which a program written against OpenRouter — senior-dev reads `reasoning` and nothing else — never saw, so it could never hand the working back for the model to continue its tool loop. Now: the working always goes out as `reasoning` (and its `reasoning_details`), the router's own name. Each thread remembers the field its working last arrived on, and working the program hands back under `reasoning` is replayed to the endpoint under that field (provider.ReasoningReplayPolicy); a field the program names outright is kept. senior-dev's own chunk parser was run over the API's stream and accepts every event. Co-Authored-By: Claude Opus 5.5 --- internal/provider/modelapi/server.go | 14 +++++++-- internal/provider/modelapi/server_test.go | 28 ++++++++++++++++++ internal/provider/modelapi/threads.go | 35 +++++++++++++++++++++-- internal/provider/modelapi/wire.go | 19 ++++++------ internal/provider/modelapi/wire_test.go | 30 +++++++++++++++++-- internal/provider/modelapi/working.go | 14 ++++----- 6 files changed, 117 insertions(+), 23 deletions(-) diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go index 813e5a5d4..bda516f8f 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -375,17 +375,26 @@ func (r *record) close(fill func(turn *delegate.Turn)) delegate.Turn { } // open numbers one call, opens its turn with what the thread had not said -// before, and answers the run's spend at the moment the call arrived — the -// figure its ceiling is asked against. +// 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 @@ -436,6 +445,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { 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() diff --git a/internal/provider/modelapi/server_test.go b/internal/provider/modelapi/server_test.go index dd58bebf1..73a9ecf4a 100644 --- a/internal/provider/modelapi/server_test.go +++ b/internal/provider/modelapi/server_test.go @@ -367,6 +367,34 @@ func TestTheCallCrossesIntoTheFunnelWholeAndTheAnswerComesBackWhole(t *testing.T } } +// 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) { diff --git a/internal/provider/modelapi/threads.go b/internal/provider/modelapi/threads.go index 7a0aeb464..58cf5ea21 100644 --- a/internal/provider/modelapi/threads.go +++ b/internal/provider/modelapi/threads.go @@ -28,12 +28,43 @@ import ( "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. It is guarded by -// the server's lock. +// 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 diff --git a/internal/provider/modelapi/wire.go b/internal/provider/modelapi/wire.go index dd06eb044..631eee4ee 100644 --- a/internal/provider/modelapi/wire.go +++ b/internal/provider/modelapi/wire.go @@ -156,18 +156,21 @@ func decodeRequest(body []byte) (*call, error) { return decoded, nil } -// working is the reasoning a program handed back, under the field it arrived -// on — the provider's replay law is that working travels back unmodified under -// the name it came in with (provider.ReasoningReplayPolicy). +// 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.Reasoning != "": - working.Field, working.Text = "reasoning", e.Reasoning 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...) @@ -454,10 +457,8 @@ func toolCalls(calls []ai.ToolCall, indexed bool) []wireToolCall { return out } -// message is the whole answer's assistant message. THE WORKING TRAVELS UNDER -// THE FIELD IT ARRIVED ON, so a program that hands it back on its next call is -// handing back exactly what the endpoint wrote (provider.ReasoningReplayPolicy): -// OpenRouter's `reasoning`, or a direct endpoint's `reasoning_content`. +// 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 { diff --git a/internal/provider/modelapi/wire_test.go b/internal/provider/modelapi/wire_test.go index 918cade18..fe0b2ee07 100644 --- a/internal/provider/modelapi/wire_test.go +++ b/internal/provider/modelapi/wire_test.go @@ -71,7 +71,9 @@ func TestTheWireCarriesEveryFieldTheFunnelHasAHomeFor(t *testing.T) { if tool := decoded.messages[3]; tool.Role != "tool" || tool.ToolCallID != "call_1" || tool.Content[0].Text != "ok" { t.Fatalf("tool result = %+v", tool) } - if len(decoded.reasoning) != 4 || decoded.reasoning[2].Field != "reasoning" || decoded.reasoning[2].Text != "look first" || + // 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) } @@ -166,8 +168,10 @@ func TestAStreamedAnswerIsTheRoutersChunksInTheRoutersOrder(t *testing.T) { if len(chunks) != 6 { t.Fatalf("%d chunks, want working, words, two calls, finish and usage", len(chunks)) } - if chunks[0].Choices[0].Delta["reasoning_content"] != "thinking" || chunks[0].Choices[0].Delta["reasoning_details"] == nil { - t.Fatalf("working chunk = %+v, want the working under the field it arrived on", chunks[0].Choices[0].Delta) + // 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) @@ -238,6 +242,26 @@ func TestAThreadRecordsOnlyWhatItHadNotSaidBefore(t *testing.T) { } } +// 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"}, diff --git a/internal/provider/modelapi/working.go b/internal/provider/modelapi/working.go index a1b3f6a0e..a2f46502b 100644 --- a/internal/provider/modelapi/working.go +++ b/internal/provider/modelapi/working.go @@ -35,15 +35,15 @@ type captured struct { // 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 field it came -// in on, `reasoning` when the funnel did not say. +// 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 != "" { - field := c.field - if field == "" { - field = "reasoning" - } - target[field] = c.text + target["reasoning"] = c.text } if len(c.details) > 0 { target["reasoning_details"] = c.details From e1ee5bd4b930b456086a9a53174e4dc8e78d3cf6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:35:54 -0400 Subject: [PATCH 034/195] cli: senior-dev itself works a task through a shell run's model API, pinned by a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was: the shell host was proven against a fake program only, so nothing showed that the program codeaf actually carries could speak to the model API codeaf serves it. Now: a test runs the real senior-dev as a shell run — this test binary started as its child, the real model API between them, and its own scripted conversation (write, write, submit, done) played by a funnel behind the API — in a real repository, to a passing ending: the work is in the tree, every call carries senior-dev's own cache lineage and is on the ledger once, the conversation is kept with its submit, and a person reads the stages, the calls and the ending. The shell run's record root is named once (carriedRecordRoot) for the host and its tests. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/carried.go | 9 +- cmd/codeaf/carried_child_test.go | 19 ++- cmd/codeaf/carried_host_test.go | 3 +- cmd/codeaf/carried_seniordev_test.go | 203 +++++++++++++++++++++++++++ 4 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 cmd/codeaf/carried_seniordev_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index 6c2735e85..6e6c4e640 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -279,8 +279,13 @@ const carriedStderrName = "delegate-stderr.log" // 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 { - stamp := time.Now().Format("20060102-150405.000000") - return home.Join("v3", "carried", name, stamp) + 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 diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go index 750414298..c35ba8571 100644 --- a/cmd/codeaf/carried_child_test.go +++ b/cmd/codeaf/carried_child_test.go @@ -116,13 +116,18 @@ func askCarried(ctx context.Context, api delegate.ModelAPI, question string) (st return answer.Choices[0].Message.Content, nil } -// runAsCarriedChild runs the dispatch with the fake program carried, when this -// binary was started as a shell run's child, and says whether it was. +// 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", and with the build's own list — senior-dev itself — when it +// is "real". func runAsCarriedChild() (int, bool) { - if os.Getenv(carriedChildEnv) != "1" { - return 0, false + switch os.Getenv(carriedChildEnv) { + case "1": + restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()}) + defer restore() + return execute(), true + case "real": + return execute(), true } - restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()}) - defer restore() - return execute(), true + return 0, false } diff --git a/cmd/codeaf/carried_host_test.go b/cmd/codeaf/carried_host_test.go index 4693058d3..86d6387ab 100644 --- a/cmd/codeaf/carried_host_test.go +++ b/cmd/codeaf/carried_host_test.go @@ -16,7 +16,6 @@ import ( "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/home" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/provider/modelapi" "github.com/Agent-Field/codeaf/internal/session" @@ -105,7 +104,7 @@ func ledgerRowsFor(t *testing.T, workspace string) []session.UsageLine { // 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(home.Join("v3", "carried", fakeCarried), "*")) + matches, _ := filepath.Glob(filepath.Join(carriedRecordRoot(fakeCarried), "*")) if len(matches) == 0 { t.Fatal("the shell run kept no record folder") } diff --git a/cmd/codeaf/carried_seniordev_test.go b/cmd/codeaf/carried_seniordev_test.go new file mode 100644 index 000000000..e7bad9244 --- /dev/null +++ b/cmd/codeaf/carried_seniordev_test.go @@ -0,0 +1,203 @@ +//go:build !windows + +package main + +import ( + "context" + "encoding/json" + "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{{ + ID: "call-" + name, 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 +} From 8702e3efe777dc05fc49ac9f9bd9d34ab5c0ce4a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:45:26 -0400 Subject: [PATCH 035/195] =?UTF-8?q?delegate:=20the=20three=20lanes=20meet?= =?UTF-8?q?=20=E2=80=94=20help=20fits=20eighty=20columns,=20and=20the=20pa?= =?UTF-8?q?ges=20say=20what=20the=20API=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was true: a carried program's `-h` page drew one line of 84 cells, past the width every codeaf help page is held to; senior-dev's page and the programs page said nothing about the model a machine without senior-dev's pool is answered on, quoted no ending for a run the dollar ceiling stopped, and did not say where a shell run keeps its record; PROTOCOL.md still said the `spend` record was read. What is true now: the shared flag's help fits, and a law holds every carried program's help pages to eighty cells. senior-dev.md quotes the ceiling ending exactly (`senior-dev reached the run's dollar ceiling of $5.00: …`), says a model this person's services cannot serve is answered on the run's work model and the page names it, and says a shell run's record is under ~/.codeaf/v3/carried/senior-dev/. The programs page drops a step count the page no longer draws. PROTOCOL.md describes the 402 refusal, the model resolution, the thread key and the shell record, and says there is no spend record. Co-Authored-By: Claude Opus 5.5 --- docs/design/delegate/PROTOCOL.md | 26 +++++++++++++----- internal/delegate/builtin/builtin_test.go | 32 ++++++++++++++++++++++- internal/delegate/cli.go | 8 ++++-- internal/manual/chat/delegates.md | 7 ++--- internal/manual/chat/senior-dev.md | 17 +++++++++--- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index 0e41fa877..d220ea25d 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -47,9 +47,15 @@ For each run codeaf serves an OpenAI-style chat-completions API at `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; +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; + 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); @@ -57,7 +63,15 @@ one package codeaf's funnel law lets spell a model route. Every call: run's conversation log. The token dies with the run, so a grandchild that outlives its parent can no -longer spend. +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 @@ -68,9 +82,9 @@ longer spend. | `step` | once per finished action | `command` (one line, 200 bytes at most), `observation` (2048 bytes at most) | | `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. `spend` is still read until the model API meters -every call; after that it is redundant, because the API is the one source of -truth for money. +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. 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, diff --git a/internal/delegate/builtin/builtin_test.go b/internal/delegate/builtin/builtin_test.go index 7f2daff88..67df90072 100644 --- a/internal/delegate/builtin/builtin_test.go +++ b/internal/delegate/builtin/builtin_test.go @@ -1,6 +1,13 @@ package builtin -import "testing" +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. @@ -16,3 +23,26 @@ func TestEveryCarriedProgramIsWellDefined(t *testing.T) { 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/cli.go b/internal/delegate/cli.go index 94b33aeb4..ee1d3c13d 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -65,7 +65,7 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) 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 ceiling in dollars; codeaf refuses the call that would cross it") + 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) @@ -121,6 +121,10 @@ func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings) []s // 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] runs %s\n", program.Name, program.Default) @@ -129,7 +133,7 @@ func Help(program Delegate, out io.Writer) { } 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 ceiling in dollars; codeaf refuses the call that would cross it\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) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 4cd239a1b..81c014943 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -17,7 +17,8 @@ none of them runs on its own outside codeaf. Each is a command in the chat, `/ reached the run's dollar ceiling of $…`. **It runs alone.** While one is running, no other task can join its copy, and it cannot be started under another run. Both are refused with the folder that is busy: diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index eabbe5377..98b845b6a 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -79,7 +79,9 @@ Every model call senior-dev makes goes through codeaf, which serves each run its model API. So every call is priced like one of codeaf's own, shows in the conversation's total and in `/cost`, and is held to the run's dollar ceiling: **codeaf refuses the call that would cross it**, before it is made. A refused call ends senior-dev's turn; it runs -the project's build and tests on the tree it has, and ends there. +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. 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, @@ -88,7 +90,10 @@ submit. senior-dev picks its model call by call from its own list of open models, and avoids one for a while after it fails. `--high` replaces the list, and `--variant` sets the -reasoning effort every call asks for. +reasoning effort every call asks for. **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 conversation on the task page names the model that +answered. A call is never refused only because this machine does not know a model's id. ## senior-dev's flags — run, --variant, --in-place, --high, --max-cost @@ -119,7 +124,9 @@ A run ends in one of these ways, and the task's ending says which: 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; -- `senior-dev stopped on its own ceiling: …` — it crossed the dollar or time ceiling; +- `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`, no git repository); - `stopped by the run: …` — you, or the run it belonged to, stopped it; what follows is @@ -130,4 +137,6 @@ tests cannot even start there, the tree is put back to the last state whose buil tests could run, or to where it began. 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. +is kept in `delegate-stderr.log` in the task's record folder. A run started at a shell has +no task, so its record — that log, its conversation with codeaf and its stages — is kept +in a folder of its own under `~/.codeaf/v3/carried/senior-dev/`, one per run. From 8abbd11302c5218177aa727d32737e6f538a3c6d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:47:22 -0400 Subject: [PATCH 036/195] cli: senior-dev itself works a task the chat's way, through the run's worker, pinned by a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was true: real senior-dev was proven end to end only on the shell's road; the chat's road — the run's worker serving the model API, the chat's own line for the child, the task's spend rows, the ledger, the conversation log and the program record the page reads — was proven with a fake program. What is true now: TestSeniorDevWorksATaskAsTheChatsRunWorker runs the real senior-dev as a child of the test binary under the worker, with its own default model pool (the chat passes no --high) and a scripted model behind the real API, and holds every call to one spend row, one ledger row and one turn, the record to the run's ceiling, and the planted key to nowhere in its process. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/carried_seniordev_worker_test.go | 163 ++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 cmd/codeaf/carried_seniordev_worker_test.go diff --git a/cmd/codeaf/carried_seniordev_worker_test.go b/cmd/codeaf/carried_seniordev_worker_test.go new file mode 100644 index 000000000..abad38c48 --- /dev/null +++ b/cmd/codeaf/carried_seniordev_worker_test.go @@ -0,0 +1,163 @@ +//go:build !windows + +package main + +import ( + "context" + "encoding/json" + "math" + "os" + "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/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) + } + // 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") + } +} + +// 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) +} From be2a083d77b4693986ebbdf260fa9d2e1d4a36d3 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:49:05 -0400 Subject: [PATCH 037/195] perf: SIZE-BUDGET rises by what senior-dev weighs, and the bill already owed is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was true: SIZE-BUDGET was 54,600,000. senior-dev inside the binary puts the laptop's own build (darwin/arm64, furrow staged) at 56,493,874, over it. What is true now: the budget is 57,400,000 — the old figure plus the largest per-platform cost of the engine (2,775,360 on darwin/amd64), measured before and after on all four platforms and tabled in PERF.md. The same table shows that darwin/amd64 and linux/amd64 were already over the old budget before this change, even without their furrow artifacts; that is named in PERF.md and not folded into this reset, because the fix is agreeing which architecture the budget is measured on, which the CI size job already says. Co-Authored-By: Claude Opus 5.5 --- PERF.md | 28 ++++++++++++++++++++++++++++ SIZE-BUDGET | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/PERF.md b/PERF.md index e6b9c9bfb..eb38a0e67 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/SIZE-BUDGET b/SIZE-BUDGET index b480208c3..c1453c418 100644 --- a/SIZE-BUDGET +++ b/SIZE-BUDGET @@ -1 +1 @@ -54600000 +57400000 From 81b726cd565b9ba9bc5e041f1e87dfe0719653b5 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:49:39 -0400 Subject: [PATCH 038/195] delegate: a bare --help is the program's own page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was true: `codeaf senior-dev --help` fell through to the default command's parse and printed `run`'s flags, while the contract said a bare ask prints the program's page; only the word `help` did. What is true now: `help`, `-h`, `-help` and `--help` as the first word all print the program's page — what it is, every command, the shared flags — and `codeaf --help` prints that command's own flags. Co-Authored-By: Claude Opus 5.5 --- internal/delegate/cli.go | 14 +++++++++++--- internal/delegate/cli_test.go | 6 ++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index ee1d3c13d..4171fd030 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -49,9 +49,17 @@ func (inv *Invocation) Brief() string { return strings.TrimSpace(strings.Join(in // as ErrHelp. func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) { rest := line - if len(rest) > 0 && rest[0] == "help" { - Help(program, out) - return nil, ErrHelp + 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 { diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index 9b1ef520e..bc372052e 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -93,6 +93,12 @@ func TestParseWritesHelpAndSaysSo(t *testing.T) { 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, From 045a6f008114c8d264c169ca602c1057caf8288d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:50:03 -0400 Subject: [PATCH 039/195] delegate: the program's page says a bare brief is the same as its default command What was true: the usage line read "runs run" for senior-dev. What is true now: it reads "the same as run". Co-Authored-By: Claude Opus 5.5 --- internal/delegate/cli.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index 4171fd030..909d86835 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -135,7 +135,7 @@ func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings) []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] runs %s\n", program.Name, program.Default) + 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) } From 9c4076a985d9a5c00e18172cca6ac479d6969ed1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:20:58 -0400 Subject: [PATCH 040/195] delegate: a program describes itself to the model, and codeaf says which folder to hand it The model that hands work to a program was told its name and a paragraph codeaf wrote about senior-dev. Each program now carries a guide (delegate.Delegate.Guide: one paragraph, at most 400 bytes) that the hand-off page prints under its name. codeaf adds one rule of its own when a program that edits files is carried: it works in a copy of the task's folder and only that copy lands, so it is handed the repository the work belongs in, cloned first into a new folder on a branch at the named commit when the machine lacks it, and never briefed to work elsewhere. Written from a live run in which senior-dev was handed the benchmark's repository and cloned the real one into the person's projects folder by itself. propose_task's `via` now points at `ground`. The manual's senior-dev and delegates pages say which folder a program works in, and two probes hold them to it. The shipping test shape now carries the build's programs, as the chat door does, and the widest page weighs the program paragraph filled. Both prefix gates are over as a result: fixed 56,271 against 55,442, lean 47,719 against 47,055. The paragraph was already on the shipped page and went unweighed; the waiver is the owner's decision and lands separately. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/carried_child_test.go | 1 + docs/design/delegate/PROTOCOL.md | 24 +++++++-- internal/delegate/cli_test.go | 28 +++++++++++ internal/delegate/delegate.go | 31 ++++++++++++ internal/manual/chat/delegates.md | 20 +++++++- internal/manual/chat/senior-dev.md | 18 +++++++ internal/manual/chat_test.go | 2 + internal/seniordev/seniordev.go | 12 ++++- internal/session/delegate_door.go | 70 ++++++++++++++++++++++---- internal/session/delegate_door_test.go | 57 +++++++++++++++++++-- internal/session/prefixbudget_test.go | 10 ++++ internal/session/prompt_belt_test.go | 7 +++ internal/session/task.go | 2 +- 13 files changed, 260 insertions(+), 22 deletions(-) diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go index c35ba8571..2c6a7fc86 100644 --- a/cmd/codeaf/carried_child_test.go +++ b/cmd/codeaf/carried_child_test.go @@ -37,6 +37,7 @@ const fakeCarried = "fake-carried" 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 { diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index d220ea25d..f3c196aee 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -11,11 +11,26 @@ program's own name.* 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, what it lands (`tree` or `text`), its -commands with their own flags, its default command, and the name of its page in -the chat's manual. There is nothing to install. A program not in the list does +`codeaf `), a one-line summary, a guide, what it lands (`tree` or `text`), +its commands with their own flags, its default command, 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, +what its brief must hold and what it needs of its folder. 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.** The copy a program that edits files works in, the rule that only that +copy lands, 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. @@ -28,7 +43,8 @@ codeaf --json --dir [--max-cost USD] [--max-hours H ``` - **From the chat,** the engine's run (`internal/run`'s `DelegateWorker`) starts - that line in the run's working copy. + that line in the run's working copy, which is cut from 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 and starts the same child. diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index bc372052e..339ed929c 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -17,6 +17,7 @@ func testProgram(body Body) Delegate { } 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 { @@ -174,3 +175,30 @@ func TestValidateRefusesADefinitionThatCouldNotRun(t *testing.T) { 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) + } +} diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index c96c1d25e..c89310aeb 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -57,6 +57,23 @@ type Delegate struct { // 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 copy a program works in, what lands from it 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 @@ -101,6 +118,12 @@ type Body func(ctx context.Context, host Host, args []string) error // type without quoting. var nameShape = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) +// 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. @@ -117,6 +140,14 @@ func (d Delegate) Validate() error { 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: diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 81c014943..83fb47605 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -35,13 +35,29 @@ That is `/task` with the worker chosen. A run starts at once in a copy of your f turn goes on, and the row appears on the rail. The model can choose one as well. `propose_task` takes `via` naming the program, and the -card you answer says which program the work is going to. The model is told only the names -your build carries. +card you answer says which program the work is going to. 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. +## Which folder a program works in — a repository I have not cloned, it edited files outside its copy + +A program that edits code works in a copy of one folder: the one this conversation works +in, or the one the task names. **Only what it changes inside that copy lands.** Anything it +changed anywhere else is not part of the task, and the task's ending does not see it. + +So when the work belongs in a repository that is not on this machine (a benchmark task +that names a repository and a commit, or a project you have not cloned), the model clones +it first, into a new folder, onto a branch at the commit the work names, and hands the +program that folder. It is told never to write a brief that sends the program to work in +another folder, because nothing the program did there could land. + +At a shell nobody does that for you: clone the repository, 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, why it was refused **It cannot ask you anything.** Nobody is at its keyboard. Write the brief so that diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 98b845b6a..f1b57ce08 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -36,6 +36,24 @@ before the brief, and `--` ends them: `codeaf senior-dev run --variant high -- r 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 a copy of the folder it is handed, and only what it changes in that +copy lands. 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, onto a branch at that commit, and hands +senior-dev that folder. A benchmark task works this way: senior-dev gets a copy of the +project's own repository and not of the benchmark's, so the benchmark's files, its +reference solution among them, are not in its copy. + +At a shell, clone the repository yourself, then run `codeaf senior-dev` inside it or pass +the folder with `--dir`. + +A brief that tells senior-dev to make a checkout of its own somewhere else does not work. +It has no copy of that folder, so nothing it does there lands, and it would be editing a +folder of yours directly. + ## What senior-dev cannot do — it cannot ask you anything, no step cap, no Windows **It cannot ask you anything.** Nobody is at its keyboard: a question its model tries to diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 6c0c50cbe..cb34596d5 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -925,6 +925,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, + {"which folder does a delegate work in", "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 diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 650d2e153..c5c6fdc4e 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -35,8 +35,16 @@ import ( // Program is senior-dev as codeaf carries it. var Program = delegate.Delegate{ - Name: "senior-dev", - Summary: "an autonomous agent for one large, well-specified code change", + Name: "senior-dev", + Summary: "an autonomous agent for one large, well-specified code change", + // What the chat's model reads before it names senior-dev in `via`. The + // brief is copied word for word into .senior-dev/spec.md and is all it ever + // knows of the work, so the guide says what that brief must settle; and + // its recorder is git unless it runs --in-place, which the chat's line + // never passes, so the guide says what its folder must be. + Guide: "For one large code change worth an hour: a rewrite across a package, a migration, " + + "a feature with its tests. Its brief names the files and commands, what done means and " + + "how to check it, and what must not change. Its folder must be a git repository with a commit.", Lands: delegate.LandsTree, Default: "run", Page: "senior-dev", diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index f0d020a88..6354ccf17 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -80,22 +80,74 @@ func (c Config) mayDelegate() bool { // delegateFact is the hand-off page's one paragraph about these programs. It is // rendered only where [Config.mayDelegate] holds, and its `fill` writes the -// names in, so the model is told the words it can put in `via` and never a name -// this build does not carry. +// programs in — each one's name and its own guide — so the model is told the +// words it can put in `via` and never a name this build does not carry. +// +// THE PARAGRAPH SAYS WHAT CODEAF DOES, AND EACH PROGRAM SAYS WHAT IT IS. What +// a program is for, what its brief must hold and what it needs of its folder +// are the program's own [delegate.Delegate.Guide], printed under its name, so +// nothing here names senior-dev. What is true of every program that edits +// files — the copy it works in, what lands, and so which folder it must be +// handed — is codeaf's mechanics, and is said here once ([delegateFolderRule]). +// That nobody can be asked anything is `propose_task`'s own `brief` +// description, and that small work is never handed off is this section's +// own; neither is said a second time here. var delegateFact = beltFact{ tools: []string{"propose_task"}, holds: Config.mayDelegate, - present: "AND WORK BIG ENOUGH TO WANT ITS OWN AGENT FOR AN HOUR — one large change, specified\n" + - "well enough that nobody will be asked anything — can go to a PROGRAM BUILT INTO CODEAF\n" + - "that does the whole task on its own, in a copy of the folder, under the same dollar and\n" + - "time limits, landed when it ends. Name it in `propose_task`'s `via`. The programs here\n" + - "are: %s. It cannot ask the person anything, so its brief has to settle everything;\n" + - "a change you would do in a few steps is never worth one.", + present: "AND ONE LARGE TASK CAN GO TO A PROGRAM BUILT INTO CODEAF, named in `propose_task`'s\n" + + "`via`, which does the whole of it alone.%s The programs here:\n%s", fill: func(config Config, text string) string { - return fmt.Sprintf(text, strings.Join(config.delegateNames(), ", ")) + rule := "" + if config.carriesTreeProgram() { + rule = delegateFolderRule + } + return fmt.Sprintf(text, rule, config.delegateGuides()) }, } +// delegateFolderRule is codeaf's one sentence about the folder a program that +// edits files is handed, and it is printed only when the build carries one. +// +// IT EXISTS BECAUSE A MODEL SENT SENIOR-DEV TO THE WRONG REPOSITORY. Asked to +// solve a benchmark task whose code lived in a repository not on the machine, +// the conversation handed senior-dev the one repository it knew — the +// benchmark's, which holds the task's reference solution beside its statement — +// and wrote a brief telling it to make a checkout of the real one. senior-dev +// cloned it into the person's own projects folder and edited it there, outside +// the copy codeaf lands from, and the task ended saying it had changed nothing. +// The copy is cut from the folder the proposal names, so the folder is the one +// thing the model has to get right, and fetching a repository that is not here +// is its job, done before the proposal. +const delegateFolderRule = "\nIt works in a copy of the task's folder and only that copy lands, so hand it the\n" + + "repository the work belongs in: clone one this machine lacks into a new folder, on a\n" + + "branch at the commit the work names, and pass it as `ground`. Never brief it to work\n" + + "elsewhere." + +// carriesTreeProgram says whether any program this conversation can hand work +// to edits files, which is when [delegateFolderRule] is true of it. +func (c Config) carriesTreeProgram() bool { + for _, program := range c.Delegates { + if program.LandsTree() { + return true + } + } + return false +} + +// delegateGuides is the programs as the hand-off paragraph lists them: one item +// each, sorted by name, the name as `via` takes it and then the program's own +// guide. +func (c Config) delegateGuides() string { + programs := append([]delegate.Delegate(nil), c.Delegates...) + sort.Slice(programs, func(i, j int) bool { return programs[i].Name < programs[j].Name }) + items := make([]string, 0, len(programs)) + for _, program := range programs { + items = append(items, "- `"+program.Name+"`: "+strings.TrimSpace(program.Guide)) + } + return strings.Join(items, "\n") +} + // DelegateUnknownError is the refusal for a `via` or a command naming no // program this build carries. It names the ones it does, sorted, so the next // attempt has the words in front of it. diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 307eaf2e7..56b88af38 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -17,6 +17,7 @@ import ( func testPrograms(name string) []delegate.Delegate { return []delegate.Delegate{{ Name: name, Summary: "a fake program", Default: "run", Page: name, + Guide: "For work a fake does, with a brief that names the fake's files.", Commands: []delegate.Command{{Name: "run", Bind: func(*flag.FlagSet) delegate.Body { return func(context.Context, delegate.Host, []string) error { return nil } }}}, @@ -160,13 +161,13 @@ func TestNothingJoinsADelegatedRunAndADelegateJoinsNothing(t *testing.T) { } // The prompt names the programs this build carries, and only where there are -// some: a conversation with one reads its name under the hand-off facts, and -// one without reads nothing about them at all. +// some: a conversation with one reads its name and its own guide under the +// hand-off facts, and one without reads nothing about them at all. func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { with := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} page := promptWithBeltFacts(with) - if !strings.Contains(page, "The programs here\nare: fake.") { - t.Fatalf("the page does not name the delegate:\n%s", page) + if !strings.Contains(page, "The programs here:\n- `fake`: "+with.Delegates[0].Guide) { + t.Fatalf("the page does not list the delegate with its own guide:\n%s", page) } if !strings.Contains(page, "`via`") { t.Fatal("the page does not say how a delegate is named on a proposal") @@ -180,3 +181,51 @@ func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { t.Fatal("a task node is told it may delegate") } } + +// THE FOLDER A PROGRAM IS HANDED IS CODEAF'S TO EXPLAIN, and it is explained +// only where it is true. A program that edits files works in a copy of the +// proposal's folder and lands only from there, so the page tells the model to +// hand it the repository the work belongs in — cloned first when this machine +// lacks it — and never to brief it to work somewhere else: the failure this +// sentence was written from is senior-dev cloning a repository into the +// person's projects folder because its brief said to. A program that only +// answers works in place and lands nothing, so a build carrying only those is +// told nothing about copies. +func TestTheFolderRuleIsSaidWhereAProgramEditsFilesAndOnlyThere(t *testing.T) { + tree := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} + page := promptWithBeltFacts(tree) + for _, want := range []string{ + "It works in a copy of the task's folder and only that copy lands", + "clone one this machine lacks into a new folder", + "branch at the commit the work names, and pass it as `ground`.", + "Never brief it to work\nelsewhere.", + } { + if !strings.Contains(page, want) { + t.Fatalf("a build carrying a program that edits files is not told %q:\n%s", want, page) + } + } + textOnly := testPrograms("reader") + textOnly[0].Lands = delegate.LandsText + page = promptWithBeltFacts(Config{Workspace: t.TempDir(), Delegates: textOnly}) + if !strings.Contains(page, "- `reader`: ") { + t.Fatalf("the program that answers is not listed:\n%s", page) + } + if strings.Contains(page, "copy of the task's folder") { + t.Fatalf("a build whose only program works in place is told about copies:\n%s", page) + } +} + +// THE PAGE SAYS NOTHING ABOUT A PROGRAM THAT THE PROGRAM DOES NOT SAY. Two +// programs are listed in name order, each with its own guide and nobody +// else's, so a second program joins the page by bringing its guide and never +// by an edit to the conversation's words. +func TestEachProgramIsListedWithItsOwnGuideInNameOrder(t *testing.T) { + programs := append(testPrograms("zeta"), testPrograms("alpha")...) + programs[0].Guide = "For the zeta work." + programs[1].Guide = "For the alpha work." + page := promptWithBeltFacts(Config{Workspace: t.TempDir(), Delegates: programs}) + want := "The programs here:\n- `alpha`: For the alpha work.\n- `zeta`: For the zeta work." + if !strings.Contains(page, want) { + t.Fatalf("the page does not list both programs with their own guides in order; want %q in:\n%s", want, page) + } +} diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index fe68efceb..6f7352f16 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -43,6 +43,7 @@ import ( "time" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" "github.com/Agent-Field/codeaf/internal/exec/bare" ) @@ -606,6 +607,15 @@ func widestPage() string { if len(fact.shelved) > len(widest) { widest = fact.shelved } + // AND A FILLED FACT IS WEIGHED FILLED. The programs paragraph is a + // frame whose body is each carried program's own guide + // (delegate_door.go), and weighing the frame alone once let a + // paragraph of a few hundred bytes ride every request unseen. It is + // filled with the programs this build carries, as the chat door + // hands them over. + if fact.fill != nil { + widest = fact.fill(Config{Delegates: builtin.All()}, widest) + } lines = append(lines, widest) } page = strings.Replace(page, section.token, strings.Join(lines, section.join), 1) diff --git a/internal/session/prompt_belt_test.go b/internal/session/prompt_belt_test.go index 5a1965e9f..27d887347 100644 --- a/internal/session/prompt_belt_test.go +++ b/internal/session/prompt_belt_test.go @@ -30,6 +30,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" configpkg "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/store" "github.com/Agent-Field/codeaf/internal/subharness" @@ -86,6 +87,12 @@ func buildShippedConversation(t *testing.T, config *Config) { config.standingItems = &fakeStanding{} config.Subharnesses = registryWith(t, &fakeGeneralist{}, &fakeRunner{manifest: theProgram()}) config.HarnessCards = true + // AND THE PROGRAMS THE BUILD CARRIES, as the chat door hands them over + // (cmd/codeaf's chatv3.go: `Delegates: v3Delegates()`). Their paragraph — + // each program's own guide, and codeaf's rule about the folder one is handed + // — rides every request of the shipping conversation, so a shape that left + // them off would weigh, and lint, a page nobody is sent. + config.Delegates = builtin.All() } // beltShapes is every shape, and each is built the way its own door builds it — diff --git a/internal/session/task.go b/internal/session/task.go index 2e009c309..482e6e912 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -192,7 +192,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + - `"via":{"type":"string","description":"Optional: the name of a program built into codeaf that does the whole task on its own, for one large, well-specified change. Only a name your instructions list; it cannot ask the person anything"},` + + `"via":{"type":"string","description":"Optional: a program your instructions list, to do the whole task alone in a copy of ground (or of this conversation's folder)"},` + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + `"no_progress":{"type":"integer","description":"Optional. Tool calls in a row that may add nothing before it is stopped as stuck (default ` + strconv.Itoa(taskNoProgress) + `). Raise it for work that must read a great deal first"}` + `},"required":["title","summary","brief","deliverable","acceptance"],"additionalProperties":false}` From 2ad0a12f2926dec2bab6b62729cb7b8832add160 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:49:38 -0400 Subject: [PATCH 041/195] perf: the prompt caps rise by what the programs paragraph costs, on the owner's call The hand-off page's programs paragraph (each program's guide and codeaf's folder rule) rides every request of the shipping conversation, and the budget now weighs it. Fixed is 56,271 against 55,442 and lean is 47,719 against 47,055; both waivers rise by exactly that (fixed 7,442 to 8,271, lean 15,555 to 16,219) and sit on the measurement. Paying it back out of other prompt text was offered and declined, and the ledger entry says so. Co-Authored-By: Claude Opus 5.5 --- internal/session/prefixbudget_test.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 6f7352f16..d5330ad37 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -457,9 +457,23 @@ const fixedPrefixTarget = 48_000 // the widest page pays for it: fixed is 55,442, over its 55,280 by 162, so that // waiver rises by 162. The lean shape's page never renders the attribution row // and lean is 47,055 still, exactly on its measurement. +// +// 2026-09-23, the programs codeaf carries, and the owner's call by name. The +// hand-off page's programs paragraph (delegate_door.go) is each carried +// program's own guide, printed under its name, and codeaf's rule about the +// folder a program that edits files must be handed; `propose_task`'s `via` +// field points at `ground`. The paragraph was already on the shipped page +// and was never weighed: the shipping shape carried no programs, and +// [widestPage] weighed the frame without its fill. Both are fixed in the same +// change, and the true cost shows on both arms: fixed is 56,271, over its +// 55,442 by 829, and lean is 47,719, over its 47,055 by 664. Both waivers rise +// by exactly that and sit on the measurement. Paying it back out of other +// prompt text was offered and declined: the paragraph is how the conversation +// learns what senior-dev is for and which folder to hand it, and cutting +// other lanes' wording to make room was the riskier edit days before a ship. const ( - fixedPrefixWaiver = 7_442 - leanPrefixWaiver = 15_555 + fixedPrefixWaiver = 8_271 + leanPrefixWaiver = 16_219 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane From d4ad04c262f34e4d2f63bbd1f0f0255f189fd50c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:10:04 -0400 Subject: [PATCH 042/195] tui3: every door into a program's task opens its conversation, not a blank room The card in the conversation, a transcript link, the task strip, the home panel and the sessions place all open a task through openRoomFor, which opened a room. A task handed to senior-dev has no worker transcript, so its room said "nothing on this page yet" while the program made call after call; only the rail's own door asked the store for the program's page. A held row that names its program now opens the stored page the rail's way, with the room as the answer only when the store has none. Co-Authored-By: Claude Opus 5.5 --- internal/tui3/room.go | 37 +++++++++++++++++++++++ internal/tui3/taskconversation_test.go | 42 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 560fb4e2f..c9fee97b7 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -1101,14 +1101,51 @@ func (a *app) roomStandingOn(node *taskNode) bool { // openRoomFor toggles compact task controls and transcript links within the same // conversation. Sidebar rows use openRailRoom so a repeated click stays inside. // A guest with the same task number belongs to a different conversation. +// +// A PROGRAM'S TASK OPENS ITS CONVERSATION AND NEVER A ROOM. A task handed to a +// program codeaf carries (senior-dev) has no worker transcript: the program +// talks to codeaf through the run's model API, and what it said is on the +// task's stored page ([app.taskConversation]). A room on it is a blank page +// saying it fills in as the task works while the program makes call after +// call, which is how a person watching senior-dev saw nothing for five +// minutes. The rail's own door already asked the store first +// ([app.openRailRoom]); every other door — the card in the conversation, a +// transcript link, the task strip, the home panel, the sessions place — came +// through here and went straight to the room. So a row the surface already +// holds as a program's opens its page the rail's way, with the room as the +// answer only when the store has no page for it. func (a *app) openRoomFor(id uint64, title string) { if a.room != nil && !a.roomIsGuest() && a.room.id == id { a.closeRoom() return } + if a.programTask(id) { + a.roomPump = tea.Batch(a.roomPump, a.openRailPlan(strconv.FormatUint(id, 10), func() tea.Cmd { + a.openRoom(id, title) + return a.takeRoomPump() + })) + return + } a.openRoom(id, title) } +// programTask reports whether the surface holds this conversation's task as a +// program's run: its held row names the program. It reads only what is held, +// never the store, because it is asked on the loop at a key or a click. +func (a *app) programTask(id uint64) bool { + rows, ok := a.heldPlanRows() + if !ok { + return false + } + want := strconv.FormatUint(id, 10) + for _, row := range rows { + if row.ID == want { + return strings.TrimSpace(row.Program) != "" + } + } + return false +} + // openRailRoom makes list selection idempotent. Repeated clicks must not close // the page or replace its draft, scroll position and live subscription. // diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index eea6d2147..0a125e50c 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -481,3 +481,45 @@ func TestACallsArgumentsAreReadForWhatTheCallWasAbout(t *testing.T) { } } } + +// EVERY DOOR INTO A PROGRAM'S TASK OPENS ITS CONVERSATION. The card in the +// conversation, a transcript link, the task strip and the home panel all come +// through [app.openRoomFor], which used to open a room: a blank page, because a +// program has no worker transcript, while senior-dev made call after call. A +// held row that names its program now opens the stored page the rail's way. +func TestEveryDoorIntoAProgramsTaskOpensItsConversation(t *testing.T) { + row := programRow() + row.ID = "7" + a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) + a.width, a.height = 120, 28 + a.openRoomFor(7, row.Title) + if a.room != nil { + t.Fatal("a program's task opened a room") + } + cmd := a.takeRoomPump() + if cmd == nil { + t.Fatal("nothing asked the store for the program's page") + } + drive(t, a, cmd()) + if !a.taskSheet.planOn || !a.taskPlanIsProgram() { + t.Fatalf("the door did not open the program's page: plan %v, program %+v", a.taskSheet.planOn, a.taskSheet.plan.Program) + } + if lines := programPageLines(a); !saidBy(lines, "senior-dev", "rewrite the auth middleware") { + t.Fatalf("the page does not show the program's conversation:\n%s", strings.Join(lines, "\n")) + } +} + +// AND A TASK THAT IS NOT A PROGRAM'S STILL OPENS ITS ROOM, at once and without +// asking the store: the redirect is for a program's run and no other. +func TestADoorIntoAnOrdinaryTaskStillOpensItsRoom(t *testing.T) { + row := programRow() + row.ID, row.Program, row.Stage = "7", "", "" + a, _ := planAppWith(t, []session.PlanTaskRow{row}, nil) + if a.programTask(7) { + t.Fatal("an ordinary task was taken for a program's") + } + a.openRoomFor(7, row.Title) + if a.railPlanPending.id != "" { + t.Fatal("an ordinary task's door asked the store for a page instead of opening its room") + } +} From 2f72e15fb6c6e9be7a4961040dc8de8cdc4e3482 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:40:30 -0400 Subject: [PATCH 043/195] session: a program's task page is read with the belt switch off `/senior-dev` and `propose_task`'s `via` take the run road whatever CODEAF_TASK_BELT says, and the run's store, its conversation with codeaf and its stage are written either way. The task pages read that store only through planIfArmed, which answers nil unless the switch is on, so on the default belt the rail drew no stage and every door into the task opened a room that said it would fill in and never did. Seen live twice. The page readers now go through planForPages: the armed plan under the switch, and otherwise the store the session folder already holds, read and never armed, so the switch's other roads stay closed to ordinary tasks. A test starts a program's run with the switch off and reads its row and page; it fails on the old readers with no row at all. Co-Authored-By: Claude Opus 5.5 --- internal/session/delegate_door_test.go | 43 ++++++++++++++++++++++++++ internal/session/plandb_plan.go | 33 ++++++++++++++++++++ internal/session/plandb_tasks.go | 4 +-- internal/session/task_run.go | 3 ++ 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 56b88af38..6804efada 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -229,3 +229,46 @@ func TestEachProgramIsListedWithItsOwnGuideInNameOrder(t *testing.T) { t.Fatalf("the page does not list both programs with their own guides in order; want %q in:\n%s", want, page) } } + +// A PROGRAM'S PAGE IS READ WITH THE SWITCH OFF. `/senior-dev` takes the run +// road whatever CODEAF_TASK_BELT says, and its store is written either way, so +// the pages that read that store must answer either way: with the readers +// gated on the switch, a person on the default belt clicked into senior-dev's +// task and got a room that said it would fill in, for the whole run. +func TestAProgramsRunIsReadableWithTheSwitchOff(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "") + if bashBeltAsked() { + t.Fatal("the switch is still on, so this test would prove nothing") + } + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "add a file"); err != nil { + t.Fatal(err) + } + <-double.entered + var program PlanTaskRow + for _, row := range agent.PlanTasks() { + if row.Program == "fake" { + program = row + } + } + if program.ID == "" { + t.Fatalf("the program's run has no row with the switch off: %+v", agent.PlanTasks()) + } + page, found := agent.PlanTaskPage(program.ID) + if !found || page.Program == nil || page.Program.Name != "fake" { + t.Fatalf("the program's page is not readable with the switch off: found %v, program %+v", found, page.Program) + } + // AND NOTHING WAS ARMED: the switch's own roads stay closed to every + // ordinary task of this conversation. + if g := agent.graph(); g != nil && g.planIfArmed() != nil { + t.Fatal("reading the program's page armed the plan for the switch's other roads") + } + endBeltRun(t, agent, double) +} diff --git a/internal/session/plandb_plan.go b/internal/session/plandb_plan.go index afc56c3ed..d2e36dbdd 100644 --- a/internal/session/plandb_plan.go +++ b/internal/session/plandb_plan.go @@ -103,6 +103,39 @@ func (g *TaskGraph) planIfArmed() *planState { return g.plan } +// planForPages is the plan the surface's task pages are read from: the armed +// plan under the switch, and otherwise the store this conversation's session +// folder already holds, read and never armed. +// +// A PROGRAM'S RUN WRITES ITS STORE WHATEVER THE SWITCH SAYS, and its page was +// read only under it. `/senior-dev` and `propose_task`'s `via` take the run +// road with the switch off (task.go's run-road gate), so the run's rows, its +// conversation with codeaf and its stage were all on disk while every reader +// here answered nil: the rail drew no stage, and every door into the task +// opened a room that said it would fill in and never did. Arming the plan +// instead would hand the switch's other roads — the worker's bash prefix, the +// seed, the pulse — to every ordinary task of a conversation that once ran a +// program, so the readers get a state of their own and nothing else moves. +// No store is ever made here; a conversation that never ran one answers nil. +func (g *TaskGraph) planForPages() *planState { + if plan := g.planIfArmed(); plan != nil { + return plan + } + path := g.planPath() + if path == "" { + return nil + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return nil + } + g.planMu.Lock() + defer g.planMu.Unlock() + if g.pagePlan == nil || g.pagePlan.path != path { + g.pagePlan = &planState{path: path, chat: g.planChat()} + } + return g.pagePlan +} + // planPath resolves where this run's store lives: the session folder, or — // for the legacy flat layout, whose Place is zero — the workspace's .codeaf // folder. The CLI finds the same file by walking up from the worker's own diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index 23bf6ab09..56659c3de 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -343,7 +343,7 @@ func (a *Agent) openPlanReadHandles() ([]*plandb.Store, *planState, func()) { if g == nil { return nil, nil, func() {} } - plan := g.planIfArmed() + plan := g.planForPages() if plan == nil { return nil, nil, func() {} } @@ -422,7 +422,7 @@ func (a *Agent) openPlanHandle() (*plandb.Store, *planState, func()) { if g == nil { return nil, nil, func() {} } - plan := g.planIfArmed() + plan := g.planForPages() if plan == nil { return nil, nil, func() {} } diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 120eb6513..2e4b17e23 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -1018,6 +1018,9 @@ type TaskGraph struct { // other way round. plan *planState planMu sync.Mutex + // pagePlan is the store as the task pages read it when the switch is off + // ([TaskGraph.planForPages]); it is never the plan any worker runs on. + pagePlan *planState // order is admission order, and it is what makes the frontier // DETERMINISTIC: with a cap in play, which of two ready nodes starts first // must not be Go's map iteration. From 2e9630c41f0bef16a5968b04d84ee894731f95f5 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:13:50 -0400 Subject: [PATCH 044/195] tui3: an approval card stands, and the work above it stays folded A turn holding a task proposal, a sign-in or a standing card derived no fold at all, so the moment a turn ended on `/senior-dev`'s approval card every thought and call above it unfolded at once. The card still stands, and no chip may cover it; the turn is now cut at each ask instead of kept whole, so the settled work before the card folds behind a chip that ends at the card, and the stretch after the last ask folds by the ordinary rule up to its answer. Running, stopped and otherwise blocked turns are unchanged. Co-Authored-By: Claude Opus 5.5 --- internal/tui3/settleboundary_test.go | 19 +++++---- internal/tui3/workfold.go | 58 +++++++++++++++++++++++++++- internal/tui3/workfold_test.go | 43 +++++++++++++++++++++ 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/internal/tui3/settleboundary_test.go b/internal/tui3/settleboundary_test.go index 9835bf16e..9ec1ebd4e 100644 --- a/internal/tui3/settleboundary_test.go +++ b/internal/tui3/settleboundary_test.go @@ -140,18 +140,19 @@ func TestAnEndOfTurnNoteIsNotWorkTheAnswerWaitedFor(t *testing.T) { } // THE SHAPE MEASURED AGAINST A REAL MODEL (#178): the turn carries a task -// proposal, which blocks the fold (workfold.go's `blocked`), so the forward walk -// is the only classifier — and the turn's own `⟲ … cached` line is then the -// entry it finds after the answer. On this branch's parent that answer was drawn -// plain and indented, headings, bold and table pipes and all. +// proposal, and the turn's own `⟲ … cached` line is the entry after the answer. +// On this branch's parent that answer was drawn plain and indented, headings, +// bold and table pipes and all. The proposal used to block the whole turn's +// fold; it now stands between two folds (workfold.go's asks), and the answer +// must still be drawn as an answer either way. func TestATurnWhoseFoldIsBlockedKeepsItsAnswerRendered(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) a.width, a.height = 80, 40 a.entries = []entry{ {kind: entryUser, text: "propose a task, then answer in markdown", turn: 1}, {kind: entryThinking, text: "which task", turn: 1, settled: true}, - // The card is what blocks the fold, and blocking it is correct — a - // decision may never disappear into a chip (workfold.go). + // The card stands: a decision may never disappear into a chip + // (workfold.go). {kind: entryTask, text: "Summarise this chat", turn: 1}, {kind: entryTool, tool: "propose_task", text: "Summarise this chat", turn: 1, status: toolOK}, {kind: entryAssistant, text: boundaryAnswer, turn: 1, settled: true}, @@ -160,8 +161,10 @@ func TestATurnWhoseFoldIsBlockedKeepsItsAnswerRendered(t *testing.T) { a.touch() rows(a) - if len(a.deckFolds(a.conversation())) != 0 { - t.Fatal("the proposal did not block the fold, so this is no longer the measured shape") + for _, f := range a.deckFolds(a.conversation()) { + if f.start <= 2 && 2 < f.answer { + t.Fatalf("the proposal was folded away: %+v", f) + } } if a.entries[4].demoted { t.Fatal("the answer of a turn that cannot fold was demoted by its own cost line") diff --git a/internal/tui3/workfold.go b/internal/tui3/workfold.go index 35676442f..4e86a5f53 100644 --- a/internal/tui3/workfold.go +++ b/internal/tui3/workfold.go @@ -249,6 +249,7 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { // machine the router refuses — and a chip that hid it left them with a // pin that disappeared and no sentence anywhere saying why. blocked, stopped := false, false + var asks []int for i := lo; i < hi; i++ { if es[i].kind == entryAssistant && strings.TrimSpace(es[i].text) != "" { answer = i @@ -256,8 +257,10 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { if es[i].cut { stopped = true } - if es[i].kind == entryTask || es[i].kind == entryConnect || es[i].kind == entryStanding || - (es[i].kind == entryNote && (es[i].told || strings.HasPrefix(es[i].text, "cancel"))) { + if es[i].kind == entryTask || es[i].kind == entryConnect || es[i].kind == entryStanding { + asks = append(asks, i) + } + if es[i].kind == entryNote && (es[i].told || strings.HasPrefix(es[i].text, "cancel")) { blocked = true } // A SEAM IS NEVER FOLDED AWAY. A chip hides the machinery between a @@ -270,6 +273,37 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { blocked = true } } + // AN ASK STANDS, AND THE WORK BEFORE IT STILL FOLDS. A task proposal, a + // sign-in or a standing card is a thing the work could not decide alone, + // so no chip may cover it. It used to keep the WHOLE turn open instead: + // the moment a turn ended on `/senior-dev`'s approval card, every thought + // and call above the card unfolded at once, a screenful of machinery + // arriving exactly when the person had one question to answer. So a turn + // with asks in it is cut at each of them: the settled work between two + // asks folds behind a chip that ends at the ask, the ask stands, and the + // stretch after the last ask folds by the ordinary rule, up to its answer. + // A running turn, a stopped one and a blocked one are unchanged below. + if len(asks) > 0 && !blocked && !stopped && (runningTurn == 0 || es[lo].turn != runningTurn) { + from := lo + for _, at := range asks { + if askSegmentSettled(es, from, at) { + f := workfold{key: es[lo].turn, turn: es[lo].turn, start: -1, answer: at} + if countWork(es, from, at, &f); f.start >= 0 { + out[f.start] = f + } + } + from = at + 1 + } + if answer >= from && es[answer].settled { + f := workfold{key: es[lo].turn, turn: es[lo].turn, start: -1, answer: answer} + if countWork(es, from, answer, &f); f.start >= 0 { + out[f.start] = f + } + } + lo = hi + continue + } + blocked = blocked || len(asks) > 0 // THE END OF WHAT THE CHIP SWALLOWS. An ordinary fold stops at the answer // and leaves it standing; a stopped turn's fold runs to the end of the // group, because there is nothing in it that was said TO the person. @@ -325,6 +359,26 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { return out } +// askSegmentSettled reports whether the rows before an ask are settled work a +// chip may cover: no prose still streaming, no call still running or waiting on +// the person, and no call that failed, because only failure speaks here. +func askSegmentSettled(es []entry, from, to int) bool { + for i := from; i < to; i++ { + e := &es[i] + switch e.kind { + case entryAssistant: + if e.provisional && !e.settled { + return false + } + case entryTool: + if e.status != toolOK { + return false + } + } + } + return true +} + // Only a settled tail owned by a confirmed response may fold without a later // answer. Unknown work, live reasoning, failed tools and new responses cannot. func confirmedReasoningTail(es []entry) bool { diff --git a/internal/tui3/workfold_test.go b/internal/tui3/workfold_test.go index 08277d8bf..6490b766d 100644 --- a/internal/tui3/workfold_test.go +++ b/internal/tui3/workfold_test.go @@ -214,3 +214,46 @@ func TestWorkfoldNeverHidesNewsAboutAPersonsOwnRow(t *testing.T) { t.Fatalf("an ordinary note stopped the chip forming at all: %#v", got) } } + +// AN APPROVAL CARD STANDS, AND THE WORK ABOVE IT STAYS FOLDED. A turn that ended +// on `/senior-dev`'s proposal card used to derive no fold at all, so every +// thought and call of the turn unfolded the moment the card arrived: a +// screenful of machinery exactly when the person had one question to answer. +// The work before the card folds behind a chip that stops at the card; the +// card itself and the answer after it are drawn. +func TestAnApprovalCardDoesNotUnfoldTheWorkAboveIt(t *testing.T) { + base := time.Unix(100, 0) + es := []entry{ + {kind: entryUser, text: "solve it with senior-dev", turn: 1}, + {kind: entryThinking, text: "reading the task", turn: 1, open: true, settled: true, began: base, ended: base.Add(4 * time.Second)}, + {kind: entryAssistant, text: "I found the task; reading its contract.", turn: 1, settled: true}, + {kind: entryTool, tool: "read", turn: 1, status: toolOK, began: base.Add(4 * time.Second), ended: base.Add(5 * time.Second)}, + {kind: entryThinking, text: "writing the brief", turn: 1, open: true, settled: true, began: base.Add(5 * time.Second), ended: base.Add(9 * time.Second)}, + {kind: entryTool, tool: "propose_task", turn: 1, status: toolOK}, + {kind: entryTask, text: "Solve true-myth", turn: 1}, + {kind: entryAssistant, text: "senior-dev will take it once you approve.", turn: 1, settled: true}, + } + folds := deriveWorkfolds(es, 0) + covered := map[int]bool{} + for _, f := range folds { + for i := f.start; i < f.answer; i++ { + covered[i] = true + } + } + for _, i := range []int{1, 3, 4, 5} { + if !covered[i] { + t.Fatalf("row %d (kind %d) above the card is not folded: %+v", i, es[i].kind, folds) + } + } + if covered[6] { + t.Fatal("the approval card was folded away") + } + if covered[7] { + t.Fatal("the answer after the card was folded away") + } + // AND WHILE THE TURN IS STILL RUNNING nothing here folds it: the live + // policy owns a running turn (livesteps.go). + if got := deriveWorkfolds(es, 1); len(got) != 0 { + t.Fatalf("a running turn derived settled folds: %+v", got) + } +} From 301982ec3c64a5603d9ed4311514c91699455815 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:19:35 -0400 Subject: [PATCH 045/195] tui3: a program run's tab shows its conversation, for the run still working The run's tab drew the whole tasks place (every conversation on the machine) with the first run's notes under it, and was named after the first row. It only appeared under the belt switch until senior-dev's runs became readable with it off, and then it appeared for every senior-dev task: with two in one conversation it opened on the one that had landed and showed a hundred conversations instead of the program. A program's tab now draws the page the rail opens, under the tab strip, and the tab is about the first run still working. Co-Authored-By: Claude Opus 5.5 --- internal/tui3/taskconversation_test.go | 40 ++++++++++++++++++++++++++ internal/tui3/worktab.go | 30 +++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index 0a125e50c..189ff6d44 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -523,3 +523,43 @@ func TestADoorIntoAnOrdinaryTaskStillOpensItsRoom(t *testing.T) { t.Fatal("an ordinary task's door asked the store for a page instead of opening its room") } } + +// A PROGRAM'S TAB IS ITS CONVERSATION, AND IT IS THE RUN STILL WORKING. With two +// of senior-dev's runs in one conversation, the tab was named after the first +// row, which had landed, and it drew the whole tasks place — every +// conversation on the machine — with that run's notes under it. +func TestAProgramsTabShowsTheWorkingRunsConversation(t *testing.T) { + landed := programRow() + landed.ID, landed.Title, landed.Status, landed.Stage = "1", "Implement true-myth", "done", "" + working := programRow() + working.ID, working.Title = "2", "Implement happy-dom" + rows := []session.PlanTaskRow{landed, working} + pages := map[string]session.PlanTaskPage{ + landed.ID: programPage(landed, nil), + working.ID: programPage(working, programTurns()), + } + a, fake := planAppWith(t, rows, pages) + a.width, a.height = 120, 30 + a.taskSheet.mine.plan = fake.plan + if tab, ok := a.workTab(); !ok || tab.word != "Implement happy-dom" { + t.Fatalf("the tab is %q, want the run still working", tab.word) + } + cmd := a.openWorkTab() + if cmd == nil { + t.Fatal("the run's tab did not open") + } + drive(t, a, cmd()) + if a.taskSheet.plan.Row.ID != working.ID { + t.Fatalf("the tab opened row %q, want the working run %q", a.taskSheet.plan.Row.ID, working.ID) + } + lines := make([]string, 0) + for _, line := range a.workTabFrame(a.width, a.height) { + lines = append(lines, plain(line)) + } + if !saidBy(lines, "senior-dev", "rewrite the auth middleware") { + t.Fatalf("the tab does not draw the program's conversation:\n%s", strings.Join(lines, "\n")) + } + if strings.Contains(strings.Join(lines, "\n"), " chats · ") { + t.Fatalf("the tab still draws the tasks place:\n%s", strings.Join(lines, "\n")) + } +} diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 5c8265938..6bfd72dae 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -27,7 +27,7 @@ func (a *app) workTab() (chatTab, bool) { if !live && a.workTabStable() { return chatTab{}, false } - word := strings.TrimSpace(rows[0].Title) + word := strings.TrimSpace(workTabRow(rows).Title) if word == "" { return chatTab{}, false } @@ -70,11 +70,25 @@ func (a *app) openWorkTab() tea.Cmd { a.refreshElsewhere() a.taskSheet = a.takeTaskReading() a.workTabOn, a.taskSheet.planOn, a.taskSheet.detailOn = true, true, true - a.taskSheet.plan = session.PlanTaskPage{Row: rows[0]} + row := workTabRow(rows) + a.taskSheet.plan = session.PlanTaskPage{Row: row} a.taskSheet.planNote.reset() a.chatTabBar = tabBar{} a.touch() - return a.taskSheetPlanAsk(rows[0].ID, nil, nil, nil) + return a.taskSheetPlanAsk(row.ID, nil, nil, nil) +} + +// workTabRow is the row the run's tab is about: the first one still working, +// and the first row when none is. A conversation that handed senior-dev two +// tasks holds two runs' rows, and a tab named after the one that had already +// landed opened on it while the other was the work in front of the person. +func workTabRow(rows []session.PlanTaskRow) session.PlanTaskRow { + for _, row := range rows { + if planRunning(row.Status) { + return row + } + } + return rows[0] } func (a *app) workTabKey(msg tea.KeyPressMsg) tea.Cmd { @@ -98,6 +112,16 @@ func (a *app) workTabKey(msg tea.KeyPressMsg) tea.Cmd { func (a *app) workTabFrame(width, height int) []string { a.workTabStable() out := a.headRows(width, a.tabsRow(width), a.pal) + // A PROGRAM'S RUN SHOWS ITS PAGE. The rows below are the whole tasks place + // — every conversation this machine has held — and for a run the plan + // switch drives that was the run's own list; a program's run has one row + // and its page is its conversation with codeaf, so the tab drew a hundred + // conversations and the run's notes under them and never the program. + // Its tab draws the page the rail opens, under the tab strip. + if a.taskSheet.planOn && a.taskPlanIsProgram() { + page, _, _ := a.taskPlanFrame(width, max(height-len(out), 1)) + return append(out, page...) + } reading := a.tasksFiltered() reading.unfolded = true rows := reading.rows(width, a.pal) From ca679d17ad5109ec5f8f35ff8dd58d7f17add718 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:27:08 -0400 Subject: [PATCH 046/195] tui3: a room opened on a program's task becomes its page, from every door The sessions place brings a conversation forward and reopens the room it was aimed at through a door that skips openRoomFor, before that conversation's rows are read, so a running senior-dev task opened from the sessions place was a blank room while the same task opened from the conversation showed its whole conversation. openRoom now asks the store off the loop whether its task is a program's and trades the room for the page when it is. A door that already asked the store at the gesture (the rail) is not asked again, so the rail still reads the store once. Co-Authored-By: Claude Opus 5.5 --- internal/tui3/app.go | 4 +++ internal/tui3/room.go | 47 ++++++++++++++++++++++++-- internal/tui3/taskconversation_test.go | 33 ++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index d11a86209..b79f64bbd 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -997,6 +997,10 @@ type app struct { // answer belong to the replay that asked for it. historyLoading bool historyGen int + // roomPageAsked is the task whose stored page the door opening its room has + // just asked for and not found, so the room does not ask again + // ([app.roomProgramCheck]). Zero is every other opening. + roomPageAsked uint64 // unfolded holds the turns whose tool cluster is showing every call. unfolded map[int]bool // workOpen is the ephemeral expansion state of live and completed work. diff --git a/internal/tui3/room.go b/internal/tui3/room.go index c9fee97b7..8d207a443 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -782,11 +782,52 @@ func (a *app) openRoom(id uint64, title string) { // what is asked ([roomRowDone]). room.done = roomRowDone(a.tasks[id]) room.resolveUnfinished() - a.roomPump = tea.Batch(prefetch, a.wake()) + a.roomPump = tea.Batch(prefetch, a.wake(), a.roomProgramCheck(id)) return } room.lane, room.stop = lane, stop - a.roomPump = tea.Batch(waitRoom(lane, room.gen), prefetch, a.wake()) + a.roomPump = tea.Batch(waitRoom(lane, room.gen), prefetch, a.wake(), a.roomProgramCheck(id)) +} + +// roomProgramCheck asks the store, off the loop, whether the task a room was +// just opened on is a program's, and if it is, trades the room for the task's +// page. +// +// EVERY DOOR ENDS HERE, SO THE QUESTION IS ASKED HERE. [app.openRoomFor] asks +// the rows this conversation holds, which answers without a frame of room, but +// a door that brings a conversation forward and reopens the room it was on +// (the sessions place, a switch back to a held conversation) opens it before +// that conversation's rows have been read, and a program's room is a blank +// page that says it will fill in: the program has no worker transcript, and +// its conversation with codeaf is on the stored page. Nothing is traded when +// the person has already left the room, or when it is another conversation's. +func (a *app) roomProgramCheck(id uint64) tea.Cmd { + // A DOOR THAT ALREADY ASKED THE STORE, and was told there is no page, is + // not asked again: the rail reads the store once, at the gesture. + if asked := a.roomPageAsked; asked != 0 { + a.roomPageAsked = 0 + if asked == id { + return nil + } + } + agent, ok := a.planReader() + if !ok || id == 0 { + return nil + } + key := strconv.FormatUint(id, 10) + return a.offLoop(func() func(bool) tea.Cmd { + page, found := agent.PlanTaskPage(key) + return func(here bool) tea.Cmd { + if !here || !found || (page.Program == nil && strings.TrimSpace(page.Row.Program) == "") { + return nil + } + if a.room == nil || a.roomIsGuest() || a.room.id != id { + return nil + } + a.closeRoom() + return a.openRailPlan(key, nil) + } + }) } // openFarRoom opens a hosted node immediately and asks the engine for its @@ -1121,6 +1162,7 @@ func (a *app) openRoomFor(id uint64, title string) { } if a.programTask(id) { a.roomPump = tea.Batch(a.roomPump, a.openRailPlan(strconv.FormatUint(id, 10), func() tea.Cmd { + a.roomPageAsked = id a.openRoom(id, title) return a.takeRoomPump() })) @@ -1164,6 +1206,7 @@ func (a *app) openRailRoom(node *taskNode) tea.Cmd { if run != "" && !hasPlan { a.openOrchRoom(run, part) } else { + a.roomPageAsked = id a.openRoom(id, title) } return a.takeRoomPump() diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index 189ff6d44..898eb0b17 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -563,3 +563,36 @@ func TestAProgramsTabShowsTheWorkingRunsConversation(t *testing.T) { t.Fatalf("the tab still draws the tasks place:\n%s", strings.Join(lines, "\n")) } } + +// A ROOM OPENED ON A PROGRAM'S TASK TRADES ITSELF FOR THE PAGE. The sessions +// place brings a conversation forward and reopens the room it was aimed at +// before that conversation's rows are read, so the row check at the door +// cannot see the program; the room asks the store itself, and a program's +// page replaces the blank room. +func TestARoomOpenedOnAProgramsTaskBecomesItsPage(t *testing.T) { + row := programRow() + row.ID = "7" + a, _ := planAppWith(t, nil, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) + a.width, a.height = 120, 28 + a.room = a.newRoom(7, row.Title) + cmd := a.roomProgramCheck(7) + if cmd == nil { + t.Fatal("the room did not ask whether its task is a program's") + } + drive(t, a, cmd()) + if a.room != nil { + t.Fatal("the program's room stayed open") + } + if !a.taskSheet.planOn || !a.taskPlanIsProgram() { + t.Fatal("the program's page did not replace the room") + } + // AND AN ORDINARY TASK KEEPS ITS ROOM. + plain := row + plain.Program, plain.Stage = "", "" + b, _ := planAppWith(t, nil, map[string]session.PlanTaskPage{"8": {Row: plain}}) + b.room = b.newRoom(8, "ordinary") + drive(t, b, b.roomProgramCheck(8)()) + if b.room == nil { + t.Fatal("an ordinary task's room was traded away") + } +} From 990345ff8d20976c0bec73bab1cc6ea2911fb1a7 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:35:57 -0400 Subject: [PATCH 047/195] seniordev: writes stay inside the workspace; reads may leave it senior-dev's file tools wrote anywhere its model named, because its config allowed external directories for every tool. A run briefed to "make a checkout" cloned a repository into the person's projects folder and edited it there, and codeaf, which lands only the copy, reported that nothing had changed. write, edit and apply_patch now refuse a path outside the workspace (absolute, climbed to with .., or through a link that points out), with a sentence the model can act on; reads stay open, since a task's statement can live elsewhere. The shell cannot be fenced this way, so the prompt's workspace section now says only the workspace is handed back. The manual and the protocol say the same. Co-Authored-By: Claude Opus 5.5 --- docs/design/delegate/PROTOCOL.md | 6 +- internal/manual/chat/senior-dev.md | 9 ++- internal/seniordev/app/config.go | 3 + internal/seniordev/app/solo_prompt.go | 5 +- internal/seniordev/tool/apply_patch.go | 4 +- .../seniordev/tool/confine_writes_test.go | 81 +++++++++++++++++++ internal/seniordev/tool/edit.go | 2 +- internal/seniordev/tool/path.go | 56 +++++++++++++ internal/seniordev/tool/registry.go | 7 ++ internal/seniordev/tool/write.go | 2 +- 10 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 internal/seniordev/tool/confine_writes_test.go diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index f3c196aee..e04f6fd17 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -131,7 +131,11 @@ the conversation between the program and codeaf. - 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). + 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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index f1b57ce08..f8854a094 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -51,8 +51,8 @@ At a shell, clone the repository yourself, then run `codeaf senior-dev` inside i the folder with `--dir`. A brief that tells senior-dev to make a checkout of its own somewhere else does not work. -It has no copy of that folder, so nothing it does there lands, and it would be editing a -folder of yours directly. +It has no copy of that folder, so nothing it does there lands: its file tools refuse to +write outside its copy, and what a shell command changes out there stays where it is. ## What senior-dev cannot do — it cannot ask you anything, no step cap, no Windows @@ -67,6 +67,11 @@ and codeaf enforces both from outside whatever it does. `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 copy.** 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 copy lands. + **It needs a git repository with at least one commit**, unless it runs `--in-place`, which edits a plain folder without committing anything. diff --git a/internal/seniordev/app/config.go b/internal/seniordev/app/config.go index 0e6d5c53b..0a1f77dfe 100644 --- a/internal/seniordev/app/config.go +++ b/internal/seniordev/app/config.go @@ -157,6 +157,9 @@ func (cfg *seniorDevConfig) registryOptions() 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) }, diff --git a/internal/seniordev/app/solo_prompt.go b/internal/seniordev/app/solo_prompt.go index 465415824..3b200e405 100644 --- a/internal/seniordev/app/solo_prompt.go +++ b/internal/seniordev/app/solo_prompt.go @@ -52,7 +52,10 @@ 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.` +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 diff --git a/internal/seniordev/tool/apply_patch.go b/internal/seniordev/tool/apply_patch.go index 7a6994b5d..70fe09d0f 100644 --- a/internal/seniordev/tool/apply_patch.go +++ b/internal/seniordev/tool/apply_patch.go @@ -76,7 +76,7 @@ func (r *Registry) executeApplyPatch(ctx context.Context, call steploop.ToolCall if err := ctx.Err(); err != nil { return steploop.ToolResult{}, err } - filePath, err := r.resolvePath(hunk.Path) + filePath, err := r.resolveWritePath(hunk.Path) if err != nil { return steploop.ToolResult{}, err } @@ -124,7 +124,7 @@ func (r *Registry) executeApplyPatch(ctx context.Context, call steploop.ToolCall } movePath := "" if hunk.MovePath != "" { - movePath, err = r.resolvePath(hunk.MovePath) + movePath, err = r.resolveWritePath(hunk.MovePath) if err != nil { return steploop.ToolResult{}, err } diff --git a/internal/seniordev/tool/confine_writes_test.go b/internal/seniordev/tool/confine_writes_test.go new file mode 100644 index 000000000..5f35d3b2c --- /dev/null +++ b/internal/seniordev/tool/confine_writes_test.go @@ -0,0 +1,81 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/permission" +) + +// WRITES STAY IN THE WORKSPACE, READS DO NOT HAVE TO. codeaf lands only the +// copy a run is handed, so under ConfineWrites every writer refuses a path +// outside the workspace — named absolutely, climbed to with `..`, or reached +// through a link inside the workspace that points out — and the file outside +// is untouched. A read outside still works, because a task's statement can +// live outside its copy. This is the road a run took when its brief told it +// to make a checkout in the person's projects folder and it edited there. +func TestConfinedWritesRefuseEveryPathOutsideTheWorkspace(t *testing.T) { + workspace, external := t.TempDir(), t.TempDir() + target := filepath.Join(external, "outside.txt") + if err := os.WriteFile(target, []byte("keep\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(workspace, "out")); err != nil { + t.Fatal(err) + } + registry := NewWithOptions(workspace, RegistryOptions{ + AllowExternalDirectories: true, + ConfineWrites: true, + Permission: permissionEvaluatorFunc(func(permission.AskInput) error { return nil }), + }) + escape, err := filepath.Rel(workspace, target) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{target, escape, filepath.Join("out", "outside.txt")} { + for _, call := range []struct { + tool string + input map[string]any + }{ + {"write", map[string]any{"filePath": path, "content": "changed\n"}}, + {"edit", map[string]any{"filePath": path, "oldString": "keep", "newString": "changed"}}, + {"apply_patch", map[string]any{"patchText": "*** Begin Patch\n*** Update File: " + path + "\n@@\n-keep\n+changed\n*** End Patch"}}, + {"apply_patch", map[string]any{"patchText": "*** Begin Patch\n*** Add File: " + path + ".new\n+new\n*** End Patch"}}, + } { + _, err := execute(t, registry, call.tool, call.input) + if err == nil || !strings.Contains(err.Error(), "outside this run's workspace") { + t.Fatalf("%s of %q: err = %v, want the write refused", call.tool, path, err) + } + } + } + if data, _ := os.ReadFile(target); string(data) != "keep\n" { + t.Fatalf("the file outside the workspace changed: %q", data) + } + if _, err := os.Stat(target + ".new"); !os.IsNotExist(err) { + t.Fatal("a file was added outside the workspace") + } + if result, err := execute(t, registry, "read", map[string]any{"filePath": target}); err != nil || !strings.Contains(result.Output, "keep") { + t.Fatalf("a read outside the workspace was refused: %v", err) + } + if _, err := execute(t, registry, "write", map[string]any{"filePath": "inside.txt", "content": "fine\n"}); err != nil { + t.Fatalf("a write inside the workspace was refused: %v", err) + } +} + +// AND WITHOUT THE OPTION NOTHING CHANGES: an embedder that never asked for the +// fence keeps the permission flow it had. +func TestUnconfinedWritesStillReachOutsideThroughPermission(t *testing.T) { + workspace, external := t.TempDir(), t.TempDir() + target := filepath.Join(external, "outside.txt") + registry := NewWithOptions(workspace, RegistryOptions{ + AllowExternalDirectories: true, + Permission: permissionEvaluatorFunc(func(permission.AskInput) error { return nil }), + }) + if _, err := execute(t, registry, "write", map[string]any{"filePath": target, "content": "x\n"}); err != nil { + t.Fatalf("an unconfined registry refused an allowed write: %v", err) + } +} diff --git a/internal/seniordev/tool/edit.go b/internal/seniordev/tool/edit.go index d50b87455..06fd4b799 100644 --- a/internal/seniordev/tool/edit.go +++ b/internal/seniordev/tool/edit.go @@ -61,7 +61,7 @@ func (r *Registry) executeEdit(ctx context.Context, call steploop.ToolCall) (ste return steploop.ToolResult{}, err } - resolved, err := r.resolvePath(input.FilePath) + resolved, err := r.resolveWritePath(input.FilePath) if err != nil { return steploop.ToolResult{}, err } diff --git a/internal/seniordev/tool/path.go b/internal/seniordev/tool/path.go index 78c33bd11..431e75a1a 100644 --- a/internal/seniordev/tool/path.go +++ b/internal/seniordev/tool/path.go @@ -35,6 +35,62 @@ func (r *Registry) resolvePath(path string) (string, error) { return absolute, nil } +// resolveWritePath is resolvePath for a path a tool is about to write: under +// ConfineWrites a path outside the workspace is refused, with a sentence the +// model can act on, before anything is asked or touched. +// +// IT FOLLOWS SYMLINKS. A link inside the workspace that points out of it is a +// write outside it, so the deepest part of the path that exists is resolved on +// both sides before they are compared (and macOS's /var and /private/var are +// the same place by the same rule). +// +// WHY IT EXISTS. codeaf lands only the copy of the folder a run is handed. A +// run told by its brief to "make a checkout" cloned a repository into the +// person's own projects folder and edited it there with these tools: the task +// ended saying it had changed nothing, and the edits sat in a folder of the +// person's that no task owned. Reads stay open, because a task's statement can +// live outside its copy; the shell cannot be fenced this way, and the prompt +// says so. +func (r *Registry) resolveWritePath(path string) (string, error) { + resolved, err := r.resolvePath(path) + if err != nil || !r.confineWrites { + return resolved, err + } + if !withinReal(r.workDir, resolved) { + return "", fmt.Errorf("write refused: %s is outside this run's workspace (%s). "+ + "Only changes inside the workspace are handed back, so make this change there", path, r.workDir) + } + return resolved, nil +} + +// withinReal reports whether target is root or under it once both have had +// their symlinks resolved as far as they exist. +func withinReal(root, target string) bool { + relative, err := filepath.Rel(realPrefix(root), realPrefix(target)) + return err == nil && !outsidePath(relative) +} + +// realPrefix resolves the symlinks of the deepest existing ancestor of path and +// puts the parts that do not exist yet back on the end. +func realPrefix(path string) string { + path = filepath.Clean(path) + var rest []string + for current := path; ; { + if real, err := filepath.EvalSymlinks(current); err == nil { + for i := len(rest) - 1; i >= 0; i-- { + real = filepath.Join(real, rest[i]) + } + return real + } + parent := filepath.Dir(current) + if parent == current { + return path + } + rest = append(rest, filepath.Base(current)) + current = parent + } +} + func outsidePath(relative string) bool { return relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) } diff --git a/internal/seniordev/tool/registry.go b/internal/seniordev/tool/registry.go index 6f71a94d7..066d09ea8 100644 --- a/internal/seniordev/tool/registry.go +++ b/internal/seniordev/tool/registry.go @@ -143,6 +143,7 @@ type Registry struct { npm *core.Npm instructionConfig instruction.Config allowExternal bool + confineWrites bool hardConfineShell bool question *question.Service questionEnabled bool @@ -179,6 +180,11 @@ type RegistryOptions struct { Instructions []string Config *config.Service AllowExternalDirectories bool + // ConfineWrites refuses every file write outside the workspace, whatever + // AllowExternalDirectories says of reads: codeaf lands only the copy a + // program works in, so a write anywhere else is work that can never come + // back and a change made to somebody's folder directly (path.go). + ConfineWrites bool // HardConfineShellPaths rejects parsed external shell operands instead of // asking permission, for an embedder that must not prompt. senior-dev leaves it // disabled and asks. @@ -271,6 +277,7 @@ func NewWithOptions(workDir string, options RegistryOptions) *Registry { rules: rules, config: configService, allowExternal: options.AllowExternalDirectories, + confineWrites: options.ConfineWrites, hardConfineShell: options.HardConfineShellPaths, question: questionService, questionEnabled: clientIdentity == "app" || clientIdentity == "cli" || clientIdentity == "desktop" || env.Enabled("SENIOR_DEV_ENABLE_QUESTION_TOOL"), diff --git a/internal/seniordev/tool/write.go b/internal/seniordev/tool/write.go index 4dcb10d00..93d91e396 100644 --- a/internal/seniordev/tool/write.go +++ b/internal/seniordev/tool/write.go @@ -31,7 +31,7 @@ func (r *Registry) executeWrite(ctx context.Context, call steploop.ToolCall) (st return steploop.ToolResult{}, err } - resolved, err := r.resolvePath(input.FilePath) + resolved, err := r.resolveWritePath(input.FilePath) if err != nil { return steploop.ToolResult{}, err } From 4f40fe7b84f706961c15dac43875d75a4b1cc71f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:45:58 -0400 Subject: [PATCH 048/195] delegate: a plain folder is worked in place, and the program is told so codeaf reads the task's folder before it starts a tree program. A folder with no git history has nothing to cut a copy from, so the run already stood in it; now the program's own PlainFolder flags ride its line (senior-dev's is --in-place), and the landing commits nothing and says where the work is. Before, senior-dev died on its first line with "workspace is not a git repository". Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/carried_seniordev_worker_test.go | 59 ++++++++++++++ docs/design/delegate/PROTOCOL.md | 18 ++++- internal/delegate/cli.go | 8 +- internal/delegate/cli_test.go | 36 ++++++++- internal/delegate/delegate.go | 24 ++++++ internal/delegate/launch_test.go | 2 +- internal/manual/chat/delegates.md | 7 +- internal/manual/chat/senior-dev.md | 29 ++++++- internal/manual/chat_test.go | 2 + internal/run/delegateworker.go | 6 +- internal/run/enginewire.go | 1 + .../seniordev/app/workspace_recorder_git.go | 7 +- internal/seniordev/seniordev.go | 20 +++-- internal/session/delegate_door.go | 16 ++++ internal/session/delegate_door_test.go | 77 +++++++++++++++++++ internal/session/task_run_belt.go | 28 +++++++ 16 files changed, 317 insertions(+), 23 deletions(-) diff --git a/cmd/codeaf/carried_seniordev_worker_test.go b/cmd/codeaf/carried_seniordev_worker_test.go index abad38c48..dbfb56c15 100644 --- a/cmd/codeaf/carried_seniordev_worker_test.go +++ b/cmd/codeaf/carried_seniordev_worker_test.go @@ -120,6 +120,65 @@ func TestSeniorDevWorksATaskAsTheChatsRunWorker(t *testing.T) { } } +// 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) + } +} + // 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 diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index e04f6fd17..2854d384b 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -12,13 +12,14 @@ program's own name.* 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, and the name of its page -in the chat's manual. There is nothing to install. A program not in the list does +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, -what its brief must hold and what it needs of its folder. The conversation prints +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. @@ -39,9 +40,18 @@ a `delegate.Host`, and only codeaf makes one. Always as a child process of codeaf's own executable: ``` -codeaf --json --dir [--max-cost USD] [--max-hours H] -- +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 +repository with a commit gets a working copy cut from it. A folder with no git +history (a plain folder, or a repository with no commit) has nothing to cut +from, so the program works in the folder itself and codeaf puts the program's +own `PlainFolder` flags on its line (senior-dev's is `--in-place`); its landing +commits nothing, because the work is already there, and the run's page says so. +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. + - **From the chat,** the engine's run (`internal/run`'s `DelegateWorker`) starts that line in the run's working copy, which is cut from the folder the proposal names (`propose_task`'s `ground`) or else the conversation's own. diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index 909d86835..9d3810dd4 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -116,7 +116,10 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) // // 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. -func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings) []string { +// +// plain says the folder has no git history, and puts the program's own +// [Delegate.PlainFolder] flags on the line after codeaf's. +func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings, plain bool) []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)) @@ -124,6 +127,9 @@ func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings) []s if ceilings.Hours > 0 { args = append(args, "--max-hours", strconv.FormatFloat(ceilings.Hours, 'f', -1, 64)) } + if plain { + args = append(args, program.PlainFolder...) + } return append(args, "--", brief) } diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index 339ed929c..93556c44f 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -65,7 +65,7 @@ func TestParseTakesANamedCommandAndItsOwnFlags(t *testing.T) { // 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}) + line := ChildArgs(program, "/work", "add a --flag to the parser", Ceilings{CostUSD: 2.5, Hours: 1}, false) if line[0] != "fake" { t.Fatalf("line = %q, want the program's name first", line) } @@ -79,6 +79,40 @@ func TestChildArgsParseBackToTheSameInvocation(t *testing.T) { } } +// 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{}, false); 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{}, 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) + } +} + +// 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 diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index c89310aeb..d2b40b3df 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -29,6 +29,7 @@ import ( "errors" "flag" "fmt" + "io" "regexp" "strings" ) @@ -77,6 +78,17 @@ type Delegate struct { // 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 it has a history to cut a working copy from is + // read by codeaf before the program starts: a plain folder is worked in + // where it is, 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 // Default is the command a bare brief runs: `/ ` in the chat // and `codeaf ` in a shell. It names one of Commands. Default string @@ -184,6 +196,18 @@ func (d Delegate) Validate() error { if !seen[d.Default] { return fmt.Errorf("%s: the default command %q is not one of its commands", d.Name, d.Default) } + if len(d.PlainFolder) > 0 { + // THE FLAGS ARE PARSED BY THE COMMAND THEY WILL BE HANDED TO, so a + // misspelt one fails here, in the build's own test, and never as a + // run that dies on its first line in somebody's folder. + command, _ := d.Command(d.Default) + fs := flag.NewFlagSet(d.Name+" "+command.Name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + command.Bind(fs) + if err := fs.Parse(d.PlainFolder); err != nil || fs.NArg() > 0 { + 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 } diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go index e150a0959..680875236 100644 --- a/internal/delegate/launch_test.go +++ b/internal/delegate/launch_test.go @@ -41,7 +41,7 @@ func fakeLaunch(t *testing.T, script, workspace, brief string, ceilings Ceilings return Launch{ Name: "fake", Bin: script, - Args: ChildArgs(program, workspace, brief, ceilings), + Args: ChildArgs(program, workspace, brief, ceilings, false), Env: ChildEnv(api), Dir: workspace, } diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 83fb47605..05be9ebc5 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -43,7 +43,7 @@ At a shell, `codeaf ` runs the same program in the folder you are 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. -## Which folder a program works in — a repository I have not cloned, it edited files outside its copy +## Which folder a program works in — a repository I have not cloned, it edited files outside its copy, a folder with no git A program that edits code works in a copy of one folder: the one this conversation works in, or the one the task names. **Only what it changes inside that copy lands.** Anything it @@ -55,6 +55,11 @@ it first, into a new folder, onto a branch at the commit the work names, and han program that folder. It is told never to write a brief that sends the program to work in another folder, because nothing the program did there could land. +A folder with no git history (a plain folder, or a repository with no commit yet) has +nothing to copy from, so the program works in that folder itself, and codeaf tells it so +on the line it starts it with (senior-dev is given `--in-place`). Nothing is committed: its +changes are already in the folder when it ends. + At a shell nobody does that for you: clone the repository, then run `codeaf ` inside it, or name the folder with `--dir`. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index f8854a094..9c1e8252b 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -72,13 +72,33 @@ refuse any path outside the folder it was handed, including one reached through 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 copy lands. -**It needs a git repository with at least one commit**, unless it runs `--in-place`, which -edits a plain folder without committing anything. +**It keeps its record in git**, unless it runs `--in-place`; from the chat, codeaf chooses +that for a folder with no git history (see the section on plain folders). **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. +## senior-dev on a folder that is not a git repository — a plain folder, no git, --in-place + +From the chat, codeaf reads the task's folder before it starts senior-dev. A repository +with at least one commit gets a copy, as every task does. **A folder with no git history — +a plain folder, or a repository with no commit yet — has nothing to copy from**, so +senior-dev works in that folder itself, and codeaf starts it with `--in-place`: it commits +nothing, and keeps its checkpoints outside the folder. + +When it ends there is nothing to commit, because 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`. Its `.senior-dev/` notes (the brief, its checklist) stay in the folder +afterwards; delete them when you are done with them. + +At a shell, pass `--in-place` yourself. Without it senior-dev stops at once with +`workspace is not a git repository: ; run with --in-place to work in a plain +folder`. + +To have its work isolated and landed as one commit instead, make the folder a repository +with a first commit (`git init`, `git add -A`, `git commit`) before you ask. + ## Where senior-dev's work lands — one squashed commit on your branch senior-dev commits every file it writes inside its copy (`wip(write): `, @@ -94,7 +114,8 @@ project's build and tests (`senior-dev observed: …`). Read the second for "did Its own notes live in `.senior-dev/` in the copy: the brief, its checklist, the command it pinned and its session database. That folder is kept out of git, so it never lands. -When a run changed nothing, there is nothing to land and the task says so. +When a run changed nothing, there is nothing to land and the task says so. On a folder +with no git history nothing is committed at all: the work is already in the folder. ## What a senior-dev run costs — model calls, the dollar ceiling, which models @@ -151,7 +172,7 @@ A run ends in one of these ways, and the task's ending says which: 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`, no git repository); + refused `senior-dev.json`, no git repository at a shell without `--in-place`); - `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`. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index cb34596d5..fc78e1660 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -926,6 +926,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"which folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index c34ec89b1..3e182b9d6 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -98,6 +98,10 @@ type DelegateSetup struct { Ledger string // Keepalive overrides the model API's keepalive interval, for a test. Keepalive time.Duration + // PlainFolder says the working folder has no git history + // (session.RunSpec.PlainFolder), so the program's line carries its own + // flags for one (delegate.Delegate.PlainFolder). + PlainFolder bool } // NewDelegateWorker builds the worker. cost and elapsed are the run's @@ -298,7 +302,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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()}), + Args: delegate.ChildArgs(w.program, w.workspace, brief, delegate.Ceilings{CostUSD: w.cost, Hours: w.elapsed.Hours()}, w.setup.PlainFolder), // 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()), diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 4f224bb5b..a979709d5 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -52,6 +52,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar CompleterFor: spec.CompleterFor, Serves: spec.Serves, Seat: WorkSeat(spec.ProfileDir, spec.WorkModel), + PlainFolder: spec.PlainFolder, } factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, setup, limits, factory) } diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go index 2f79b7d98..39fab44ce 100644 --- a/internal/seniordev/app/workspace_recorder_git.go +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -52,7 +52,10 @@ func (recorder *gitRecorder) git(args ...string) (string, error) { func (recorder *gitRecorder) Prepare(ctx context.Context) error { if gitOutput(ctx, recorder.workspace, "rev-parse", "--show-toplevel") == "" { - return fmt.Errorf("workspace is not a git repository: %s", recorder.workspace) + // A PERSON AT A SHELL CAN ANSWER THIS, so the sentence names the flag. + // The chat never meets it: codeaf reads the folder first and passes + // the flag itself (seniordev.Program's PlainFolder). + 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 @@ -67,7 +70,7 @@ func (recorder *gitRecorder) Prepare(ctx context.Context) error { 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") + 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 == "" { diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index c5c6fdc4e..04c4ea810 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -39,16 +39,20 @@ var Program = delegate.Delegate{ Summary: "an autonomous agent for one large, well-specified code change", // What the chat's model reads before it names senior-dev in `via`. The // brief is copied word for word into .senior-dev/spec.md and is all it ever - // knows of the work, so the guide says what that brief must settle; and - // its recorder is git unless it runs --in-place, which the chat's line - // never passes, so the guide says what its folder must be. + // knows of the work, so the guide says what that brief must settle. Its + // folder is codeaf's to choose and to read: a plain one is handed over + // with PlainFolder on the line, so the guide says nothing about git. Guide: "For one large code change worth an hour: a rewrite across a package, a migration, " + "a feature with its tests. Its brief names the files and commands, what done means and " + - "how to check it, and what must not change. Its folder must be a git repository with a commit.", - Lands: delegate.LandsTree, - Default: "run", - Page: "senior-dev", - Commands: []delegate.Command{runCommand}, + "how to check it, and what must not change.", + Lands: delegate.LandsTree, + // Its recorder is git unless it is told --in-place, which keeps its + // checkpoints outside the folder and commits nothing; a folder with no git + // history has nothing else it can run on. + PlainFolder: []string{"--in-place"}, + Default: "run", + Page: "senior-dev", + Commands: []delegate.Command{runCommand}, } // runCommand is senior-dev's one verb: the whole run, from the brief to the diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 6354ccf17..211b36b58 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -235,6 +235,10 @@ func delegateStand(workspace string, program delegate.Delegate) taskStand { // terminal record's two sentences. Then the copy comes home the way every run's // copy does. // +// A TREE PROGRAM ON A PLAIN FOLDER LANDS NOTHING EITHER: there was no history +// to copy from, so it worked in the folder itself and its changes are already +// there ([delegateOnPlainFolder]). +// // A TEXT PROGRAM LANDS NOTHING: it worked in place and promised to change // nothing, and its answer is the run's result, which the outcome note carries. func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { @@ -242,6 +246,18 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { if m == nil || !m.LandsTree() || run.tree.dir == "" { return RunLanding{Home: mergeInPlace} } + if run.plain { + // A PLAIN FOLDER HAS NO HISTORY TO COMMIT TO, and the program worked in + // it where it stands: its changes are already the person's, and the + // landing is only the note that says where they are. + note := "its work is in " + run.ground + ", which has no git history, so nothing was committed" + if _, err := run.store.AddNote(run.root, run.root, note); err != nil { + if g := a.graph(); g != nil { + g.planNote("the run's landing note failed: " + err.Error()) + } + } + return RunLanding{Home: mergeInPlace} + } dir := run.workspace if run.startSha != "" { head, err := git(dir, "rev-parse", "HEAD") diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 6804efada..ff902d658 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -105,6 +105,83 @@ func TestADelegatedRunSquashesTheProgramsCommitsIntoOneAndLandsIt(t *testing.T) } } +// A FOLDER WITH NO GIT HISTORY: the program is told so on its line, works in +// the folder itself because there is nothing to copy from, and its landing +// commits nothing — no repository is made in the person's folder — and says +// where the work is instead of refusing a commit git could never make. +func TestADelegatedRunOnAPlainFolderIsToldSoAndLandsWhereItWorked(t *testing.T) { + const result = "submitted and verified. fake's model said: done" + double := newBeltRunDouble(result) + double.work = func(workspace string) { + if err := os.WriteFile(filepath.Join(workspace, "made.txt"), []byte("made\n"), 0o644); err != nil { + t.Error(err) + } + } + registerBeltRunEngine(t, double) + folder := t.TempDir() + if err := os.WriteFile(filepath.Join(folder, "notes.txt"), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, beltRunCompleter{text: result}, func(config *Config) { + config.Workspace = folder + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file in this folder"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) { + t.Fatalf("spec = plain %v in %q, want the plain folder itself, said to be one", spec.PlainFolder, spec.Workspace) + } + endBeltRun(t, agent, double) + + if content, err := os.ReadFile(filepath.Join(folder, "made.txt")); err != nil || string(content) != "made\n" { + t.Fatalf("the work is not in the folder: %q %v", content, err) + } + if _, err := os.Stat(filepath.Join(folder, ".git")); !os.IsNotExist(err) { + t.Fatalf("the landing made the plain folder a repository: %v", err) + } + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + var said []string + for _, note := range store.Notes(store.RootID(), 0) { + said = append(said, note.Body) + } + joined := strings.Join(said, "\n") + if !strings.Contains(joined, "no git history, so nothing was committed") || strings.Contains(joined, "not a git repository") { + t.Fatalf("the run's notes = %q, want the plain-folder landing and no git refusal", said) + } +} + +// A folder with history is copied, and the program is told nothing extra. +func TestADelegatedRunOnARepositoryIsNotToldItIsPlain(t *testing.T) { + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + plain := double.spec.PlainFolder + double.mu.Unlock() + endBeltRun(t, agent, double) + if plain { + t.Fatal("a repository with a commit was called a plain folder") + } +} + func TestStartDelegateRefusesANameThisMachineDoesNotHave(t *testing.T) { double := newBeltRunDouble("done") registerBeltRunEngine(t, double) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 07af742a4..c7adac451 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -124,6 +124,10 @@ type RunSpec struct { // program reaches a model only through the API codeaf serves the run. Nil is // every run the conversation's own workers drive. Delegate *delegate.Delegate + // PlainFolder says the delegated run's folder has no git history, so the + // program is started with its own flags for one + // (delegate.Delegate.PlainFolder). False for every other run. + PlainFolder bool } // RunLimit is which bound a person set ended a run. The engine's outcome word @@ -245,6 +249,10 @@ type beltRun struct { // back to at landing (delegate_door.go). delegate *delegate.Delegate startSha string + // plain is a tree program working in a folder with no git history + // ([delegateOnPlainFolder]): it is told so on its line, and its landing + // commits nothing, because the work is already where it belongs. + plain bool } // startTaskRun is StartTask's second road, taken whenever the bash belt is asked @@ -345,6 +353,7 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie plan: plan, store: store, root: store.RootID(), row: id, title: title, workspace: tree.dir, ground: canonicalPath(stand.dir), tree: tree, cut: cut, born: born, delegate: via, startSha: delegateStartSha(tree, via), + plain: delegateOnPlainFolder(tree, via), } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because @@ -418,6 +427,24 @@ func delegateStartSha(tree taskTree, via *delegate.Delegate) string { return strings.TrimSpace(head) } +// delegateOnPlainFolder says a tree program is about to work in a folder with no +// git history to cut a copy from: a plain folder, or a repository with no +// commit yet. The copy road already answered that by working in the folder +// itself ([prepareTaskTreeOn]); this is the same fact read off the tree it +// answered with, for the program's line and its landing. +// +// IT WAS A RUN THAT DIED ON ITS FIRST LINE. senior-dev keeps its history in +// git unless it is told otherwise, and handed a plain folder it ended at once +// with "workspace is not a git repository", though it has a way of working +// without one. codeaf is the one that read the folder, so codeaf says so. +func delegateOnPlainFolder(tree taskTree, via *delegate.Delegate) bool { + if via == nil || !via.LandsTree() || tree.merge != mergeInPlace || tree.dir == "" { + return false + } + root, ok := repositoryRoot(tree.dir) + return !ok || !hasCommit(root) +} + // beltRunSpec is what the engine is handed for a run of this conversation: its // seats, its bounds and the copy it works in. // @@ -459,6 +486,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { CompleterFor: func(string) Completer { return a.beltRunCompleter() }, Serves: a.servesModel, Delegate: run.delegate, + PlainFolder: run.plain, } } From c45d04595d794c71faae87191a50462bcc232e88 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:11:58 -0400 Subject: [PATCH 049/195] delegate: work lands as its branch, the brief names the copy, the row says why Three things a senior-dev run on happy-dom showed: - The brief named the person's checkout, and senior-dev's shell ran git there. codeaf now rewrites every spelling of the proposed folder in the brief to the copy's path (delegate.RehomeBrief). - Merging an hour of work into a checkout that changed meanwhile made a finished run conflict and read as failed. A tree program's work now lands as its branch, and nothing is merged into the checkout. - The row said "a fault: ran and did not finish". The program's own ending now crosses typed (run.ProgramEndedError -> session.ProgramEnding); fail and budget end on TaskEndingProgram, whose reason is the program's sentence and which is not a fault. Co-Authored-By: Claude Opus 5.5 --- docs/design/delegate/PROTOCOL.md | 20 +++++ internal/delegate/rehome.go | 89 ++++++++++++++++++ internal/delegate/rehome_test.go | 30 +++++++ internal/manual/chat/delegates.md | 23 +++-- internal/manual/chat/senior-dev.md | 23 +++-- internal/manual/chat_test.go | 2 + internal/run/delegateworker.go | 42 +++++++-- internal/run/delegateworker_test.go | 45 ++++++++++ internal/run/enginewire.go | 14 +++ internal/run/run.go | 16 ++++ internal/session/delegate_door.go | 21 +++++ internal/session/delegate_door_test.go | 98 ++++++++++++++------ internal/session/task_contract.go | 7 ++ internal/session/task_run.go | 46 +++++++--- internal/session/task_run_belt.go | 119 +++++++++++++++++++++++-- internal/session/task_status.go | 60 +++++++++---- internal/tui3/taskending.go | 3 + 17 files changed, 574 insertions(+), 84 deletions(-) create mode 100644 internal/delegate/rehome.go create mode 100644 internal/delegate/rehome_test.go diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index 2854d384b..f5efe447c 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -52,6 +52,26 @@ commits nothing, because the work is already there, and the run's page says so. 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. +**The brief names the copy.** Where the program works in a copy, every spelling +of the proposed folder in the brief (as proposed, resolved, under `~`) is +rewritten to the copy's path before the child is started +(`delegate.RehomeBrief`), whole paths only. A senior-dev run briefed on "the +checkout at /Users/…/happy-dom-task" ran its git commands there, in the +person's checkout, because that is what it was told. + +**A tree program's work lands as its branch.** Its commits are squashed into +one `task:` commit on the copy's branch, the branch is put where the person's +repository can reach it, the copy is given back, and nothing is merged into the +person's checkout: the page says `its work is on the branch in +; nothing was merged into your checkout`. An hour-long run meeting the +checkout's hour of changes at a merge was a finished run reading as failed. + +**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 run's working copy, which is cut from the folder the proposal names (`propose_task`'s `ground`) or else the conversation's own. diff --git a/internal/delegate/rehome.go b/internal/delegate/rehome.go new file mode 100644 index 000000000..164aa0266 --- /dev/null +++ b/internal/delegate/rehome.go @@ -0,0 +1,89 @@ +package delegate + +import ( + "sort" + "strings" +) + +// RehomeBrief rewrites every mention of the folder a task was proposed on into +// the working copy the program was handed, so the brief a program reads names +// only the folder it works in. +// +// IT EXISTS BECAUSE A PROGRAM DID WHAT ITS BRIEF SAID. A conversation briefed +// senior-dev on "the checkout at /Users/…/happy-dom-task", which was the task's +// folder and so exactly right as a description; codeaf then handed senior-dev +// a copy of that folder, and senior-dev's model, reading the path, ran its git +// commands in the person's checkout instead. It committed there, made branches +// there, and the copy's own work would not merge over what it had done. The +// copy IS that folder as far as the work is concerned, so the brief is made to +// say so: the one fact the program needs to find its work is where it stands, +// and a path it cannot use is a path it is better never told. +// +// from is every spelling of the folder (as proposed, resolved, under ~); to is +// the copy. A mention is replaced only where it is the whole path or a path +// inside it — `/a/b` is rewritten in `/a/b` and `/a/b/src`, never in `/a/bc` +// or `/x/a/b` — so a sibling folder or a longer path is left as it was. +func RehomeBrief(brief string, from []string, to string) string { + to = strings.TrimRight(strings.TrimSpace(to), "/") + if to == "" || brief == "" { + return brief + } + spellings := make([]string, 0, len(from)) + for _, spelling := range from { + spelling = strings.TrimRight(strings.TrimSpace(spelling), "/") + if spelling != "" && spelling != to && strings.ContainsRune(spelling, '/') { + spellings = append(spellings, spelling) + } + } + // THE LONGEST SPELLING FIRST, so a resolved path that contains a shorter + // one is rewritten whole rather than half by the shorter. + sort.Slice(spellings, func(i, j int) bool { return len(spellings[i]) > len(spellings[j]) }) + for _, spelling := range spellings { + brief = rehomeOne(brief, spelling, to) + } + return brief +} + +// rehomeOne rewrites one spelling wherever it stands as a whole path. +func rehomeOne(brief, from, to string) string { + var out strings.Builder + rest := brief + for { + at := strings.Index(rest, from) + if at < 0 { + out.WriteString(rest) + return out.String() + } + end := at + len(from) + whole := (at == 0 || !pathByte(rest[at-1])) && endsName(rest[end:]) + out.WriteString(rest[:at]) + if whole { + out.WriteString(to) + } else { + out.WriteString(from) + } + rest = rest[end:] + } +} + +// endsName says the text after a match does not continue its last name: it is +// empty, a separator, or a sentence's full stop and not a file's extension. +func endsName(after string) bool { + switch { + case after == "": + return true + case after[0] == '.': + return len(after) == 1 || !nameByte(after[1]) + } + return !nameByte(after[0]) +} + +// nameByte is a byte a path's last name continues through, so a match +// followed by one is a longer name and not the folder. +func nameByte(b byte) bool { + return b == '-' || b == '_' || b == '.' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' +} + +// pathByte is a byte a path runs through before a match, so a match preceded +// by one is the tail of a longer path. +func pathByte(b byte) bool { return nameByte(b) || b == '/' || b == '~' } diff --git a/internal/delegate/rehome_test.go b/internal/delegate/rehome_test.go new file mode 100644 index 000000000..a42da96d3 --- /dev/null +++ b/internal/delegate/rehome_test.go @@ -0,0 +1,30 @@ +package delegate + +import "testing" + +// The brief a program reads names the copy it works in wherever it named the +// folder the task was proposed on, in every spelling, and leaves every other +// path alone. +func TestRehomeBriefNamesTheCopyWhereverTheFolderWasNamed(t *testing.T) { + from := []string{"~/Code/app", "/Users/p/Code/app", "/private/Users/p/Code/app/"} + const to = "/Users/p/.codeaf/trees/3" + for _, c := range []struct{ in, want string }{ + {"work in the checkout at /Users/p/Code/app (a git repo).", "work in the checkout at /Users/p/.codeaf/trees/3 (a git repo)."}, + {"cd /Users/p/Code/app/packages/x && npm test", "cd /Users/p/.codeaf/trees/3/packages/x && npm test"}, + {"the repo is ~/Code/app.", "the repo is /Users/p/.codeaf/trees/3."}, + {"resolved: /private/Users/p/Code/app", "resolved: /Users/p/.codeaf/trees/3"}, + {"`/Users/p/Code/app`", "`/Users/p/.codeaf/trees/3`"}, + // Not the folder: a sibling, a longer name, an extension, a deeper root. + {"/Users/p/Code/app-two and /Users/p/Code/apps", "/Users/p/Code/app-two and /Users/p/Code/apps"}, + {"/Users/p/Code/app.tar", "/Users/p/Code/app.tar"}, + {"/mnt/Users/p/Code/app", "/mnt/Users/p/Code/app"}, + {"nothing to rewrite", "nothing to rewrite"}, + } { + if got := RehomeBrief(c.in, from, to); got != c.want { + t.Errorf("RehomeBrief(%q) = %q, want %q", c.in, got, c.want) + } + } + if got := RehomeBrief("at /a/b", []string{"/a/b"}, "/a/b"); got != "at /a/b" { + t.Errorf("a copy that is the folder itself rewrote the brief: %q", got) + } +} diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 05be9ebc5..5ac633c5e 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -6,7 +6,7 @@ codeaf carries programs of its own that take one whole coding task and do it alo 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 a copy of your folder, under this conversation's dollar and time limits, shows on the rail while it runs, can be stopped, -and lands on your branch when it ends. +and leaves its work on a branch of its own 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, `/ @@ -49,6 +49,10 @@ A program that edits code works in a copy of one folder: the one this conversati in, or the one the task names. **Only what it changes inside that copy lands.** Anything it changed anywhere else is not part of the task, and the task's ending does not see it. +**The brief it reads names its copy.** Wherever the brief names the task's folder, codeaf +rewrites that path to the copy's before the program reads it, so it is never pointed at +your checkout. + So when the work belongs in a repository that is not on this machine (a benchmark task that names a repository and a commit, or a project you have not cloned), the model clones it first, into a new folder, onto a branch at the commit the work names, and hands the @@ -85,14 +89,21 @@ program itself checked is reported in its result, kept apart from what its model A name your build does not carry is refused with the ones it does: `this codeaf carries no program called ; it carries …`. -## Where its work goes — squashed into one commit, landed on my branch, the wip commits, what it costs +## Where its work goes — its own branch, not merged into mine, squashed into one commit, the wip commits, what it costs A program that edits code works in a copy cut from your folder. When it ends, every commit it made in that copy is squashed into **one commit**. The commit's subject is the task's -title, and its body is the program's own account of the ending. That commit is merged into -your folder the way every task's work comes home, so a program that commits after every -edit leaves no trail of bookkeeping commits on your branch. When there is nothing to land, -it says `nothing to land: the run's working copy holds no change`. +title, and its body is the program's own account of the ending. + +**That commit stays on the task's own branch** (`task/-<id>`) in your repository, +and **codeaf does not merge it into your checkout**. Your files and your branch are exactly +as you left them. The task's page says `its work is on the branch <branch> in <folder>; +nothing was merged into your checkout`. Ask the chat to merge it, or merge it yourself +(`git merge <branch>`), when you are ready. Nothing can conflict when the run ends, +because the landing writes nothing of yours; a conflict only appears when you merge. + +When there is nothing to land, it says `nothing to land: the run's working copy holds no +change`. A folder with no git history is the exception: the program works in it directly. 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. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 9c1e8252b..291eb72c6 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -50,6 +50,9 @@ reference solution among them, are not in its copy. 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 you proposed is fine: codeaf rewrites that path to senior-dev's +copy before senior-dev reads it, so its commands run in the copy. + A brief that tells senior-dev to make a checkout of its own somewhere else does not work. It has no copy of that folder, so nothing it does there lands: its file tools refuse to write outside its copy, and what a shell command changes out there stays where it is. @@ -82,7 +85,7 @@ rather than carry something that fails every time. ## senior-dev on a folder that is not a git repository — a plain folder, no git, --in-place From the chat, codeaf reads the task's folder before it starts senior-dev. A repository -with at least one commit gets a copy, as every task does. **A folder with no git history — +with at least one commit gets a copy, and the work lands on a branch of its own. **A folder with no git history — a plain folder, or a repository with no commit yet — has nothing to copy from**, so senior-dev works in that folder itself, and codeaf starts it with `--in-place`: it commits nothing, and keeps its checkpoints outside the folder. @@ -96,16 +99,21 @@ At a shell, pass `--in-place` yourself. Without it senior-dev stops at once with `workspace is not a git repository: <folder>; run with --in-place to work in a plain folder`. -To have its work isolated and landed as one commit instead, make the folder a repository -with a first commit (`git init`, `git add -A`, `git commit`) before you ask. +To have its work isolated and left on a branch as one commit instead, make the folder a +repository with a first commit (`git init`, `git add -A`, `git commit`) before you ask. -## Where senior-dev's work lands — one squashed commit on your branch +## Where senior-dev's work lands — its own branch, not merged, one squashed commit senior-dev commits every file it writes inside its copy (`wip(write): <path>`, `wip(edit): <path>`), which is how it keeps a record to restore from. None of those commits reaches your branch. When the run ends, they are squashed into **one commit** -whose subject is `task:` and the task's title, and whose body is senior-dev's own ending; -that commit comes home the way every task's work does. +whose subject is `task:` and the task's title, and whose body is senior-dev's own ending. + +**That commit is left on the task's own branch in your repository, and nothing is merged +into your checkout.** The task's page says `its work is on the branch <branch> in +<folder>; nothing was merged into your checkout`. Merge it when you are ready, or ask the +chat to. A run can take an hour, and a merge at its end used to meet whatever changed in +your checkout meanwhile; now nothing can clash until you choose to merge. The ending keeps two witnesses apart: what senior-dev's model said it did when it submitted (`senior-dev's model said: …`) and what senior-dev itself saw when it ran the @@ -167,7 +175,8 @@ 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; + the project's own build or tests. The task row shows this sentence as its reason; it is + not drawn as a fault, and what it made is still on its branch; - `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; diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index fc78e1660..f5e03e242 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -928,6 +928,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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 folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 3e182b9d6..500c085e3 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -102,6 +102,12 @@ type DelegateSetup struct { // (session.RunSpec.PlainFolder), so the program's line carries its own // flags for one (delegate.Delegate.PlainFolder). PlainFolder bool + // Ground is every spelling of the folder the task was proposed on, when the + // program works in a copy of it (session.RunSpec.Ground). The brief is + // rewritten to name the copy wherever it named that folder + // (delegate.RehomeBrief), so the program is never told a path it must not + // work in. Empty for a program working in the folder itself. + Ground []string } // NewDelegateWorker builds the worker. cost and elapsed are the run's @@ -299,6 +305,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err if brief == "" { brief = strings.TrimSpace(task.Title) } + brief = delegate.RehomeBrief(brief, w.setup.Ground, w.workspace) result, err := delegate.Run(launchCtx, delegate.Launch{ Name: w.program.Name, Bin: exe, @@ -369,27 +376,44 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err } t := *result.Reading.Terminal report.Result = delegateResult(w.program, t) + var reason string switch t.Status { case delegate.StatusPass: 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 - end(sink.steps, reason, report.Result) - return report, errors.New(reason) + reason = w.program.Name + " stopped on its own ceiling: " + t.Message case delegate.StatusCrashed: - reason := w.program.Name + " crashed: " + t.Message - end(sink.steps, reason, report.Result) - return report, errors.New(reason) + 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, errors.New(reason) + 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 diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go index 0df17731f..91d4e1dd0 100644 --- a/internal/run/delegateworker_test.go +++ b/internal/run/delegateworker_test.go @@ -346,6 +346,51 @@ func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { } } +// A brief that named the folder the task was proposed on reaches the program +// naming the copy it works in, so the program is never told a path it must not +// work in. +func TestDelegateWorkerHandsTheProgramABriefThatNamesItsCopy(t *testing.T) { + store := runOpenStore(t) + ground := filepath.Join(t.TempDir(), "project") + if _, err := store.Amend(store.RootID(), "work in the checkout at "+ground+" and commit there."); err != nil { + t.Fatal(err) + } + args := filepath.Join(t.TempDir(), "args") + t.Setenv("FAKE_ARGS", args) + workspace := t.TempDir() + m, setup := fakeDelegate(t, passLine("done")) + setup.Ground = []string{ground} + worker := run.NewDelegateWorker(store, workspace, m, setup, 0, 0) + if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(args) + if !strings.Contains(string(got), "work in the checkout at "+workspace+" and commit there.") || strings.Contains(string(got), ground) { + t.Fatalf("the program was handed:\n%s\nwant the brief naming its copy %s and never %s", got, workspace, ground) + } +} + +// 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") diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index a979709d5..6675bdf34 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -53,6 +53,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar Serves: spec.Serves, Seat: WorkSeat(spec.ProfileDir, spec.WorkModel), PlainFolder: spec.PlainFolder, + Ground: spec.Ground, } factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, setup, limits, factory) } @@ -74,6 +75,10 @@ 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), // 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 @@ -98,6 +103,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, rootID string) (session.RunLanding, error) { landing, err := Land(ctx, store, workspace, rootID) if err != nil { diff --git a/internal/run/run.go b/internal/run/run.go index 5dea1bc59..2168afeb3 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -145,6 +145,10 @@ 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 // 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 @@ -690,6 +694,13 @@ func (s *Supervisor) absorb(ret workerReturn) { s.addReviewCheck(ret.task, root.Result) } else { s.rootFailed = true + // 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 @@ -1545,6 +1556,10 @@ 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 // 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 @@ -1600,6 +1615,7 @@ func Start(ctx context.Context, spec Spec) (Outcome, Summary) { Outcome: outcome, Result: result, Limit: supervisor.limitHit, + Program: supervisor.rootProgram, Cut: supervisor.cutIDs(), Nodes: supervisor.nodes, Steps: supervisor.steps, diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 211b36b58..cf2b98e07 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -148,6 +148,27 @@ func (c Config) delegateGuides() string { return strings.Join(items, "\n") } +// delegateKeepsBranch says how a tree program's work comes home: ON ITS +// BRANCH, and never merged into the person's checkout by codeaf. +// +// A PROGRAM'S HOUR OF WORK IS NOT MERGED BEHIND ANYBODY'S BACK. A merge at the +// end of an hour meets whatever happened to the checkout in that hour — the +// person's own edits, another task's landing, a conversation that kept working +// — and a clash then turned a finished run into one that read as failed, with +// its work parked on a branch anyway. So the branch is the landing: one +// squashed commit, reachable from the folder the task was proposed on, and the +// conversation or the person merges it when they choose. Nothing can conflict +// at the landing, because the landing writes nothing of anybody's. +func delegateKeepsBranch(via *delegate.Delegate, plain bool) bool { + return via != nil && via.LandsTree() && !plain +} + +// branchOnlySentence is what a person is told about work that landed as its +// branch: where it is, and that it is theirs to bring in. +func branchOnlySentence(branch, root string) string { + return "its work is on the branch " + branch + " in " + root + "; nothing was merged into your checkout" +} + // DelegateUnknownError is the refusal for a `via` or a command naming no // program this build carries. It names the ones it does, sorted, so the next // attempt has the words in front of it. diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index ff902d658..3fe695dbb 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -5,6 +5,7 @@ import ( "flag" "os" "path/filepath" + "slices" "strings" "testing" @@ -27,10 +28,11 @@ func testPrograms(name string) []delegate.Delegate { // The whole road from the door to the branch: `/fake <brief>` starts a run // whose spec names the delegate, the program's own commits in the copy are // squashed into ONE commit whose subject is the task's title and whose body is -// the run's result, and that commit comes home to the folder the copy was cut -// from. The engine is a double whose `work` hook plays the program: two files, -// two commits, the way senior-dev commits every edit. -func TestADelegatedRunSquashesTheProgramsCommitsIntoOneAndLandsIt(t *testing.T) { +// the run's result, and that commit lands AS ITS BRANCH in the repository the +// copy was cut from — never merged into the person's checkout. The engine is a +// double whose `work` hook plays the program: two files, two commits, the way +// senior-dev commits every edit. +func TestADelegatedRunSquashesTheProgramsCommitsAndLandsThemAsABranch(t *testing.T) { // The double answers the run's result off the completer it is handed, so // the result is scripted there: the sentence the landing commit must carry. const result = "submitted and verified. fake's model said: tests pass" @@ -74,34 +76,46 @@ func TestADelegatedRunSquashesTheProgramsCommitsIntoOneAndLandsIt(t *testing.T) if spec.Brief != "add two files to the project" { t.Fatalf("brief = %q", spec.Brief) } + // The folder the task was proposed on is handed over in its spellings, so + // the program's brief names its copy wherever it named the folder. + if !slices.Contains(spec.Ground, canonicalPath(conversation)) || canonicalPath(spec.Workspace) == canonicalPath(conversation) { + t.Fatalf("spec.Ground = %q for a copy at %q, want the proposed folder's spellings", spec.Ground, spec.Workspace) + } endBeltRun(t, agent, double) - // ONE COMMIT ABOVE THE BASE, and it is codeaf's landing commit, not the - // program's two. - log := gitOut(t, conversation, "log", "--format=%s%n%b", base+"..HEAD") - if strings.Contains(log, "wip(edit)") { - t.Fatalf("the program's own commits reached the branch:\n%s", log) - } - subjects := strings.TrimSpace(gitOut(t, conversation, "log", "--format=%s", base+"..HEAD")) - lines := strings.Split(subjects, "\n") - // A merge may add its own commit above the squash; the squash itself is - // exactly one, and it is the task's title. - found := 0 - for _, line := range lines { - if strings.HasPrefix(line, "task: ") { - found++ + // THE CHECKOUT IS UNTOUCHED: nothing was merged into it. + if head := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "HEAD")); head != base { + t.Fatalf("the person's checkout moved from %s to %s; a program's work lands as its branch", base, head) + } + for _, name := range []string{"one.txt", "two.txt"} { + if _, err := os.Stat(filepath.Join(conversation, name)); !os.IsNotExist(err) { + t.Fatalf("%s was written into the person's checkout: %v", name, err) } } - if found != 1 { - t.Fatalf("want exactly one `task:` commit above the base, got %d in:\n%s", found, subjects) + // ONE COMMIT ON THE TASK'S BRANCH ABOVE THE BASE, and it is codeaf's + // landing commit, not the program's two. + branches := strings.Fields(gitOut(t, conversation, "branch", "--format=%(refname:short)", "--list", "task/*")) + if len(branches) != 1 { + t.Fatalf("want the task's one branch in the repository, got %q", branches) + } + log := gitOut(t, conversation, "log", "--format=%s%n%b", base+".."+branches[0]) + subjects := strings.Fields(gitOut(t, conversation, "rev-list", base+".."+branches[0])) + if len(subjects) != 1 || strings.Contains(log, "wip(edit)") || !strings.HasPrefix(log, "task: ") { + t.Fatalf("the branch holds %d commits above the base, want one `task:` commit:\n%s", len(subjects), log) } if !strings.Contains(log, "fake's model said: tests pass") { t.Fatalf("the landing commit's body does not carry the run's result:\n%s", log) } - for _, name := range []string{"one.txt", "two.txt"} { - if _, err := os.Stat(filepath.Join(conversation, name)); err != nil { - t.Fatalf("%s did not come home: %v", name, err) - } + // AND THE PAGE SAYS WHERE IT IS. + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + var said []string + for _, n := range store.Notes(store.RootID(), 0) { + said = append(said, n.Body) + } + if joined := strings.Join(said, "\n"); !strings.Contains(joined, "its work is on the branch "+branches[0]) || + !strings.Contains(joined, "nothing was merged into your checkout") { + t.Fatalf("the run's notes = %q, want the branch it landed on", said) } } @@ -136,8 +150,8 @@ func TestADelegatedRunOnAPlainFolderIsToldSoAndLandsWhereItWorked(t *testing.T) double.mu.Lock() spec := double.spec double.mu.Unlock() - if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) { - t.Fatalf("spec = plain %v in %q, want the plain folder itself, said to be one", spec.PlainFolder, spec.Workspace) + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) || len(spec.Ground) != 0 { + t.Fatalf("spec = plain %v in %q, ground %q, want the plain folder itself, said to be one, and nothing to rewrite", spec.PlainFolder, spec.Workspace, spec.Ground) } endBeltRun(t, agent, double) @@ -182,6 +196,38 @@ func TestADelegatedRunOnARepositoryIsNotToldItIsPlain(t *testing.T) { } } +// A program that ended without finishing is drawn from its own words: the row +// names what it said, with no fault in front of it, and a crash is the fault +// it is. The row that read "a fault: ran and did not finish" over an hour of +// work that had said exactly why it would not stand told a person nothing. +func TestAProgramsOwnEndingIsTheRowsReasonAndNotAFault(t *testing.T) { + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) {}) + program := testPrograms("fake")[0] + run := &beltRun{row: 3, title: "the task", delegate: &program} + summary := RunSummary{Outcome: "ran and did not finish", Program: &ProgramEnding{ + Status: delegate.StatusFail, + Reason: "fake did not finish: submitted a change the project's own tests do not pass", + Result: "submitted a change the project's own tests do not pass. fake's model said: done", + }} + notice := agent.beltRunNotice(run, summary, RunLanding{}) + if notice.State != TaskFailed || notice.Ending != TaskEndingProgram { + t.Fatalf("notice = %s / %q, want failed on the program's own ending", notice.State, notice.Ending) + } + if reason := TaskReasonOf(notice.Ending, notice.Report); reason != summary.Program.Reason { + t.Fatalf("reason = %q, want the program's sentence %q", reason, summary.Program.Reason) + } + if taskEndingIsFault(notice.Ending) { + t.Fatal("a program judging its own work unfinished was drawn as a fault") + } + if note := beltRunOutcomeNote(nil, "", summary, RunLanding{}); !strings.HasPrefix(note, summary.Program.Reason) || strings.Contains(note, "ran and did not finish") { + t.Fatalf("the outcome note = %q, want the program's own words and not the run's generic one", note) + } + summary.Program.Status = delegate.StatusCrashed + if notice := agent.beltRunNotice(run, summary, RunLanding{}); notice.Ending != TaskEndingError { + t.Fatalf("a crash ended %q, want the fault it is", notice.Ending) + } +} + func TestStartDelegateRefusesANameThisMachineDoesNotHave(t *testing.T) { double := newBeltRunDouble("done") registerBeltRunEngine(t, double) diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index da0d298ba..8fd6cc5dc 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -307,6 +307,13 @@ const ( // Its reason names the dollar limit ([taskReasonCostLimit]), and the two // endings exist apart so a person who set both is told which one fired. TaskEndingCostLimit TaskEnding = "cost-limit" + // TaskEndingProgram says the program a task was handed to + // (delegate_door.go) ended it without finishing, and said why: its own + // check did not pass what it made, or it stopped on its own ceiling. The + // program's sentence is the reason ([TaskReasonOf]), and it is not a fault: + // nothing broke, a program judged its own work and said so, and what it + // made is on its branch. A program that crashed is [TaskEndingError]. + TaskEndingProgram TaskEnding = "program" // TaskEndingError is everything else: a working copy that could not be // made, a worker that would not start, an error nobody classified. TaskEndingError TaskEnding = "error" diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 2e4b17e23..7df61f9b8 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -8168,6 +8168,11 @@ type taskTree struct { // ([stageTaskWork]). Every other worker's ledger is complete by // construction, and its landing reads the ledger alone, exactly as before. bashBelt bool + // keepsBranch is a copy whose work lands AS ITS BRANCH and is never merged: + // the branch is put where the person's repository can reach it and the + // copy is given back, and bringing it in is the person's call. A program's + // run is landed this way (delegate_door.go's [delegateKeepsBranch]). + keepsBranch bool } // gitRoot is the in-process half of the root repository's lock, and the file @@ -8711,20 +8716,14 @@ func (t taskTree) comeHome(title string, wrote []string, sign bool) (string, str return mergeConflicted, withReport(withReport(unreachedSentence(t.branch, t.dir, out), stranded), leftBehindSentence(left, true)), nil, refusedByTheWork } - // A TASK NEVER WRITES A PROTECTED, MOVED OR DETACHED CHECKOUT. The branch is - // already committed and present in the ground repository at this point, so - // keeping it gives the person a durable result and gives the working copy - // back without changing a byte of the checkout they are using. - if t.landsInThePersonsRepository() { - if kept := t.keptLandingSentence(); kept != "" { - t.releaseKeptLocked() - // refusedNothing: the landing was not refused, it was HONOURED. The - // work is committed on its branch and the person has been told which - // one — a refusal here would put a policy keep on the unsaved road - // (task_land_unsaved.go) and offer to try it again, which is the one - // thing that must not happen to a checkout codeaf will not write. - return mergeKept, withReport(withReport(kept, stranded), leftBehindSentence(left, true)), nil, refusedNothing - } + if kept := t.keptInsteadOfMerged(); kept != "" { + t.releaseKeptLocked() + // refusedNothing: the landing was not refused, it was HONOURED. The + // work is committed on its branch and the person has been told which + // one — a refusal here would put a policy keep on the unsaved road + // (task_land_unsaved.go) and offer to try it again, which is the one + // thing that must not happen to a checkout codeaf will not write. + return mergeKept, withReport(withReport(kept, stranded), leftBehindSentence(left, true)), nil, refusedNothing } // AND THE MERGE IS THE CARRY-OR-REFUSE ONE (groundcarry.go). The ground a // task was carved from is the ground it merges into: work of the person's own @@ -8768,6 +8767,25 @@ func (t taskTree) comeHome(title string, wrote []string, sign bool) (string, str leftBehindSentence(left, false)), nil, refusedNothing } +// keptInsteadOfMerged is the sentence for a branch that lands by being kept +// rather than merged, and "" for one that is merged. It is asked once the +// branch is committed and present in the ground repository, so keeping it +// gives the person a durable result and gives the working copy back without +// changing a byte of the checkout they are using. +func (t taskTree) keptInsteadOfMerged() string { + // A COPY THAT LANDS AS ITS BRANCH is kept whatever the checkout looks like: + // the branch in the person's repository is the whole landing it was + // promised, and nothing of theirs is merged into. + if t.keepsBranch { + return branchOnlySentence(t.branch, t.root) + } + // A TASK NEVER WRITES A PROTECTED, MOVED OR DETACHED CHECKOUT. + if t.landsInThePersonsRepository() { + return t.keptLandingSentence() + } + return "" +} + // landMirror brings a mirrored folder home: the files the node wrote, laid over // the ground BY NAME, and the ones it wrote and then deleted taken away again. // diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index c7adac451..72acbe0b7 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -35,6 +35,7 @@ import ( "fmt" "math" "os" + "path/filepath" "strconv" "strings" "time" @@ -128,6 +129,24 @@ type RunSpec struct { // program is started with its own flags for one // (delegate.Delegate.PlainFolder). False for every other run. PlainFolder bool + // Ground is every spelling of the folder a delegated run's task was + // proposed on, when the program works in a copy of it: the brief it is + // handed names the copy wherever it named the folder + // (delegate.RehomeBrief). Empty for every other run. + Ground []string +} + +// ProgramEnding is a delegated run's program's own ending when it did not +// finish, as the engine read it off the program's terminal record: the status +// word, the one sentence the row says, and the program's account. +type ProgramEnding struct { + // Status is delegate.StatusFail, StatusBudget, StatusCrashed, or a word + // this build does not know. + Status string + // Reason is the sentence: `senior-dev did not finish: …`. + Reason string + // Result is the program's account in full. + Result string } // RunLimit is which bound a person set ended a run. The engine's outcome word @@ -154,6 +173,9 @@ type RunSummary struct { Result string // Limit is empty on every run that did not end on a bound its person set. Limit RunLimit + // Program is how a delegated run's program ended when it did not finish, + // nil otherwise ([ProgramEnding]). + Program *ProgramEnding // Cut is every task the run's own ending cut mid-flight, by store id: the // same typed fact as the limit, read where the run recorded it. A joined // row in this set is drawn with the run's own ending and never as a fault. @@ -253,6 +275,10 @@ type beltRun struct { // ([delegateOnPlainFolder]): it is told so on its line, and its landing // commits nothing, because the work is already where it belongs. plain bool + // groundNames is every spelling of the folder a tree program's task was + // proposed on, when the program works in a copy of it + // ([delegateGroundNames]); empty otherwise. + groundNames []string } // startTaskRun is StartTask's second road, taken whenever the bash belt is asked @@ -343,6 +369,8 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie // THE COPY IS A SHELL WORKER'S, so its landing stages the tree's own status: // a run's workers edit through bash and fill no write ledger. tree.bashBelt = true + // AND A PROGRAM'S WORK LANDS AS ITS BRANCH ([delegateKeepsBranch]). + tree.keepsBranch = delegateKeepsBranch(via, delegateOnPlainFolder(tree, via)) // THE RUN'S CONTEXT IS ONE A PERSON'S STOP CAN CUT. It outlives the turn that // started it, which is the caller's business (task.go hands this door a // context no turn's ending cancels); what it must not outlive is the person @@ -355,6 +383,9 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie born: born, delegate: via, startSha: delegateStartSha(tree, via), plain: delegateOnPlainFolder(tree, via), } + if via != nil && via.LandsTree() && !run.plain { + run.groundNames = delegateGroundNames(stand.dir, tree.dir) + } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because // the branch it names exists only in this variable until it is: the road that @@ -487,9 +518,35 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { Serves: a.servesModel, Delegate: run.delegate, PlainFolder: run.plain, + Ground: run.groundNames, } } +// delegateGroundNames is every way a brief is likely to spell the folder a +// tree program's task was proposed on: as the proposal named it, absolute, +// with its links resolved, and under ~. It is empty when the program works in +// that folder itself, where there is nothing to rewrite. +func delegateGroundNames(proposed, copyDir string) []string { + proposed = strings.TrimSpace(proposed) + if proposed == "" || canonicalPath(proposed) == canonicalPath(copyDir) { + return nil + } + names := []string{proposed, canonicalPath(proposed)} + home, _ := os.UserHomeDir() + home = strings.TrimRight(home, "/") + if abs, err := filepath.Abs(proposed); err == nil && !strings.HasPrefix(proposed, "~") { + names = append(names, abs) + } + if home != "" { + for _, name := range append([]string(nil), names...) { + if rest, ok := strings.CutPrefix(name, home+"/"); ok { + names = append(names, "~/"+rest) + } + } + } + return names +} + // openBeltRunStore opens the conversation's store for a run, creating it under // this run's root or adopting the live one already there. It is [planSeed]'s own // road stated for the run door: a store whose root has ended is archived beside @@ -691,6 +748,20 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { // the sentence the engine answered is the whole account. return landing } + if merge == mergeKept && run.tree.keepsBranch { + // A BRANCH-ONLY LANDING IS A LANDING, not a refusal: the work is on its + // branch in the person's repository, which is where it was promised. + landing.Home = merge + if run.tree.branch != "" { + landing.Branch = run.tree.branch + } + if _, err := run.store.AddNote(run.root, run.root, said); err != nil { + if g := a.graph(); g != nil { + g.planNote("the run's homecoming note failed: " + err.Error()) + } + } + return landing + } if merge != mergeMerged && merge != mergeInPlace { // THE WORK DID NOT GO IN, AND THE OUTCOME NOTE SAYS SO IN THE ROAD'S OWN // SENTENCE, which names the kept branch and what it clashed with. It is @@ -847,9 +918,16 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi if summary.Outcome != beltRunOutcomeDone { state = TaskFailed } - report := strings.TrimSpace(summary.Result) - if report == "" && summary.Outcome != beltRunOutcomeDone { - report = strings.TrimSpace(summary.Outcome) + outcome, result := runEndingWords(summary) + report := result + if summary.Outcome != beltRunOutcomeDone { + if summary.Program != nil { + // THE PROGRAM'S OWN SENTENCE LEADS, and its account follows: the + // reason line a surface draws is the report's first line. + report = strings.TrimSpace(outcome + "\n" + result) + } else if report == "" { + report = outcome + } } if line := beltLandingLine(landing); line != "" { if report != "" { @@ -864,7 +942,7 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi // ([TaskReasonOf]): the outcome word alone says only that one of them // fired. The ending comes from the summary's own fact and never out of // the outcome sentence. - Ending: beltRunLimitEnding(summary.Limit), + Ending: beltRunEnding(summary), Report: report, Result: summary.Result, Changed: landing.Changed, } @@ -881,6 +959,34 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi return notice } +// beltRunEnding is the run row's ending: a limit its person set, or how the +// program a delegated run was handed to ended it — a crash is the fault it is, +// and every other ending of the program's own is [TaskEndingProgram], whose +// reason is the program's sentence. Empty for every other run. +func beltRunEnding(summary RunSummary) TaskEnding { + if ending := beltRunLimitEnding(summary.Limit); ending != "" { + return ending + } + if ended := summary.Program; ended != nil && summary.Outcome != beltRunOutcomeDone { + if ended.Status == delegate.StatusCrashed { + return TaskEndingError + } + return TaskEndingProgram + } + return "" +} + +// runEndingWords is a run's ending in the two parts every drawing of it reads: +// the one sentence, and the account under it. A program that ended its run +// unfinished speaks for itself; every other run answers the engine's outcome +// word and the root's result. +func runEndingWords(summary RunSummary) (string, string) { + if ended := summary.Program; ended != nil && summary.Outcome != beltRunOutcomeDone { + return strings.TrimSpace(ended.Reason), strings.TrimSpace(ended.Result) + } + return summary.Outcome, strings.TrimSpace(summary.Result) +} + // beltRunLimitEnding is the run row's ending for a limit its person set, off // the summary's own fact. Empty, which no reading knows as an ending, is the answer for // every run that did not end on a bound, which is the reading those runs always @@ -900,8 +1006,9 @@ func beltRunLimitEnding(limit RunLimit) TaskEnding { // says why it did not. The last stored run reading supplies its Now sentence; // without one this remains the landing digest that predates run summaries. func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, landing RunLanding) string { - parts := []string{summary.Outcome} - if result := strings.TrimSpace(summary.Result); result != "" { + outcome, result := runEndingWords(summary) + parts := []string{outcome} + if result != "" { parts = append(parts, result) } if line := beltLandingLine(landing); line != "" { diff --git a/internal/session/task_status.go b/internal/session/task_status.go index dbb29cb3d..c3d7544bc 100644 --- a/internal/session/task_status.go +++ b/internal/session/task_status.go @@ -552,7 +552,7 @@ func taskEndingIsFault(ending TaskEnding) bool { switch ending { case TaskEndingStopped, TaskEndingWire, TaskEndingUpstream, TaskEndingCircling, TaskEndingBlocked, TaskEndingSteps, TaskEndingNotes, TaskEndingRefused, TaskEndingStale, - TaskEndingInterrupted, TaskEndingTimeLimit, TaskEndingCostLimit: + TaskEndingInterrupted, TaskEndingTimeLimit, TaskEndingCostLimit, TaskEndingProgram: return false } return true @@ -765,8 +765,11 @@ const ( // taskReasonGaps and taskReasonFault are the two the ending alone cannot // answer: what the check found, and what broke. Both read the landing's own // report, which is the only place either sentence exists. - taskReasonGaps = "the check found gaps: " - taskReasonFault = "a fault" + // taskReasonProgram is [TaskEndingProgram]'s reason when the program left + // no sentence, which the worker never lets happen. + taskReasonProgram = "the program it was handed to did not finish it" + taskReasonGaps = "the check found gaps: " + taskReasonFault = "a fault" ) // The six questions a your-call row can be asking, and the two answers each one @@ -844,32 +847,50 @@ const ( // TaskReasonOf is the incomplete reason sentence for one ending, in the person's // own words, and it is the ONE place that table is written down. // -// The report is read for the two endings whose reason is not knowable from the -// word alone: a check that named gaps, whose finding is the first line of its own -// report, and a fault, whose first line is the only account of what broke. A +// The report is read for the three endings whose reason is not knowable from +// the word alone: a check that named gaps, whose finding is the first line of its +// own report, a program's own ending, whose sentence is that first line, and a +// fault, whose first line is the only account of what broke. A // stop is not here at all — `stopped` is its own word, not a kind of incomplete. func TaskReasonOf(ending TaskEnding, report string) string { + if reason, fixed := taskReasonOfEnding(ending); fixed { + return reason + } + return taskReasonOfReport(ending, report) +} + +// taskReasonOfEnding is every ending whose reason is one fixed sentence, which +// the ending alone answers. +func taskReasonOfEnding(ending TaskEnding) (string, bool) { switch ending { case TaskEndingStopped: - return "" + return "", true case TaskEndingInterrupted: - return taskReasonInterrupted + return taskReasonInterrupted, true case TaskEndingWire: - return taskReasonWire + return taskReasonWire, true case TaskEndingUpstream: - return taskReasonUpstream + return taskReasonUpstream, true case TaskEndingCircling: - return taskReasonCircling + return taskReasonCircling, true case TaskEndingBlocked: - return taskReasonBlocked + return taskReasonBlocked, true case TaskEndingSteps: - return taskReasonSteps + return taskReasonSteps, true case TaskEndingNotes: - return taskReasonNotes + return taskReasonNotes, true case TaskEndingStale: - return taskReasonStale + return taskReasonStale, true case TaskEndingTimeLimit, TaskEndingCostLimit: - return taskLimitReason(ending) + return taskLimitReason(ending), true + } + return "", false +} + +// taskReasonOfReport is every ending whose reason is read out of the landing's +// own report, which is the only place the sentence exists. +func taskReasonOfReport(ending TaskEnding, report string) string { + switch ending { case TaskEndingRefused: // THE CHECK'S OWN FINDING OUTRANKS THE WORD FOR IT. "Refused" is the // engine's name for both a check that named gaps and a worker that would @@ -879,6 +900,13 @@ func TaskReasonOf(ending TaskEnding, report string) string { return taskReasonGaps + gaps } return taskReasonRefused + case TaskEndingProgram: + // THE PROGRAM'S OWN SENTENCE IS THE REASON, first line of the report + // (task_run_belt.go's [runEndingWords]), and never under "a fault". + if line := taskFirstLine(report); line != "" { + return line + } + return taskReasonProgram } // An error, or a node that named no ending at all. The gaps are read first // because a landing carrying them was looked at, whatever else went wrong diff --git a/internal/tui3/taskending.go b/internal/tui3/taskending.go index ec9676155..2568df5ee 100644 --- a/internal/tui3/taskending.go +++ b/internal/tui3/taskending.go @@ -34,6 +34,7 @@ const ( endingWordRefused = "not accepted" endingWordStale = "its world did not match" endingWordError = "ended with an error" + endingWordProgram = "did not finish" ) // endingWord is the two-or-three-word reason a failed node's row leads with, @@ -61,6 +62,8 @@ func endingWord(ending session.TaskEnding) string { return endingWordStale case session.TaskEndingError: return endingWordError + case session.TaskEndingProgram: + return endingWordProgram } return "" } From 4576593c5069e0b6138028ed8fa50c81a629453b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:25:20 -0400 Subject: [PATCH 050/195] delegate: a program works on the conversation's crew by default A run a conversation starts hands its program the crew (brain, hands, light) through the program's own CrewFlags. senior-dev routes on the worker model, uses the mastermind for its frontier tier and the low model for summaries; with --crew it skips a model its catalog cannot size and falls back to its own list only when no working model is left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried_seniordev_worker_test.go | 62 +++++++++++++++++++++ docs/design/delegate/PROTOCOL.md | 9 +++ internal/delegate/cli.go | 21 +++++-- internal/delegate/cli_test.go | 27 ++++++++- internal/delegate/delegate.go | 55 +++++++++++++++--- internal/delegate/launch_test.go | 2 +- internal/manual/chat/senior-dev.md | 29 +++++++--- internal/manual/chat_test.go | 2 + internal/run/delegateworker.go | 7 ++- internal/run/enginewire.go | 1 + internal/seniordev/app/args.go | 50 ++++++++++++++++- internal/seniordev/app/crew_test.go | 49 ++++++++++++++++ internal/seniordev/app/run.go | 14 +++++ internal/seniordev/crew_test.go | 23 ++++++++ internal/seniordev/seniordev.go | 21 +++++++ internal/session/delegate_door_test.go | 21 +++++++ internal/session/task_run_belt.go | 27 +++++++++ 17 files changed, 396 insertions(+), 24 deletions(-) create mode 100644 internal/seniordev/app/crew_test.go create mode 100644 internal/seniordev/crew_test.go diff --git a/cmd/codeaf/carried_seniordev_worker_test.go b/cmd/codeaf/carried_seniordev_worker_test.go index dbfb56c15..e71a6b73f 100644 --- a/cmd/codeaf/carried_seniordev_worker_test.go +++ b/cmd/codeaf/carried_seniordev_worker_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -179,6 +180,67 @@ func TestSeniorDevWorksAPlainFolderAsTheChatsRunWorker(t *testing.T) { } } +// 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 diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index f5efe447c..ed218fdde 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -66,6 +66,15 @@ person's checkout: the page says `its work is on the branch <branch> in <folder>; nothing was merged into your checkout`. An hour-long run meeting the checkout's hour of changes at a merge was a finished run reading as failed. +**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 <hands> +--frontier <brain> --low <light>`, 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 diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index 9d3810dd4..fd1063daf 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -117,9 +117,10 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) // 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. // -// plain says the folder has no git history, and puts the program's own -// [Delegate.PlainFolder] flags on the line after codeaf's. -func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings, plain bool) []string { +// 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)) @@ -127,12 +128,24 @@ func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings, pla if ceilings.Hours > 0 { args = append(args, "--max-hours", strconv.FormatFloat(ceilings.Hours, 'f', -1, 64)) } - if plain { + 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. // diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index 93556c44f..f989ae58c 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -65,7 +65,7 @@ func TestParseTakesANamedCommandAndItsOwnFlags(t *testing.T) { // 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}, false) + 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) } @@ -88,10 +88,10 @@ func TestChildArgsCarryThePlainFolderFlagsOnlyForAPlainFolder(t *testing.T) { 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{}, false); strings.Contains(strings.Join(line, " "), "--variant") { + 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{}, true) + 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) } @@ -103,6 +103,27 @@ func TestChildArgsCarryThePlainFolderFlagsOnlyForAPlainFolder(t *testing.T) { } } +// 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) { diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index d2b40b3df..cdd10d70f 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -89,6 +89,17 @@ type Delegate struct { // where it is, 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 + // 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 // Default is the command a bare brief runs: `/<name> <brief>` in the chat // and `codeaf <name> <brief>` in a shell. It names one of Commands. Default string @@ -130,6 +141,21 @@ type Body func(ctx context.Context, host Host, args []string) error // 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 +} + +// IsZero says the crew names no model at all, so no flag is owed for it. +func (c Crew) IsZero() bool { return c == Crew{} } + // 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 @@ -196,18 +222,33 @@ func (d Delegate) Validate() error { if !seen[d.Default] { return fmt.Errorf("%s: the default command %q is not one of its commands", d.Name, d.Default) } - if len(d.PlainFolder) > 0 { - // THE FLAGS ARE PARSED BY THE COMMAND THEY WILL BE HANDED TO, so a - // misspelt one fails here, in the build's own test, and never as a - // run that dies on its first line in somebody's folder. - command, _ := d.Command(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) - if err := fs.Parse(d.PlainFolder); err != nil || fs.NArg() > 0 { - 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 fs.Parse(flags) == nil && fs.NArg() == 0 + } + if d.CrewFlags != nil { + sample := Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"} + 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 } diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go index 680875236..d9097df20 100644 --- a/internal/delegate/launch_test.go +++ b/internal/delegate/launch_test.go @@ -41,7 +41,7 @@ func fakeLaunch(t *testing.T, script, workspace, brief string, ceilings Ceilings return Launch{ Name: "fake", Bin: script, - Args: ChildArgs(program, workspace, brief, ceilings, false), + Args: ChildArgs(program, workspace, brief, ceilings, RunFacts{}), Env: ChildEnv(api), Dir: workspace, } diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 291eb72c6..7cee2e097 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -140,12 +140,25 @@ of its time to land: two fifteenths of the run, at least 45 seconds, at most 12 and never more than a quarter of it. When that window opens it gets one last turn to submit. -senior-dev picks its model call by call from its own list of open models, and avoids one -for a while after it fails. `--high` replaces the list, and `--variant` sets the -reasoning effort every call asks for. **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 conversation on the task page names the model that -answered. A call is never refused only because this machine does not know a model's id. +**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 +conversation on the task page names the model that answered. Which models it asks for is +the next section. + +## Which models does senior-dev use — your crew, its own list, --high + +**From the chat it uses your crew.** codeaf hands senior-dev the conversation's crew: the +worker (hands) model is the one it works with, the mastermind (brain) model its hardest +calls, and the low model its history summaries. Change the crew and the next run follows. +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, `--frontier` and `--low` set the +other two, and `--variant` sets the reasoning effort every call asks for. ## senior-dev's flags — run, --variant, --in-place, --high, --max-cost @@ -163,7 +176,9 @@ senior-dev's own flags on `run`: - `--in-place` — work in a folder without git: no commits, and its checkpoints kept outside the folder; - `--high`, `--low`, `--frontier` — comma-separated models it routes among; `--low` - (its history summaries) and `--frontier` fall back to `--high`. + (its history summaries) and `--frontier` fall back to `--high`; +- `--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. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index f5e03e242..f2a4ad32f 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -930,6 +930,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"which folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 500c085e3..79e5dfb30 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -102,6 +102,10 @@ type DelegateSetup struct { // (session.RunSpec.PlainFolder), so the program's line carries its own // flags for one (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 // Ground is every spelling of the folder the task was proposed on, when the // program works in a copy of it (session.RunSpec.Ground). The brief is // rewritten to name the copy wherever it named that folder @@ -309,7 +313,8 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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()}, w.setup.PlainFolder), + 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()), diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 6675bdf34..96e237bfb 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -54,6 +54,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar Seat: WorkSeat(spec.ProfileDir, spec.WorkModel), PlainFolder: spec.PlainFolder, Ground: spec.Ground, + Crew: spec.Crew, } factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, setup, limits, factory) } diff --git a/internal/seniordev/app/args.go b/internal/seniordev/app/args.go index e94c953f8..4cc67a624 100644 --- a/internal/seniordev/app/args.go +++ b/internal/seniordev/app/args.go @@ -2,7 +2,13 @@ package app -import "strings" +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 @@ -30,6 +36,48 @@ type cliArgs struct { 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, ",") { diff --git a/internal/seniordev/app/crew_test.go b/internal/seniordev/app/crew_test.go new file mode 100644 index 000000000..25f90913e --- /dev/null +++ b/internal/seniordev/app/crew_test.go @@ -0,0 +1,49 @@ +//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()) + } +} diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go index e86d41e02..6aa2b3981 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -63,6 +63,11 @@ type Options struct { // 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 } // Run runs senior-dev once in the host's workspace and answers how it ended. @@ -135,6 +140,15 @@ func runWith(ctx context.Context, host delegate.Host, options Options, notes io. } client.catalog = catalog model = client + if options.Crew { + args = crewPools(args, 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 + }, notes) + } } runner := newPipeline(args, workspace, pipelineDeps{ diff --git a/internal/seniordev/crew_test.go b/internal/seniordev/crew_test.go new file mode 100644 index 000000000..ac1356fd6 --- /dev/null +++ b/internal/seniordev/crew_test.go @@ -0,0 +1,23 @@ +//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 planning seat its frontier, the light seat its summaries, +// and a seat left unset keeps senior-dev's own default. +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 --frontier openrouter/vendor/brain --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) + } +} diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 04c4ea810..b344886de 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -50,11 +50,30 @@ var Program = delegate.Delegate{ // checkpoints outside the folder and commits nothing; a folder with no git // history has nothing else it can run on. PlainFolder: []string{"--in-place"}, + CrewFlags: crewFlags, Default: "run", Page: "senior-dev", Commands: []delegate.Command{runCommand}, } +// crewFlags is the conversation's crew as senior-dev's own flags: the working +// seat is the pool the coder routes on (--high), the planning seat its frontier +// tier, and the light seat the history summaries (--low). --crew says the pools +// came from a crew, so a model senior-dev's catalog cannot size is left out +// rather than failing the run. A seat the crew leaves unset keeps senior-dev's +// own default for it. +func crewFlags(crew delegate.Crew) []string { + flags := []string{"--crew"} + for _, seat := range []struct{ flag, model string }{ + {"--high", crew.Hands}, {"--frontier", crew.Brain}, {"--low", crew.Light}, + } { + if model := app.CrewModel(seat.model); model != "" { + flags = append(flags, seat.flag, model) + } + } + return flags +} + // runCommand is senior-dev's one verb: the whole run, from the brief to the // terminal record. codeaf owns --dir, --max-cost, --max-hours and --json; the // flags here are senior-dev's own. @@ -72,6 +91,7 @@ func bindRun(fs *flag.FlagSet) delegate.Body { high := fs.String("high", app.DefaultHighModels, "models the coder routes among, comma-separated") low := fs.String("low", "", "models for the history summary (default: --high)") frontier := fs.String("frontier", "", "models for the frontier tier (default: --high)") + crew := fs.Bool("crew", false, "the models came from codeaf's crew: skip any it cannot size") return func(ctx context.Context, host delegate.Host, args []string) error { run(ctx, host, app.Options{ Goal: strings.Join(args, " "), @@ -80,6 +100,7 @@ func bindRun(fs *flag.FlagSet) delegate.Body { Frontier: *frontier, Variant: *variant, InPlace: *inPlace, + Crew: *crew, }, os.Stderr) return nil } diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 3fe695dbb..566208bb4 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -228,6 +228,27 @@ func TestAProgramsOwnEndingIsTheRowsReasonAndNotAFault(t *testing.T) { } } +// A program is handed the conversation's crew — its planning, working and +// light seats, with any effort taken off — and a run no program works is handed +// none. +func TestAProgramIsHandedTheConversationsCrew(t *testing.T) { + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.RolesSource = tierSettings(map[string]string{ + "tiers.mastermind": "vendor/brain:high", + "tiers.worker": "vendor/hands", + "tiers.low": "vendor/light", + }) + }) + program := testPrograms("fake")[0] + got := agent.delegateCrew(&beltRun{delegate: &program}) + if want := (delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"}); got != want { + t.Fatalf("crew = %+v, want %+v", got, want) + } + if got := agent.delegateCrew(&beltRun{}); !got.IsZero() { + t.Fatalf("a run no program works was handed a crew: %+v", got) + } +} + func TestStartDelegateRefusesANameThisMachineDoesNotHave(t *testing.T) { double := newBeltRunDouble("done") registerBeltRunEngine(t, double) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 72acbe0b7..564b6b93b 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -134,6 +134,10 @@ type RunSpec struct { // handed names the copy wherever it named the folder // (delegate.RehomeBrief). Empty for every other run. Ground []string + // Crew is the conversation's crew as a delegated run's program is handed it + // ([conversationCrew]), so the program works on the models the person + // chose. Zero for every other run. + Crew delegate.Crew } // ProgramEnding is a delegated run's program's own ending when it did not @@ -519,9 +523,32 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { Delegate: run.delegate, PlainFolder: run.plain, Ground: run.groundNames, + Crew: a.delegateCrew(run), } } +// delegateCrew is the conversation's crew as a delegated run hands it to its +// program: the planning seat, the working seat and the light seat, read off the +// same role ladder this conversation's own planner and workers resolve through, +// with each seat's effort taken off, because a program's pool is a list of +// models and an effort is a knob of the request. Zero for a run no program works. +// +// THE PERSON'S CREW IS THE DEFAULT. A program handed an hour of work used to +// route on a list of its own the person never chose, while the crew they set +// sat unread beside it. +func (a *Agent) delegateCrew(run *beltRun) delegate.Crew { + if run.delegate == nil { + return delegate.Crew{} + } + source := roles.Source(a.config.RolesSource) + seat := func(tier roles.Tier) string { + value, _ := roles.TierModel(source, tier) + model, _ := roles.SplitEffort(strings.TrimSpace(value)) + return strings.TrimSpace(model) + } + return delegate.Crew{Brain: seat(roles.TierMastermind), Hands: seat(roles.TierWorker), Light: seat(roles.TierLow)} +} + // delegateGroundNames is every way a brief is likely to spell the folder a // tree program's task was proposed on: as the proposal named it, absolute, // with its links resolved, and under ~. It is empty when the program works in From 9f283de3a32033f744908ab5e79202c8dd6c564d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:26:59 -0400 Subject: [PATCH 051/195] scripts: clean-run opens bin/codeaf on a fresh state root with only settings and keys Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Makefile | 8 ++++++- scripts/clean-run.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100755 scripts/clean-run.sh diff --git a/Makefile b/Makefile index c2a683da9..32e34be07 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/scripts/clean-run.sh b/scripts/clean-run.sh new file mode 100755 index 000000000..f14a71daa --- /dev/null +++ b/scripts/clean-run.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# clean-run.sh opens bin/codeaf as though it had never run on this machine, +# except that it still knows who you are: your settings (models, crew, +# services) and your keys are copied into a fresh state root, and nothing else +# is. No conversation, project, task, memory, standing order or notice from +# ~/.codeaf is there, so every try of a new build starts from the same place. +# +# The state root is moved with CODEAF_HOME (internal/home), which moves every +# file codeaf writes and leaves HOME alone, so git, your shell and caches +# outside codeaf behave exactly as they do every day. The copy is left behind +# after codeaf exits, and its path is printed, so a run can be looked at later. +# +# scripts/clean-run.sh [codeaf arguments...] +# CLEAN_FROM=~/.codeaf where the settings and keys are copied from +# CLEAN_INTO=<dir> use this folder instead of a fresh one (emptied first) +# CODEAF_BIN=<path> the binary to open (default: bin/codeaf beside this script) +set -eu + +here=$(cd "$(dirname "$0")/.." && pwd) +from=${CLEAN_FROM:-$HOME/.codeaf} +bin=${CODEAF_BIN:-$here/bin/codeaf} + +if [ ! -x "$bin" ]; then + echo "clean-run: no binary at $bin; run make build first" >&2 + exit 1 +fi +if [ -n "${CLEAN_INTO:-}" ]; then + into=$CLEAN_INTO + rm -rf "$into" + mkdir -p "$into" +else + into=$(mktemp -d "${TMPDIR:-/tmp}/codeaf-clean.XXXXXX") +fi + +# WHAT MAKES IT YOURS, AND NOTHING THAT MAKES IT USED: the settings file, the +# keys, the model services you connected, the tool servers you added, and the +# model catalog cache (a copy only saves the first launch a fetch). Every +# conversation, project and ledger lives elsewhere under the root and stays out. +for name in config.json credentials.json connections.json toolservers.json model-catalog.json model-quirks.json; do + if [ -f "$from/$name" ]; then + cp -p "$from/$name" "$into/$name" + fi +done +chmod 700 "$into" + +echo "clean-run: codeaf state in $into (settings and keys from $from)" >&2 +status=0 +CODEAF_HOME=$into "$bin" "$@" || status=$? +echo "clean-run: that run's state is kept in $into" >&2 +exit $status From cb16b0c837d15a8ee58f1bc71fbccca1e37bf227 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:34:02 -0400 Subject: [PATCH 052/195] enginehost: a socket path may weigh 103 bytes, not 104 macOS's sun_path is 104 bytes including the terminating NUL. At 104 SocketPathFits answered yes for a path bind() refuses, so a state root whose socket path came to exactly 104 bytes started no host, took no fallback, and codeaf did not open. clean-run.sh made exactly such a root under $TMPDIR; it now uses /tmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/enginehost/enginehost.go | 20 ++++++++++++------- internal/enginehost/enginehost_test.go | 13 ++++++++++++ .../manual/chat/staying-on-that-machine.md | 13 ++++++------ scripts/clean-run.sh | 6 +++++- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index 10a5b5555..29a4be571 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 124c0838b..f87792ab9 100644 --- a/internal/enginehost/enginehost_test.go +++ b/internal/enginehost/enginehost_test.go @@ -674,3 +674,16 @@ 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") + } +} diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index 59e10ee3a..d291384f1 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 @@ -542,9 +542,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 @@ -553,7 +554,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 @@ -565,7 +566,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/scripts/clean-run.sh b/scripts/clean-run.sh index f14a71daa..68773e75c 100755 --- a/scripts/clean-run.sh +++ b/scripts/clean-run.sh @@ -29,7 +29,11 @@ if [ -n "${CLEAN_INTO:-}" ]; then rm -rf "$into" mkdir -p "$into" else - into=$(mktemp -d "${TMPDIR:-/tmp}/codeaf-clean.XXXXXX") + # /tmp AND NOT $TMPDIR: macOS's $TMPDIR is a long path under /var/folders, + # and codeaf's session host listens on a unix socket under this folder, + # whose path may weigh at most 103 bytes (internal/enginehost). A root + # there left no room for the socket. + into=$(mktemp -d /tmp/codeaf-clean.XXXXXX) fi # WHAT MAKES IT YOURS, AND NOTHING THAT MAKES IT USED: the settings file, the From 14e1c342196dbaae09fef967d310e3ab300096c4 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:13:53 -0400 Subject: [PATCH 053/195] config: the talk lane's borrow row is read, and the model API pins are plumbing lane.talk.borrow is read by LaneBorrowAt and written by the lane page, but was missing from the consumed keys, so every profile the lane page wrote was warned at launch that it held an unread key. CODEAF_MODEL_API and CODEAF_MODEL_TOKEN are set only on a program's child; they join the operator plumbing the registry law demands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/config/config.go | 5 +++++ internal/config/config_test.go | 12 ++++++++++++ internal/config/settings.go | 6 ++++++ internal/config/testdata/profile-keys.ledger | 1 + 4 files changed, 24 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 1b76572e1..118337b97 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -364,6 +364,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 e9c4ba883..5fb12c494 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -649,6 +649,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) + } +} + // TestRetiredProfileKeysAreNotReportedUnread pins that a profile carrying a // retired key is silent, and that a genuinely unknown key is still named. func TestRetiredProfileKeysAreNotReportedUnread(t *testing.T) { diff --git a/internal/config/settings.go b/internal/config/settings.go index 8f53d85d8..e6d2e3f2b 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/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index 55d49ad2c..87685de44 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -27,6 +27,7 @@ google_oauth_secret history.enabled lane.guard lane.talk +lane.talk.borrow linear_mode memory.consolidation memory.enabled From 545ff02d1ef88816bf12ae07a0b9c9f96b87e1a4 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:37:43 -0400 Subject: [PATCH 054/195] run: a run whose own task failed is ended in its store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing wrote the ending of a run whose root worker failed, so the store said running for ever: senior-dev's page read 'running · x stop it' forty minutes after it ended, and the next hand-off would adopt the dead run. plandb.Store.FailRoot ends it (no result, open work cancelled); the supervisor calls it when the root fails outside a limit, and a program's run is always closed when its program ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/plandb/stoproot_test.go | 27 +++++++++++++++++ internal/plandb/store.go | 39 ++++++++++++++++++++++++ internal/run/review_test.go | 4 +++ internal/run/run.go | 12 ++++++++ internal/session/delegate_door_test.go | 41 ++++++++++++++++++++++++++ internal/session/task_run_belt.go | 9 ++++++ internal/session/task_run_belt_test.go | 5 +++- 7 files changed, 136 insertions(+), 1 deletion(-) diff --git a/internal/plandb/stoproot_test.go b/internal/plandb/stoproot_test.go index 9db8e6e2b..47de568cb 100644 --- a/internal/plandb/stoproot_test.go +++ b/internal/plandb/stoproot_test.go @@ -49,3 +49,30 @@ func TestStopRootEndsTheRunAndEverythingStillOpenUnderIt(t *testing.T) { t.Fatalf("a second stop rewrote the first one's reason: %q", root.Error) } } + +// 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) + } +} diff --git a/internal/plandb/store.go b/internal/plandb/store.go index 03aa1fdab..d6f910657 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -1547,6 +1547,45 @@ func (s *Store) StopRoot(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 the next hand-off would have +// adopted the dead run's store as live work ([Store.StopRoot] says why an open +// run is adopted). A run that already ended is left as it ended. +func (s *Store) FailRoot(reason string) 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 + } + 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 = now, now + } + root.Status, root.Error, root.ClaimedBy = StatusFailed, reason, "" + root.Owner, root.SeenAt = "", time.Time{} + root.UpdatedAt, root.CompletedAt = now, now + promote(next, now) + 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 diff --git a/internal/run/review_test.go b/internal/run/review_test.go index 1f06e5e8e..aa5250543 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 2168afeb3..22811fbb3 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -149,6 +149,9 @@ type Supervisor struct { // ended, when it ended without finishing ([ProgramEndedError]); nil for // every other run. rootProgram *ProgramEndedError + // rootFailure is the root worker's error when it failed, which the run's + // ending writes onto the root ([plandb.Store.FailRoot]). + rootFailure string // 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 @@ -380,6 +383,14 @@ func (s *Supervisor) pass(ctx context.Context, rootID string) Outcome { } if s.inFlight == 0 && (s.rootFailed || s.limitHit != "") { + if s.rootFailed && s.limitHit == "" { + // 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 the next hand-off + // would adopt it 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. + _ = 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. @@ -694,6 +705,7 @@ func (s *Supervisor) absorb(ret workerReturn) { s.addReviewCheck(ret.task, root.Result) } else { s.rootFailed = true + s.rootFailure = ret.err.Error() // 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]). diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 566208bb4..48fdfa6a5 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -6,10 +6,12 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "testing" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" ) // testPrograms is a build that carries one program called name. The session @@ -249,6 +251,45 @@ func TestAProgramIsHandedTheConversationsCrew(t *testing.T) { } } +// A PROGRAM'S RUN THAT DID NOT FINISH IS OVER ON ITS PAGE. The engine left its +// store open, the page read `running · … · x stop it` for forty minutes over a +// program that had ended, and the next hand-off would have adopted it. Its +// store's run task is now ended with the program's own sentence. +func TestAProgramsRunThatDidNotFinishIsEndedInItsStore(t *testing.T) { + double := newBeltRunDouble("") + double.leaveOpen = true + double.summary = RunSummary{Outcome: "ran and did not finish", Program: &ProgramEnding{ + Status: delegate.StatusFail, Reason: "fake did not finish: its tests fail", Result: "its tests fail", + }} + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + root := store.Task(store.RootID()) + if root.Status != plandb.StatusFailed || root.Error != "fake did not finish: its tests fail" { + t.Fatalf("the run's task = %s (%q), want failed with the program's own sentence", root.Status, root.Error) + } + page, ok := agent.PlanTaskPage(strconv.FormatUint(id, 10)) + if !ok || page.Row.Status != string(plandb.StatusFailed) { + t.Fatalf("the task's page row = %+v (%v), want it ended and not running", page.Row, ok) + } +} + func TestStartDelegateRefusesANameThisMachineDoesNotHave(t *testing.T) { double := newBeltRunDouble("done") registerBeltRunEngine(t, double) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 564b6b93b..e8aa0c21c 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -705,6 +705,15 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun var landing RunLanding if run.delegate != nil { landing = a.landDelegateRun(run, summary) + // A PROGRAM'S RUN IS OVER WHEN ITS PROGRAM IS, however it ended: it is + // a run of one task that nothing continues, so a store the engine left + // open — a program ended at the dollar ceiling leaves it so — is closed + // here, or its page would read `running` and offer `stop it` for ever. + // A run that already ended is left as it ended. + if summary.Outcome != beltRunOutcomeDone { + words, _ := runEndingWords(summary) + _ = run.store.FailRoot(words) + } } else { landing = a.landBeltRun(ctx, engine, run) } diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index 27f7e3b77..6b7be61dc 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -63,6 +63,9 @@ type beltRunDouble struct { // for a run that did not finish. honoursStop bool early func(workspace string) + // leaveOpen makes the double end the way the real engine ends a run a + // limit or a program's own ending took down: its store's root left open. + leaveOpen bool } func newBeltRunDouble(result string) *beltRunDouble { @@ -115,7 +118,7 @@ func (d *beltRunDouble) Start(ctx context.Context, spec RunSpec) RunSummary { if d.work != nil { d.work(spec.Workspace) } - if spec.Store != nil { + if spec.Store != nil && !d.leaveOpen { _ = spec.Store.CompleteRoot(d.summary.Result) } close(d.finished) From d747342671213e391eb4fcedfe740efb7edcf415 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:32:13 -0400 Subject: [PATCH 055/195] delegate: the program record carries the program's own clock 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. The record now has room for the instant the program's process started and the instant it was gone, for the worker and the shell verb to write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delegate/conversation.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go index 354a4b5b9..d26855d72 100644 --- a/internal/delegate/conversation.go +++ b/internal/delegate/conversation.go @@ -41,6 +41,16 @@ type ProgramRecord struct { // 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 From ebf014c6ac51a10301fe81dfac6ea76651cf579c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:47:06 -0400 Subject: [PATCH 056/195] run: the worker stamps the program's own clock on its record and ending line The program record had room for the instant a program's process started and the instant it was gone, and nothing wrote either. It was also written only at the hello, so a program that died on its first line left no record at all. The worker now takes the start just before it spawns the child and the exit from the launch's own measure of the process's life (never the stdout drain after it), keeps both on the record beside the hello's name, stages and ceiling, rewrites the record whole when the process is gone whether or not a hello came, and puts the same pair on the trajectory's ending line. A child of another build is still never written down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delegate/conversation.go | 8 ++- internal/run/delegateworker.go | 45 +++++++++++++- internal/run/delegateworker_clock_test.go | 73 +++++++++++++++++++++++ internal/run/trajectory.go | 11 ++++ 4 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 internal/run/delegateworker_clock_test.go diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go index d26855d72..c34d1fc57 100644 --- a/internal/delegate/conversation.go +++ b/internal/delegate/conversation.go @@ -28,8 +28,10 @@ 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; the task page reads -// it to say whose conversation it is drawing, after the run as well as during. +// `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. @@ -71,7 +73,7 @@ func WriteProgram(dir string, record ProgramRecord) error { } // ReadProgram reads the record; ok is false for a run that handed its task to -// no program, or whose program has not said hello yet. +// 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 { diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 79e5dfb30..de63af4cc 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -154,6 +154,12 @@ type delegateSink struct { // 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 } func (s *delegateSink) Hello(h delegate.Hello) { @@ -163,7 +169,8 @@ func (s *delegateSink) Hello(h delegate.Hello) { // 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. - _ = delegate.WriteProgram(s.taskDir, delegate.ProgramRecord{Name: s.name, Stages: h.Stages, CeilingUSD: s.worker.cost}) + 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 @@ -254,8 +261,16 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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}) + _ = 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 == "" { @@ -304,12 +319,15 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err launchCtx, stop := context.WithCancel(ctx) defer stop() - sink := &delegateSink{worker: w, taskID: task.ID, storeDir: storeDir, taskDir: taskDir, name: w.program.Name, stop: 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) } brief = delegate.RehomeBrief(brief, w.setup.Ground, w.workspace) + started = time.Now() + sink.record.StartedAt = started result, err := delegate.Run(launchCtx, delegate.Launch{ Name: w.program.Name, Bin: exe, @@ -322,6 +340,27 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err StderrPath: filepath.Join(taskDir, delegateStderrName), Grace: w.setup.Grace, }, sink) + // THE INSTANT THE PROCESS WAS GONE, which is the launch's own measure of the + // process's life laid on the instant it was started, and never later than + // now. The 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 has exited; the program's wall time is its + // process's, not the drain's. + ended = time.Now() + if result.Elapsed > 0 { + if exited := started.Add(result.Elapsed); exited.Before(ended) { + ended = exited + } + } + // 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. _ = api.Close() diff --git a/internal/run/delegateworker_clock_test.go b/internal/run/delegateworker_clock_test.go new file mode 100644 index 000000000..a8494f352 --- /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/trajectory.go b/internal/run/trajectory.go index 4e36e1321..ca596003c 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" @@ -96,6 +97,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 From a3a717fcf309f61cd7afc32c0e777e2976fe183e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:56:36 -0400 Subject: [PATCH 057/195] session: a run's row, checkpoint and page read one pair, the hand-off to the program's exit A senior-dev run had three clocks. Its page counted from the store's seeding, before the copy was cut, to whenever each kind of ending wrote the store; its row settled at the instant it was published, after the landing and a summary refresh, and carried no elapsed time; and the saved file dropped the row's ending, branch and merge, so a reopened conversation read a program's own ending as "a fault: ..." and named no branch. A run a limit ended also read running until its work had landed. Now a run's wall time is one pair: the hand-off (the row's StartedAt) and the instant the program's process was gone, off its record, or for a run with no program the instant the engine answered. The settle notice carries that end and the elapsed time between them, the checkpoint keeps elapsed_ms with the ending, branch, merge, result and files, and a program's page row reads the same pair. A program's store root that no live run in this process holds reads failed at its last activity instead of running with a clock that never stops, and a limit-ended program run is ended in its store before its work lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 24 ++ internal/manual/chat/worker-harness.md | 7 +- internal/manual/chat_test.go | 2 + internal/session/plandb_tasks.go | 10 +- internal/session/stoprun.go | 5 +- internal/session/task_run_belt.go | 39 ++- internal/session/task_run_clock.go | 242 +++++++++++++++++ internal/session/task_run_clock_test.go | 331 ++++++++++++++++++++++++ internal/session/task_store.go | 35 ++- 9 files changed, 681 insertions(+), 14 deletions(-) create mode 100644 internal/session/task_run_clock.go create mode 100644 internal/session/task_run_clock_test.go diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 7cee2e097..ab71f3243 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -183,6 +183,30 @@ senior-dev's own flags on `run`: `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 copy has been made — to the moment senior-dev's own process ended. +Making the copy before it, and landing the work 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 the work has landed) and its row and card once it has landed. The page +spells it `42s`, `22m 51s`, `1h 7m`. + +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, 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. + ## 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: diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 40fc28cc9..2b7b51c36 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -205,8 +205,11 @@ other. The line under the title stays put while you scroll: the stage the program says it is in (the task's own word, such as `running` or `done`, when there is none), what the run has spent so -far, how many model calls it has made, and how long it has been going. A figure with nothing -behind it is left out. The conversation opens on the brief. Each call is the program's side — a +far, how many model calls it has made, and how long it has been going — from the moment you +handed it off to the moment the program's process ended, the same span its row and its card +show. A figure with nothing behind it is left out. A run nothing is driving any more, because +codeaf closed while the program worked, reads `incomplete` with its time stopped at the last +thing it did. The conversation opens on the brief. Each call is the program's side — a tool's result as `<tool>: <first line>`, 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`, diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index f2a4ad32f..0277a6143 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -932,6 +932,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, + {"senior-dev's page still says running after codeaf crashed", "senior-dev"}, {"which folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index 56659c3de..eb0214431 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -64,7 +64,11 @@ type PlanTaskRow struct { // cost so far. USD float64 // Started is when the task was created and Ended when it completed; a task - // still open carries the zero Ended. + // still open carries the zero Ended. A PROGRAM's task carries its run's one + // pair instead — the hand-off and the instant the program was gone + // (task_run_clock.go's [planRunClocks.apply]) — because the store's pair + // brackets the copy being cut at one end and whenever each kind of ending + // wrote the store at the other. Started time.Time Ended time.Time // Note is the text of the task's last note, empty when nobody has left one. @@ -219,6 +223,7 @@ func (a *Agent) PlanTasks() []PlanTaskRow { var rows []PlanTaskRow copies := a.planDisplayRunCopy() carried := a.planCarriedPrograms() + clocks := a.planRunClocks() for _, store := range stores { dir := filepath.Dir(store.Path()) spend := planSpendByTask(store.Path()) @@ -232,6 +237,7 @@ func (a *Agent) PlanTasks() []PlanTaskRow { for _, task := range tasks { row := planTaskRow(store, dir, task, spend, live) planCarriedRow(&row, carried[task.ID]) + clocks.apply(&row, dir, task, root) row.Folder = a.planTaskRunCopy(task.ID) row.LiveParts = planStepDisplayFacts(PlanStep{Command: row.Live.Command}, copies.or(row.Folder), planShimFilename).Parts rows = append(rows, row) @@ -272,6 +278,7 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { live := store.LiveSteps() copies := a.planDisplayRunCopy() carried := a.planCarriedPrograms() + clocks := a.planRunClocks() // Walk admission order once; membership follows parent edges only. all := store.Tasks(plandb.Filter{Chat: plan.chat}) rows := make(map[string]PlanTaskRow, len(all)) @@ -280,6 +287,7 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { for _, child := range all { row := planTaskRow(store, dir, child, spend, live) planCarriedRow(&row, carried[child.ID]) + clocks.apply(&row, dir, child, store.RootID()) row.Folder = a.planTaskRunCopy(child.ID) row.LiveParts = planStepDisplayFacts(PlanStep{Command: row.Live.Command}, copies.or(row.Folder), planShimFilename).Parts rows[child.ID] = row diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index 8f1e4b2a9..25d04f02d 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -238,9 +238,12 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { a.recordUserLocked(note) a.mu.Unlock() + // THE ROW ENDS WHERE THE RUN'S WORK DID — the instant the program was gone, + // or the engine answered — and not after the kept work was committed + // ([Agent.beltRunEndedAt]). notice := TaskNotice{ ID: run.row, Title: run.title, State: TaskFailed, Stopped: true, - Report: report, Changed: changed, Merge: merge, EndedAt: a.taskClockNow(), + Report: report, Changed: changed, Merge: merge, EndedAt: a.beltRunEndedAt(run), } // THE ROW NAMES A BRANCH ONLY WHEN THERE IS WORK ON IT, for the reason the // sentence does ([beltStoppedWhere]): measured on the real binary, a run diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index e8aa0c21c..52dbda021 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -269,6 +269,15 @@ type beltRun struct { // It is the same reading the row published to the surface carries, so the // tree and the row cannot disagree about when the work began. born time.Time + // ended is the instant the run's engine answered, off the same clock, and + // spent is what the engine said the run came to: both zero until the run's + // work is over. ended is what a run with no program's clock settles at + // ([Agent.beltRunEndedAt]), which is never the later instant its landing + // and its summary have finished at. Both are written and read under + // [Agent.beltMu], because a hand-off joining the run publishes from another + // goroutine while the run is ending. + ended time.Time + spent float64 // delegate is the program this run's root is handed to, nil for a run the // conversation's own workers drive; startSha is the commit the copy stood on // the moment the run began, the point a tree program's commits are squashed @@ -633,6 +642,11 @@ func (a *Agent) installBeltRun(g *TaskGraph, run *beltRun) { // Carrying it forward in the one function every publisher goes through is what // keeps that from depending on each of them remembering. A notice that names a // copy of its own wins, because it is the more recent reading. +// +// AND A ROW THAT HAS ENDED CARRIES HOW LONG IT RAN, worked out here from the +// one pair it carries ([runSpan]) so that no publisher can put a different +// figure beside the same two instants: the rail's clock, the card's span and +// the checkpoint's elapsed_ms all read it. func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { if notice.Copy == nil { for _, kept := range g.runRows(notice.ID) { @@ -642,6 +656,9 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { } } } + if notice.Elapsed == 0 { + notice.Elapsed = runSpan(notice.StartedAt, notice.EndedAt) + } a.emitTaskUpdate(notice) g.keepRunRows(notice.ID, []TaskNotice{notice}) } @@ -684,6 +701,12 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun } spec.OnSpend = foldSpend summary := engine.Start(ctx, spec) + // THE RUN'S WORK IS OVER THE MOMENT THE ENGINE ANSWERS, and that instant is + // taken now, before the landing, the summary refresh and the note — which + // can take a quarter of a minute between them and are not the work. + a.beltMu.Lock() + run.ended, run.spent = a.taskClockNow(), summary.USD + a.beltMu.Unlock() // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. foldSpend(summary.USD) @@ -704,16 +727,22 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun } var landing RunLanding if run.delegate != nil { - landing = a.landDelegateRun(run, summary) // A PROGRAM'S RUN IS OVER WHEN ITS PROGRAM IS, however it ended: it is // a run of one task that nothing continues, so a store the engine left - // open — a program ended at the dollar ceiling leaves it so — is closed - // here, or its page would read `running` and offer `stop it` for ever. - // A run that already ended is left as it ended. + // open — a program ended at a limit leaves it so — is closed here, or its + // page would read `running` and offer `stop it` for ever. A run that + // already ended is left as it ended. + // + // IT IS CLOSED BEFORE THE LANDING, NOT AFTER IT. The squash and the + // commit take their time, and a page that went on reading `running` over + // a program that had already exited was a page claiming a present that + // was over — for the two limit endings alone, because every other ending + // is written by the engine at the program's exit. if summary.Outcome != beltRunOutcomeDone { words, _ := runEndingWords(summary) _ = run.store.FailRoot(words) } + landing = a.landDelegateRun(run, summary) } else { landing = a.landBeltRun(ctx, engine, run) } @@ -873,7 +902,7 @@ func owedLandingTier() roles.Tier { return roles.TierLow } // a surface draws. func (a *Agent) settleBeltRun(run *beltRun, summary RunSummary, landing RunLanding) { notice := a.beltRunNotice(run, summary, landing) - notice.EndedAt = a.taskClockNow() + notice.EndedAt = a.beltRunEndedAt(run) g := a.graph() if g == nil { a.emitTaskUpdate(notice) diff --git a/internal/session/task_run_clock.go b/internal/session/task_run_clock.go new file mode 100644 index 000000000..822b886e8 --- /dev/null +++ b/internal/session/task_run_clock.go @@ -0,0 +1,242 @@ +package session + +// A RUN'S WALL TIME IS ONE PAIR OF INSTANTS, and this file is where the pair is +// decided, so that no surface decides it again. +// +// A hand-off's run is measured from the HAND-OFF — the instant its row is born +// and first reads running ([beltRun.born], the first notice's StartedAt) — to +// the instant the program it handed its task to was gone +// ([delegate.ProgramRecord.EndedAt], stamped by the run's worker); for a run no +// program worked, or whose program never recorded an exit, to the instant the +// run's engine answered ([beltRun.ended]). Its elapsed time is the one minus the +// other ([runSpan]). +// +// EVERY SURFACE READS THAT PAIR, and it used to read four. The task page counted +// from the store's seeding, which is before the copy is cut (sixteen seconds on +// a real run), to whichever moment each kind of ending happened to write the +// store; the row and the card counted to the row's settling, which is after the +// landing and the summary refresh (up to a quarter of a minute more); the tasks +// tool and the landing note said no time at all. The same run read `22m 51s` on +// its page and `22m44s` on its card. Now the settle notice (and so the rail, the +// card and the checkpoint), the page's row ([planRunClocks]), the project index, +// the tasks tool and the landing note all carry the one pair. The program's own +// spawn stays on its record beside it, as a fact of the record, and nothing +// counts from it. + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// runClockEnd is a run's ending instant: the instant its program's process was +// gone when the program record carries one, and ending otherwise. A recorded +// exit that comes before the run's own start is not this run's — two clocks +// that disagree about the order of events are not two readings of one pair — and +// falls back to ending the same way a missing one does. +func runClockEnd(started time.Time, record delegate.ProgramRecord, ending time.Time) time.Time { + if exited := record.EndedAt; !exited.IsZero() && (started.IsZero() || !exited.Before(started)) { + return exited + } + return ending +} + +// runSpan is the elapsed time between a pair, and zero for a pair that is not +// one — an end missing or before its start — which every surface draws as no +// time at all (the emptiness law). +func runSpan(started, ended time.Time) time.Duration { + if started.IsZero() || ended.IsZero() || ended.Before(started) { + return 0 + } + return ended.Sub(started) +} + +// beltRunProgram is the program record a run's worker wrote in the run's own +// task folder, and the zero record for a run no program worked, a run whose +// program has not been started, and a run with no store to hold a record. +func beltRunProgram(run *beltRun) delegate.ProgramRecord { + if run == nil || run.delegate == nil || run.store == nil { + return delegate.ProgramRecord{} + } + record, _ := delegate.ReadProgram(plandb.TaskDir(filepath.Dir(run.store.Path()), run.root)) + return record +} + +// beltRunEndedAt is the instant a run's row settles at: the end of the run's +// one pair ([runClockEnd]), off the program record and the instant the engine +// answered. A run whose engine has not been heard from — a row settled by a +// road that never drove one — ends now, which is the reading it always had. +// +// IT IS NEVER THE INSTANT THE ROW SETTLES. The row used to be stamped when it +// was published, which is after the squash, the commit, the homecoming and a +// summary refresh that may wait six seconds for a model: none of that is the +// run's work, and all of it was counted as though it were. +func (a *Agent) beltRunEndedAt(run *beltRun) time.Time { + a.beltMu.Lock() + ending := run.ended + a.beltMu.Unlock() + if ending.IsZero() { + ending = a.taskClockNow() + } + return runClockEnd(run.born, beltRunProgram(run), ending) +} + +// runSpanWord spells a finished span EXACTLY AS THE TASK PAGE DOES (internal/ +// tui3's countUpWord): seconds under a minute, then minutes and seconds, then +// hours and minutes, with a second rung of zero dropped — `22m 51s`, `1h 7m`, +// `5m` — and nothing at all under a second. It is restated here because the +// session cannot import a surface, and a run's time spelled one way on its page +// and another in the tasks tool or the landing note is two vocabularies for one +// fact. [taskSpanWord] keeps its own older spelling for the other rows it has +// always drawn. +func runSpanWord(d time.Duration) string { + if d < time.Second { + return "" + } + rungs := func(big int, bigUnit string, small int, smallUnit string) string { + out := strconv.Itoa(big) + bigUnit + if small == 0 { + return out + } + return out + " " + strconv.Itoa(small) + smallUnit + } + switch { + case d < time.Minute: + return strconv.Itoa(int(d/time.Second)) + "s" + case d < time.Hour: + return rungs(int(d/time.Minute), "m", int(d%time.Minute/time.Second), "s") + default: + return rungs(int(d/time.Hour), "h", int(d%time.Hour/time.Minute), "m") + } +} + +// ── the page's row reads the same pair ────────────────────────────────────── + +// planRunClocks is what a listing needs to put a program's run on the one pair: +// the run rows this conversation keeps, by the store id each run's root task +// carries, and the root the live run in this process holds. It is read once +// for a whole listing, the way [Agent.planCarriedPrograms] is read beside it. +type planRunClocks struct { + rows map[string]TaskNotice + live string +} + +// planRunClocks reads the conversation's run rows and its live run. It builds +// no graph: a conversation that never handed work off has no rows to read. +func (a *Agent) planRunClocks() planRunClocks { + var clocks planRunClocks + a.beltMu.Lock() + if a.beltRun != nil { + clocks.live = a.beltRun.root + } + a.beltMu.Unlock() + g := a.tasker() + if g == nil { + return clocks + } + g.mu.Lock() + defer g.mu.Unlock() + for _, notice := range g.runRowsLocked() { + if notice.Run != "" || notice.Kind == TaskKindJob { + continue + } + if clocks.rows == nil { + clocks.rows = make(map[string]TaskNotice) + } + clocks.rows[strconv.FormatUint(notice.ID, 10)] = notice + } + return clocks +} + +// apply puts a PROGRAM's row on the run's one pair. Every other row keeps the +// store's own pair, which is what an ordinary task's page has always counted. +// +// THE ROW'S START IS THE HAND-OFF: the run row's StartedAt, and for a run whose +// row this conversation never kept, the program's own spawn off its record. Its +// end is the run row's EndedAt once it has settled, and before that the +// program's recorded exit — so a page read in the seconds between the program +// exiting and the row settling already stops its clock where the row will. A +// record from before the program's clock was written leaves the store's pair +// standing, because a guess at a better pair is worse than the pair it has. +// +// AND A PROGRAM'S RUN NOTHING HERE IS DRIVING IS NOT RUNNING. A store's root +// left open — codeaf closed or crashed while the program ran, and nothing wrote +// its ending — read `running` on its page for ever, offered a stop for a +// process that was long gone, and counted its clock up without bound (a page +// read `1h 10m` over a run of twenty-nine minutes, and `11h` the next morning). +// Such a row now reads `failed`, the store's own word that every surface draws +// as incomplete, ended at the run's last sign of life ([planLastActivity]), +// with no live step and no stage. It is asked of the store's ROOT alone, which +// is the one task a program's run hands its program; root is that task's id. +func (c planRunClocks) apply(row *PlanTaskRow, dir string, task *plandb.Task, root string) { + if row == nil || task == nil || row.Program == "" { + return + } + record, _ := delegate.ReadProgram(plandb.TaskDir(dir, task.ID)) + kept := c.rows[task.ID] + started := kept.StartedAt + if started.IsZero() { + started = record.StartedAt + } + if !started.IsZero() { + row.Started = started + row.Ended = kept.EndedAt + if row.Ended.IsZero() { + row.Ended = runClockEnd(started, record, time.Time{}) + } + } + if task.ID != root || terminalStoreStatus(task.Status) || c.live == task.ID { + return + } + row.Status = string(plandb.StatusFailed) + row.Live, row.Stage = plandb.LiveStep{}, "" + if row.Ended.IsZero() { + if last := planLastActivity(dir, task); !row.Started.IsZero() && !last.Before(row.Started) { + row.Ended = last + } + } +} + +// planLastActivity is the last sign of life a task's run left: the latest of +// the store's own last write to the task and the last write to its +// conversation log and its trajectory. It is read only for a run nothing is +// driving, which is the one moment the question is asked, and it reads the +// files' clocks rather than the files. +func planLastActivity(dir string, task *plandb.Task) time.Time { + last := task.UpdatedAt + folder := plandb.TaskDir(dir, task.ID) + for _, name := range []string{delegate.ConversationFile, planTrajectoryFile} { + if info, err := os.Stat(filepath.Join(folder, name)); err == nil && info.ModTime().After(last) { + last = info.ModTime() + } + } + return last +} + +// planRowSpanWord is a run row's time as the tasks tool says it: `ran 22m 51s` +// for a row that has ended, `running for 3m 2s` for one that is running, and +// nothing for a row with no start or one that is only waiting. It reads the +// row's own pair, which for a program's run is the run's one pair ([apply]). +func planRowSpanWord(row PlanTaskRow, now time.Time) string { + if row.Started.IsZero() { + return "" + } + if !row.Ended.IsZero() { + if span := runSpanWord(runSpan(row.Started, row.Ended)); span != "" { + return "ran " + span + } + return "" + } + switch strings.TrimSpace(row.Status) { + case string(plandb.StatusClaimed), string(plandb.StatusRunning): + if span := runSpanWord(runSpan(row.Started, now)); span != "" { + return "running for " + span + } + } + return "" +} diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go new file mode 100644 index 000000000..da726c7cf --- /dev/null +++ b/internal/session/task_run_clock_test.go @@ -0,0 +1,331 @@ +package session + +// A run's wall time is one pair of instants (task_run_clock.go): the hand-off, +// and the instant the program it handed its task to was gone — or, for a run +// no program worked, the instant its engine answered. These tests pin the pair +// on every surface this package feeds: the row that settles the run and the +// checkpoint it is kept in, the task page's row, and a run nothing is driving. + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// runSpanWord is the page's own spelling of a finished span, restated here +// because the session cannot import the surface; these are the values the page +// draws for the same durations (internal/tui3's countUpWord). +func TestRunSpanWordSpellsASpanTheWayThePageDoes(t *testing.T) { + for _, tc := range []struct { + d time.Duration + want string + }{ + {0, ""}, + {999 * time.Millisecond, ""}, + {5 * time.Second, "5s"}, + {22*time.Minute + 51*time.Second, "22m 51s"}, + {5 * time.Minute, "5m"}, + {67*time.Minute + 34*time.Second, "1h 7m"}, + {2 * time.Hour, "2h"}, + } { + if got := runSpanWord(tc.d); got != tc.want { + t.Errorf("runSpanWord(%v) = %q, want %q", tc.d, got, tc.want) + } + } +} + +// A PROGRAM'S RUN IS TIMED FROM THE HAND-OFF TO THE PROGRAM'S EXIT, on its row, +// in the checkpoint a reopened conversation reads, and on its page. The row used +// to settle at the instant it was published — after the landing and a summary +// refresh — and carry no elapsed time at all, and the page counted from the +// store's seeding, before the copy was cut; the same run read three different +// spans on three surfaces. +func TestAProgramsRunIsTimedFromTheHandOffToTheProgramsExit(t *testing.T) { + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + handoff := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + clock := &fakeClock{at: handoff} + agent.taskNow = clock.now + + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + key := strconv.FormatUint(id, 10) + + // WHILE IT RUNS the page counts from the hand-off, not from the store's + // seeding, which happened before the copy was cut. + page, ok := agent.PlanTaskPage(key) + if !ok || page.Row.Status != string(plandb.StatusRunning) || !page.Row.Started.Equal(handoff) || !page.Row.Ended.IsZero() { + t.Fatalf("the live page's row = %+v (%v), want running from the hand-off %v", page.Row, ok, handoff) + } + + // The program exits 22m 51s after the hand-off, and the run's landing and + // settling happen a good while later on the conversation's clock. + exited := handoff.Add(22*time.Minute + 51*time.Second) + record := delegate.ProgramRecord{Name: "fake", StartedAt: handoff.Add(2 * time.Second), EndedAt: exited} + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(spec.Store.Path()), spec.Store.RootID()), record); err != nil { + t.Fatal(err) + } + clock.advance(30 * time.Minute) + endBeltRun(t, agent, double) + + kept := agent.graph().runRows(id) + if len(kept) != 1 || !kept[0].StartedAt.Equal(handoff) || !kept[0].EndedAt.Equal(exited) || kept[0].Elapsed != 22*time.Minute+51*time.Second { + t.Fatalf("the settled row = %+v, want %v → %v and 22m51s", kept, handoff, exited) + } + page, ok = agent.PlanTaskPage(key) + if !ok || !page.Row.Started.Equal(handoff) || !page.Row.Ended.Equal(exited) { + t.Fatalf("the ended page's row = %v → %v (%v), want the row's own pair %v → %v", page.Row.Started, page.Row.Ended, ok, handoff, exited) + } + + // AND A CONVERSATION REOPENED TOMORROW READS THE SAME PAIR AND THE SAME SPAN. + journal := agent.file.journalPath() + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + reopened, err := newAgent(Config{Workspace: agent.config.Workspace, Model: "test/model", System: "SYSTEM", SessionFile: journal}, &scriptedCompleter{}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer reopened.Close() + back := reopened.graph().runRows(id) + if len(back) != 1 || !back[0].EndedAt.Equal(exited) || back[0].Elapsed != 22*time.Minute+51*time.Second { + t.Fatalf("the reopened row = %+v, want it ended at the program's exit with its span", back) + } +} + +// slowLanding is the run engine whose landing takes a minute of the +// conversation's clock, which is what the squash, the merge and the summary +// refresh of a real landing take in the run's time. +type slowLanding struct { + *beltRunDouble + clock *fakeClock +} + +func (s slowLanding) Land(ctx context.Context, store *plandb.Store, workspace, root string) (RunLanding, error) { + s.clock.advance(time.Minute) + return s.beltRunDouble.Land(ctx, store, workspace, root) +} + +// A RUN NO PROGRAM WORKED ENDS WHERE ITS ENGINE ANSWERED, never where its row +// settled: the landing is not the run's work, and a row stamped after it was a +// run that seemed to go on for as long as its homecoming took. +func TestARunWithNoProgramEndsWhereItsEngineAnswered(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run fixed the nil map") + start := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC) + clock := &fakeClock{at: start} + registerBeltRunEngine(t, slowLanding{beltRunDouble: double, clock: clock}) + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run fixed the nil map"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + }) + agent.taskNow = clock.now + id, _, _, err := agent.StartTask(context.Background(), "fix the nil map crash", false) + if err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + clock.advance(5 * time.Minute) + endBeltRun(t, agent, double) + if double.lands() != 1 { + t.Fatalf("the run landed %d times, want once", double.lands()) + } + kept := agent.graph().runRows(id) + if len(kept) != 1 || !kept[0].EndedAt.Equal(start.Add(5*time.Minute)) || kept[0].Elapsed != 5*time.Minute { + t.Fatalf("the settled row = %+v, want it ended where the engine answered, five minutes in", kept) + } +} + +// A RUN ROW KEEPS HOW IT ENDED AND WHERE ITS WORK IS ACROSS A REOPEN. The saved +// file dropped the ending, the branch and the merge: a program that judged its +// own work unfinished came back reading `a fault: …`, a run a limit ended lost +// which limit, and a kept branch was named nowhere. +func TestARunRowKeepsItsEndingBranchAndSpanAcrossAReopen(t *testing.T) { + began := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + rows := []TaskNotice{ + { + ID: 3, Title: "Implement happy-dom teardown", State: TaskFailed, Ending: TaskEndingProgram, + Report: "senior-dev did not finish: its tests fail\nsubmitted a change the tests do not pass", + Result: "submitted a change the tests do not pass", Branch: "task/happy-dom-1", Merge: mergeKept, + Changed: []string{"src/window.ts"}, StartedAt: began, EndedAt: began.Add(29*time.Minute + 8*time.Second), + }, + { + ID: 4, Title: "Port the parser", State: TaskFailed, Ending: TaskEndingTimeLimit, + Report: "a limit you set stopped it", Branch: "task/port-the-parser-2", Merge: mergeKept, + StartedAt: began, EndedAt: began.Add(time.Hour), + }, + { + ID: 5, Title: "Fix the nil map", State: TaskFailed, Ending: TaskEndingCostLimit, + Report: "a limit you set stopped it", Result: "half the guard", Branch: "task/fix-the-nil-map-3", Merge: mergeKept, + Changed: []string{"a.go", "a_test.go"}, StartedAt: began, EndedAt: began.Add(4*time.Minute + 23*time.Second), + }, + } + dir, workspace := t.TempDir(), t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + }) + g := agent.graph() + for i := range rows { + // Every row takes its number off the graph's one counter, the way a + // hand-off's row does, so the checkpoint's id counter covers it. + rows[i].ID = g.reserve() + agent.publishRunRow(g, rows[i]) + } + journal := agent.file.journalPath() + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + reopened, err := newAgent(Config{Workspace: workspace, Model: "test/model", System: "SYSTEM", SessionFile: journal}, &scriptedCompleter{}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer reopened.Close() + for _, row := range rows { + back := reopened.graph().runRows(row.ID) + if len(back) != 1 { + t.Fatalf("row %d came back as %+v", row.ID, back) + } + got := back[0] + if got.Ending != row.Ending || got.Branch != row.Branch || got.Merge != row.Merge || got.Result != row.Result || + !reflect.DeepEqual(got.Changed, row.Changed) { + t.Fatalf("row %d came back as %+v, want its ending, branch, merge, result and files kept", row.ID, got) + } + if want := runSpan(row.StartedAt, row.EndedAt); got.Elapsed != want { + t.Fatalf("row %d came back with span %v, want %v", row.ID, got.Elapsed, want) + } + if reason := TaskReasonOf(got.Ending, got.Report); reason != TaskReasonOf(row.Ending, row.Report) || strings.HasPrefix(reason, "a fault") { + t.Fatalf("row %d reads %q after the reopen, want %q", row.ID, reason, TaskReasonOf(row.Ending, row.Report)) + } + } + // The elapsed time is on the file itself, where the record says it is. + var document taskDocument + data, err := os.ReadFile(taskCheckpointPath(journal)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + for _, run := range document.Runs { + if run.ElapsedMS <= 0 { + t.Fatalf("run %d was saved with no elapsed_ms: %+v", run.ID, run) + } + } +} + +// A PROGRAM'S RUN A LIMIT ENDED READS ENDED BEFORE ITS WORK LANDS. The engine +// leaves such a store open, and the run's task was ended only after the squash +// and the commit, so the page went on reading `running` over a program that +// had exited — for the two limit endings alone. +func TestALimitEndedProgramRunIsEndedBeforeItsWorkLands(t *testing.T) { + double := newBeltRunDouble("") + double.leaveOpen = true + double.summary = RunSummary{Outcome: "a limit you set stopped it", Limit: RunLimitCost} + double.work = func(workspace string) { + if err := os.WriteFile(filepath.Join(workspace, "one.txt"), []byte("one\n"), 0o644); err != nil { + t.Error(err) + } + } + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + root := store.Task(store.RootID()) + notes := store.Notes(store.RootID(), 0) + if root == nil || root.Status != plandb.StatusFailed || len(notes) == 0 { + t.Fatalf("the run's task = %+v with notes %+v, want it failed with the landing noted", root, notes) + } + if !strings.HasPrefix(notes[0].Body, "landed on ") { + t.Fatalf("the first note on the run = %q, want the landing's own", notes[0].Body) + } + if !root.CompletedAt.Before(notes[0].At) { + t.Fatalf("the run's task ended at %v and its work landed at %v: it read running while its work landed", root.CompletedAt, notes[0].At) + } +} + +// A PROGRAM'S RUN NOTHING HERE IS DRIVING DOES NOT READ RUNNING. codeaf closed +// while the program ran, nothing wrote the store's ending, and the page read +// `running` with a clock that never stopped and a stage it was no longer in. +// It now reads failed — incomplete on every surface — ended at its last sign of +// life, with nothing live. +func TestAProgramsRunNothingIsDrivingEndsAtItsLastActivity(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + store, err := plandb.Open(path, "the run", "3", "Implement happy-dom teardown", "the brief", "chat-a") + if err != nil { + t.Fatal(err) + } + if err := store.SetLive("3", 5, "senior-dev: implement · running"); err != nil { + t.Fatal(err) + } + root := store.Task("3") + _ = store.Close() + folder := plandb.TaskDir(dir, "3") + started := root.UpdatedAt.Add(-29 * time.Minute) + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev", StartedAt: started}); err != nil { + t.Fatal(err) + } + if err := delegate.AppendTurn(folder, delegate.Turn{Seq: 1, Started: started, Model: "vendor/model"}); err != nil { + t.Fatal(err) + } + last := root.UpdatedAt.Add(90 * time.Second) + if err := os.Chtimes(filepath.Join(folder, delegate.ConversationFile), last, last); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + + page, ok := agent.PlanTaskPage("3") + if !ok { + t.Fatal("the run's task answered no page") + } + if page.Row.Status != string(plandb.StatusFailed) || !page.Live.Empty() || page.Row.Stage != "" { + t.Fatalf("the page's row = %+v, want it ended with nothing live", page.Row) + } + if !page.Row.Started.Equal(started) || !page.Row.Ended.Equal(last) { + t.Fatalf("the page's row runs %v → %v, want the program's start %v to its last activity %v", page.Row.Started, page.Row.Ended, started, last) + } + if row := planRowFor(agent.PlanTasks(), planStoreID("3")); row == nil || row.Status != string(plandb.StatusFailed) || !row.Ended.Equal(last) { + t.Fatalf("the listing's row = %+v, want the page's reading", row) + } +} diff --git a/internal/session/task_store.go b/internal/session/task_store.go index f7609461a..039fa88ef 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -748,12 +748,27 @@ type runRecord struct { // loud rather than repairing. Copy *TaskCopyRecord `json:"copy,omitempty"` - // ElapsedMS is whatever age the row was last published with, frozen. A run's - // rows do not carry one today — the family publishes no Elapsed — so it is - // absent on every record this code writes, and it is here rather than left - // out because the record's job is to carry the notice, not to decide which - // half of it matters. Zero renders as nothing, which is the emptiness law. + // ElapsedMS is whatever age the row was last published with, frozen. A + // hand-off's run row carries its wall time from the row that settles it — + // the span from the hand-off to the instant its program was gone, or its + // engine answered ([runSpan]) — and an adaptive family's rows publish no + // Elapsed, so theirs is absent. Zero renders as nothing, which is the + // emptiness law. ElapsedMS int64 `json:"elapsed_ms,omitempty"` + + // Ending, Branch, Merge, Result and Changed are HOW THE ROW ENDED AND WHERE + // ITS WORK IS, which a settled run row carries and a conversation reopened + // tomorrow must still say. They were left out, and the drop was visible: a + // program that judged its own work unfinished came back as `a fault: …` — + // the failed-with-no-ending reading — instead of its own sentence, a run + // ended by a limit its person set lost which limit it was, and a row whose + // work was kept on a branch came back naming no branch at all. Each is + // omitted when empty, so an older file decodes exactly as it always did. + Ending TaskEnding `json:"ending,omitempty"` + Branch string `json:"branch,omitempty"` + Merge string `json:"merge,omitempty"` + Result string `json:"result,omitempty"` + Changed []string `json:"changed,omitempty"` } // taskDocument is the file: a type tag, a version, the id counter, the nodes in @@ -1067,6 +1082,11 @@ func runRowRecord(notice TaskNotice) runRecord { StartedAt: notice.StartedAt, EndedAt: notice.EndedAt, Copy: notice.Copy, + Ending: notice.Ending, + Branch: notice.Branch, + Merge: notice.Merge, + Result: notice.Result, + Changed: append([]string(nil), notice.Changed...), } } @@ -1105,6 +1125,11 @@ func runRowNotice(record runRecord) TaskNotice { StartedAt: record.StartedAt, EndedAt: record.EndedAt, Copy: record.Copy, + Ending: record.Ending, + Branch: record.Branch, + Merge: record.Merge, + Result: record.Result, + Changed: append([]string(nil), record.Changed...), } if !notice.State.settled() { // WORK NOTHING IS DRIVING IS INTERRUPTED, NOT FAILED. This row was live From 8db3d5387ad81442bbc30db70d245fb8a953fdfe Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:01:24 -0400 Subject: [PATCH 058/195] session: the tasks tool reads a senior-dev run, and it and the landing note say how long it took The tasks tool's reader of the run's store was gated on the bash-belt switch, which a program's run never sets, so a conversation asking about the senior-dev run on its own rail was told `No task "3" in this project`. It now reads the plan the pages read, which is this conversation's store whatever the switch says. Nothing the model could read said how long a run had taken either: a program's run row in the tasks tool now says `ran 22m 51s` (or `running for 3m`), and the note a run's landing hands the conversation says `ran <span>` after the outcome, both off the run's one pair and spelled the way the task page spells a span. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 7 ++- internal/manual/chat/tasks.md | 6 ++- internal/manual/chat/worker-harness.md | 6 ++- internal/session/delegate_door_test.go | 2 +- internal/session/task_run_belt.go | 22 ++++++--- internal/session/task_run_belt_test.go | 6 +-- internal/session/task_run_clock.go | 21 +++++++-- internal/session/task_run_clock_test.go | 62 +++++++++++++++++++++++++ internal/session/task_run_owed_test.go | 4 +- internal/session/tools_tasks.go | 28 +++++++++-- 10 files changed, 137 insertions(+), 27 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index ab71f3243..ec635c511 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -192,8 +192,11 @@ 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 the work has landed) and its row and card once it has landed. The page -spells it `42s`, `22m 51s`, `1h 7m`. +ended, even before the work has landed), its row and card once it has landed, the note the +conversation is handed when it lands (`done · ran 22m 51s · …`), and the chat's `tasks` +tool (`#3 · <title> · done · ran 22m 51s`, 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`. 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`. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index e83c27b73..0ed36f6b8 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5163,8 +5163,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 · <title> · 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. diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 2b7b51c36..7f3648456 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -32,8 +32,10 @@ copy cut from your folder as the first run left it. **When the run ends its work comes home by itself.** The copy's 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 <folder> on <branch>`. 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 <branch>: 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 <branch>: 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 <branch> was kept`, when your checkout moved on after the copy was cut. A run that only read says `nothing to land: the run's working copy holds no change` and changes no file. The diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 48fdfa6a5..596fe4ed4 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -221,7 +221,7 @@ func TestAProgramsOwnEndingIsTheRowsReasonAndNotAFault(t *testing.T) { if taskEndingIsFault(notice.Ending) { t.Fatal("a program judging its own work unfinished was drawn as a fault") } - if note := beltRunOutcomeNote(nil, "", summary, RunLanding{}); !strings.HasPrefix(note, summary.Program.Reason) || strings.Contains(note, "ran and did not finish") { + if note := beltRunOutcomeNote(nil, "", summary, RunLanding{}, 0); !strings.HasPrefix(note, summary.Program.Reason) || strings.Contains(note, "ran and did not finish") { t.Fatalf("the outcome note = %q, want the program's own words and not the run's generic one", note) } summary.Program.Status = delegate.StatusCrashed diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 52dbda021..fc241606d 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -753,7 +753,7 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun refreshCtx, cancelRefresh := context.WithTimeout(ctx, beltRunSummaryDeadline) a.RefreshRunSummary(refreshCtx, run.root, time.Time{}) cancelRefresh() - if _, err := run.store.AddNote(run.root, run.root, beltRunOutcomeNote(run.store, run.root, summary, landing)); err != nil { + if _, err := run.store.AddNote(run.root, run.root, beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run))); err != nil { if g := a.graph(); g != nil { g.planNote("the run's outcome note failed: " + err.Error()) } @@ -854,7 +854,7 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { // deliverBeltRunLanding writes the run's digest into the conversation record. // A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED. func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { - line := beltRunOutcomeNote(run.store, run.root, summary, landing) + line := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) if task := run.store.Task(run.root); landingOwesAnswer(task) { document := owedLandingDocument(task, line) note := wakeNote(document.text()) @@ -1067,12 +1067,22 @@ func beltRunLimitEnding(limit RunLimit) TaskEnding { } // beltRunOutcomeNote is the one line a run's own page carries about how it -// ended: the engine's outcome word and where the work went, or the sentence that -// says why it did not. The last stored run reading supplies its Now sentence; -// without one this remains the landing digest that predates run summaries. -func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, landing RunLanding) string { +// ended: the engine's outcome word, how long the run took, and where the work +// went, or the sentence that says why it did not. The last stored run reading +// supplies its Now sentence; without one this remains the landing digest that +// predates run summaries. +// +// THE TIME IS THE RUN'S ONE PAIR ([Agent.beltRunSpan]), said as `ran 22m 51s` +// in the page's own spelling ([runSpanWord]) and said not at all under a +// second. The same line is what the conversation is handed when the run lands, +// and a conversation told only that a run was done could not say how long it +// had taken when it was asked. +func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, landing RunLanding, span time.Duration) string { outcome, result := runEndingWords(summary) parts := []string{outcome} + if ran := runSpanWord(span); ran != "" { + parts = append(parts, "ran "+ran) + } if result != "" { parts = append(parts, result) } diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index 6b7be61dc..50675d775 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -348,7 +348,7 @@ func TestStartTaskBashBeltStartsARunOnTheStore(t *testing.T) { if !anyNoteCarries(beltRunNotes(t, dir, rootID), "landed on "+home.Branch) { t.Fatalf("no note on the root carries the branch: %v", beltRunNotes(t, dir, rootID)) } - wantDigest := beltRunOutcomeNote(nil, "", double.summary, home) + wantDigest := beltRunOutcomeNote(nil, "", double.summary, home, 0) if !strings.Contains(wantDigest, "done") || !strings.Contains(wantDigest, "the run fixed the nil map") || !strings.Contains(wantDigest, "landed on "+home.Branch) { t.Fatalf("digest = %q, want outcome, root result, and work destination", wantDigest) @@ -539,7 +539,7 @@ func TestLandingDigestCarriesTheStoredNowSentence(t *testing.T) { got := beltRunOutcomeNote(store, planRootID, RunSummary{Outcome: beltRunOutcomeDone}, RunLanding{ Branch: "task/landing-digest", Changed: []string{"internal/session/task_run_belt.go"}, - }) + }, 0) want := "done · landed on task/landing-digest: 1 file · The focused landing tests pass." if got != want { t.Fatalf("landing digest = %q, want %q", got, want) @@ -554,7 +554,7 @@ func TestLandingDigestIsUnchangedWithoutAStoredSummary(t *testing.T) { defer store.Close() got := beltRunOutcomeNote(store, planRootID, RunSummary{Outcome: beltRunOutcomeDone}, RunLanding{ Branch: "task/landing-digest", Changed: []string{"internal/session/task_run_belt.go"}, - }) + }, 0) want := "done · landed on task/landing-digest: 1 file" if got != want { t.Fatalf("landing digest = %q, want byte-for-byte legacy digest %q", got, want) diff --git a/internal/session/task_run_clock.go b/internal/session/task_run_clock.go index 822b886e8..0d2762ab2 100644 --- a/internal/session/task_run_clock.go +++ b/internal/session/task_run_clock.go @@ -86,6 +86,12 @@ func (a *Agent) beltRunEndedAt(run *beltRun) time.Time { return runClockEnd(run.born, beltRunProgram(run), ending) } +// beltRunSpan is how long a run took, off its one pair: the hand-off to the end +// [Agent.beltRunEndedAt] answers. +func (a *Agent) beltRunSpan(run *beltRun) time.Duration { + return runSpan(run.born, a.beltRunEndedAt(run)) +} + // runSpanWord spells a finished span EXACTLY AS THE TASK PAGE DOES (internal/ // tui3's countUpWord): seconds under a minute, then minutes and seconds, then // hours and minutes, with a second rung of zero dropped — `22m 51s`, `1h 7m`, @@ -218,12 +224,17 @@ func planLastActivity(dir string, task *plandb.Task) time.Time { return last } -// planRowSpanWord is a run row's time as the tasks tool says it: `ran 22m 51s` -// for a row that has ended, `running for 3m 2s` for one that is running, and -// nothing for a row with no start or one that is only waiting. It reads the -// row's own pair, which for a program's run is the run's one pair ([apply]). +// planRowSpanWord is a program's run's time as the tasks tool says it: +// `ran 22m 51s` for a run that has ended, `running for 3m 2s` for one that is +// running, and nothing for a run with no start. It reads the row's pair, which +// for a program's row is the run's one pair ([planRunClocks.apply]). +// +// EVERY OTHER ROW SAYS NO TIME, as it never has: its pair is the store's own, +// which counts a part from when it was added rather than from when anybody +// started it, and a figure the tool cannot stand behind is left out rather than +// said. func planRowSpanWord(row PlanTaskRow, now time.Time) string { - if row.Started.IsZero() { + if row.Program == "" || row.Started.IsZero() { return "" } if !row.Ended.IsZero() { diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go index da726c7cf..7c2afd85e 100644 --- a/internal/session/task_run_clock_test.go +++ b/internal/session/task_run_clock_test.go @@ -329,3 +329,65 @@ func TestAProgramsRunNothingIsDrivingEndsAtItsLastActivity(t *testing.T) { t.Fatalf("the listing's row = %+v, want the page's reading", row) } } + +// THE CHAT'S tasks TOOL SEES A senior-dev RUN, AND SAYS HOW LONG IT TOOK. Its +// reader of the run's store was gated on the bash-belt switch, which a program's +// run never sets, so the owner's conversation was told `No task "3" in this +// project` over a run its rail was drawing; and nothing the model could read +// said how long a run had taken. The same span reaches the note the +// conversation is handed when the run lands. +func TestTheTasksToolSeesAProgramsRunAndSaysHowLongItTook(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "") + if bashBeltAsked() { + t.Fatal("the switch is still on, so this test would prove nothing") + } + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + handoff := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + clock := &fakeClock{at: handoff} + agent.taskNow = clock.now + id, title, _, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + name := "#" + strconv.FormatUint(id, 10) + + clock.advance(3 * time.Minute) + read, failed := runTool(t, agent, "tasks", `{"id":"`+strconv.FormatUint(id, 10)+`"}`) + if failed || !strings.Contains(read, name+" · "+title+" · running · running for 3m") { + t.Fatalf("reading the live run answered %q (failed %v), want it running for three minutes", read, failed) + } + found, failed := runTool(t, agent, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, name+" · "+title+" · running") { + t.Fatalf("a search for the run answered %q (failed %v), want the run named", found, failed) + } + + exited := handoff.Add(22*time.Minute + 51*time.Second) + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(spec.Store.Path()), spec.Store.RootID()), delegate.ProgramRecord{Name: "fake", StartedAt: handoff, EndedAt: exited}); err != nil { + t.Fatal(err) + } + clock.advance(30 * time.Minute) + endBeltRun(t, agent, double) + + listing, failed := runTool(t, agent, "tasks", `{}`) + if failed || !strings.Contains(listing, name+" · "+title+" · done · ran 22m 51s") { + t.Fatalf("the listing answered %q (failed %v), want the run done with its time", listing, failed) + } + read, failed = runTool(t, agent, "tasks", `{"id":"`+name+`"}`) + if failed || !strings.HasPrefix(read, name+" · "+title+" · done · ran 22m 51s\n") { + t.Fatalf("reading the ended run answered %q (failed %v), want its time on its first line", read, failed) + } + if conversationNotes(agent, "done · ran 22m 51s · submitted and verified") == 0 { + t.Fatal("the note the conversation was handed at the landing does not say how long the run took") + } +} diff --git a/internal/session/task_run_owed_test.go b/internal/session/task_run_owed_test.go index b47c148e7..17ad2586e 100644 --- a/internal/session/task_run_owed_test.go +++ b/internal/session/task_run_owed_test.go @@ -61,7 +61,7 @@ func TestOwedRootLandingWakesOnceWithOnlyQuestionAndResult(t *testing.T) { question := "What did the repair find?" summary := RunSummary{Outcome: beltRunOutcomeDone, Result: "The parser now preserves quoted commas."} landing := RunLanding{} - wantDocument := question + "\n\n" + beltRunOutcomeNote(nil, "", summary, landing) + wantDocument := question + "\n\n" + beltRunOutcomeNote(nil, "", summary, landing, 0) completer := &scriptedCompleter{steps: []step{finalText("The repair preserved quoted commas.")}} agent, _ := newTestAgent(t, completer, func(config *Config) { @@ -147,7 +147,7 @@ func TestOwedLandingCompletionReaderSeesQuestionAsAskAndOutcomeAsEvidence(t *tes question := "What is the test's name once it lands?" summary := RunSummary{Outcome: beltRunOutcomeDone, Result: "Test function name: TestDouble."} landing := RunLanding{Branch: "main", Changed: []string{"double.go", "double_test.go"}} - line := beltRunOutcomeNote(nil, "", summary, landing) + line := beltRunOutcomeNote(nil, "", summary, landing, 0) completer := &scriptedCompleter{steps: []step{finalText("The test is TestDouble."), finalText(checkpointNothingLeft)}} agent, _ := newTestAgent(t, completer, func(config *Config) { diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index 9027eb750..6d7016e5f 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -1411,12 +1411,19 @@ func TaskAgeWord(d time.Duration) string { // Told no task existed, the conversation set out to verify the work by running // the suite itself. The rows are read where the surface reads them // ([Agent.PlanTasks]), so the tool and the rail cannot disagree about what ran. +// +// AND IT WAS BLIND AGAIN TO EVERY senior-dev RUN (2026-09-24, the real +// binary): the reader was gated on the bash-belt switch, which a program's run +// never sets, so `tasks {"id":3}` answered `No task "3" in this project` over a +// run the rail was drawing, and the model went looking through unrelated older +// rows. It reads the plan the pages read ([TaskGraph.planForPages]), which is +// this conversation's store whatever the switch says and never makes one. func (a *Agent) runPlanTasks() []PlanTaskRow { g := a.graph() if g == nil { return nil } - plan := g.planIfArmed() + plan := g.planForPages() if plan == nil || plan.chat == "" { return nil } @@ -1448,11 +1455,17 @@ func planTaskLabels(rows []PlanTaskRow) map[string]string { } // planTasksText is the run's tasks as a listing: the name a person sees, the -// title, the state, and the first line of what came back. Empty when there is -// no run or nothing in it matches, so the caller's own listing stands alone. +// title, the state, how long it ran, and the first line of what came back. +// Empty when there is no run or nothing in it matches, so the caller's own +// listing stands alone. +// +// A PROGRAM'S RUN SAYS HOW LONG IT TOOK, off the run's one pair +// ([planRowSpanWord], task_run_clock.go): the tool said no time at all, and a +// model asked how long senior-dev took could only guess. func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { query = strings.ToLower(strings.TrimSpace(query)) labels := planTaskLabels(rows) + now := a.taskClockNow() var b strings.Builder for _, row := range rows { page, _ := a.PlanTaskPage(row.ID) @@ -1460,6 +1473,9 @@ func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { continue } fmt.Fprintf(&b, "%s · %s · %s", labels[row.ID], cutChars(row.Title, runAskLineChars), row.Status) + if span := planRowSpanWord(row, now); span != "" { + fmt.Fprintf(&b, " · %s", span) + } if line := summaryFirstLine(page.Result, runAskLineChars); line != "" { fmt.Fprintf(&b, " · %s", line) } @@ -1490,7 +1506,11 @@ func (a *Agent) planTaskText(rows []PlanTaskRow, token string) (string, bool) { return "", false } var b strings.Builder - fmt.Fprintf(&b, "%s · %s · %s\n\nbrief:\n%s\n", labels[id], cutChars(page.Row.Title, runAskLineChars), page.Row.Status, cutChars(page.Description, runAskBodyChars)) + head := labels[id] + " · " + cutChars(page.Row.Title, runAskLineChars) + " · " + page.Row.Status + if span := planRowSpanWord(page.Row, a.taskClockNow()); span != "" { + head += " · " + span + } + fmt.Fprintf(&b, "%s\n\nbrief:\n%s\n", head, cutChars(page.Description, runAskBodyChars)) if page.Result != "" { fmt.Fprintf(&b, "\nresult:\n%s\n", cutChars(page.Result, runAskBodyChars)) } From d10ffcbb24b6a41ec5fa59141d2de8ed28bdf7d0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:06:43 -0400 Subject: [PATCH 059/195] session: a hand-off's run reaches the project's index and its conversation's presence A run's rows lived in its conversation's graph and nowhere else, so the @ list, the hop's running count, the conversation list's roll-up, other windows and every other conversation's tasks tool were blind to a senior-dev run for its whole life and after it. Every row a run publishes now appends a project index row, the way an adaptive run's family does: running from the hand-off, and a closing row at the settle with the run's one pair and span, its ending (a person's stop included), outcome, kept branch, files and what it cost. While it runs, the run and each unsettled hand-off that joined it are named in the conversation's presence, which is how another window knows the running row has something behind it. The conversation that started the run keeps reading it from its store alone, so its own tasks tool names the run once and never answers a word for it as another conversation's work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 12 ++ internal/manual/chat/worker-harness.md | 4 +- internal/manual/chat_test.go | 1 + internal/session/stoprun.go | 7 +- internal/session/task_run_belt.go | 6 +- internal/session/task_run_index.go | 177 ++++++++++++++++++++++++ internal/session/task_run_index_test.go | 125 +++++++++++++++++ internal/session/taskpresence.go | 5 +- internal/session/tools_tasks.go | 13 +- 9 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 internal/session/task_run_index.go create mode 100644 internal/session/task_run_index_test.go diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index ec635c511..435bf6054 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -210,6 +210,18 @@ on. 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 + +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. + +If codeaf went away while the run was working, its row is closed the next time that +conversation is opened: `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: diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 7f3648456..965c0e0ef 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -43,7 +43,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, none of this is reached.** `/task` raises an ordinary task on this session's own tree, briefed beside its worker and landed through the diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 0277a6143..6b2a48caf 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -934,6 +934,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"make senior-dev use my crew models", "senior-dev"}, {"how long did the senior-dev run take", "senior-dev"}, {"senior-dev's page still says running after codeaf crashed", "senior-dev"}, + {"can my other window see the senior-dev run", "senior-dev"}, {"which folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index 25d04f02d..ce2ae41c6 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -104,9 +104,10 @@ func (a *Agent) stopBeltRow(id uint64, why string) (string, bool, error) { } // liveBeltTaskByToken resolves the model's task spelling against the run that -// is alive now. A run's rows are deliberately not graph nodes and do not reach -// the project's finished-work index until they end, so that index cannot be -// the door onto stopping one. The run's own kept rows carry the same ids and +// is alive now. A run's rows are deliberately not graph nodes, and the index +// rows they write are left out of this conversation's own reading of the index +// (task_run_index.go), so that index cannot be the door onto stopping one. The +// run's own kept rows carry the same ids and // titles the rail shows, which makes a number and a title-derived name mean the // same thing here that they mean for an ordinary task. func (a *Agent) liveBeltTaskByToken(token string) (TaskIndexEntry, uint64, bool) { diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index fc241606d..3b7a84788 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -646,7 +646,10 @@ func (a *Agent) installBeltRun(g *TaskGraph, run *beltRun) { // AND A ROW THAT HAS ENDED CARRIES HOW LONG IT RAN, worked out here from the // one pair it carries ([runSpan]) so that no publisher can put a different // figure beside the same two instants: the rail's clock, the card's span and -// the checkpoint's elapsed_ms all read it. +// the checkpoint's elapsed_ms all read it. Every row also reaches the project's +// index and, while it runs, this conversation's presence ([Agent.indexRunRow]), +// which is how the `@` list, another window and another conversation's tasks +// tool know the run is there at all. func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { if notice.Copy == nil { for _, kept := range g.runRows(notice.ID) { @@ -661,6 +664,7 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { } a.emitTaskUpdate(notice) g.keepRunRows(notice.ID, []TaskNotice{notice}) + a.indexRunRow(notice) } // cutBeltRun ends the live run because the CONVERSATION is ending. It is what diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go new file mode 100644 index 000000000..4f9fea388 --- /dev/null +++ b/internal/session/task_run_index.go @@ -0,0 +1,177 @@ +package session + +// A HAND-OFF'S RUN IS WORK THE PROJECT CAN SEE. A run's rows lived in this +// conversation's own graph and nowhere else: the project's index (tasks.jsonl) +// never took a row for one, and this conversation's presence never named one. +// So the `@` list, the hop's running count, the conversation list's roll-up, +// every other window and every other conversation's `tasks` tool were blind to +// a senior-dev run for its whole life and after it — the owner's project index +// held nothing of three runs that had taken an hour each. +// +// NOW EVERY ROW A RUN PUBLISHES REACHES THE INDEX, the way an adaptive run's +// does (orchestrate.go's family seam): a row saying running from the hand-off, +// and a row closing it when it settles, with its one pair and span +// (task_run_clock.go), its ending, its outcome, the branch its work was kept on +// and what it cost. The index keeps the last row per id ([lastPerNode]). A run +// left saying running by a process that went away is closed the next time this +// conversation opens ([Agent.closeInflightTaskIndexRows]), because a run's id is +// never one of the graph's own nodes. And while it runs it is named in this +// conversation's presence ([Agent.presenceBeltRuns]), which is what another +// window reads to know a running row in the index has something behind it. +// +// THIS CONVERSATION READS ITS OWN RUNS FROM THEIR STORE, not from the index +// ([Agent.withoutOwnRunRows]): the tasks tool lists them from the run's store +// by the numbers the rail shows, and a second copy of the same run from the +// index would be the same work named twice in one answer. + +import ( + "strconv" + "strings" +) + +// indexRunRow appends one published run row to the project's index. A job's +// row is not work and takes none (jobrow.go's law), and neither does a row of +// an adaptive run, whose family writes its own. +func (a *Agent) indexRunRow(notice TaskNotice) { + if notice.ID == 0 || notice.Kind == TaskKindJob || notice.Run != "" { + return + } + a.mu.Lock() + session := a.sessionID() + a.mu.Unlock() + entry := TaskIndexEntry{ + ID: strconv.FormatUint(notice.ID, 10), + Parent: taskIndexParent(notice.Parent), + Name: TaskSlug(notice.Title), + Label: taskLabel(notice.Title), + Title: strings.TrimSpace(notice.Title), + Status: string(notice.State), + Ending: notice.Ending, + Outcome: taskOutcome(notice.Report), + DurationMS: notice.Elapsed.Milliseconds(), + StartedAt: notice.StartedAt, + EndedAt: notice.EndedAt, + SessionID: session, + // THE KEPT BRANCH ONLY, from the row's own word for how its work came + // home ([keptBranchOf]): a run whose work was merged names none. + Branch: keptBranchOf(notice.Branch, notice.Merge), + } + // A PERSON'S STOP IS THE ROW'S ENDING, as it is on a node's row + // ([TaskNode.endingLocked]): the stop road publishes the flag and no word. + if notice.Stopped && entry.Ending == "" { + entry.Ending = TaskEndingStopped + } + entry.Files, entry.FilesChanged = taskFileCitations(notice.Changed) + worktree := "" + if where := notice.Copy; where != nil { + worktree = where.Dir + entry.Where, entry.Ground, entry.Mode, entry.Rung = where.Dir, where.Ground, where.Mode, where.Rung + } + entry.ArtifactURI = taskArtifactURI(worktree, notice.Branch, notice.Merge) + if notice.State.settled() { + entry.Cost = a.beltRunSpent(notice.ID) + } + a.recordTaskIndexEntry(entry) + // Another window learns the run started, or ended, now rather than at the + // next heartbeat. + a.nudgePresence() +} + +// beltRunSpent is what the live run whose own row id is this one came to, as +// its engine answered, and zero for every other row: a hand-off that joined +// the run has no figure of its own, and zero is drawn as no price. +func (a *Agent) beltRunSpent(id uint64) float64 { + a.beltMu.Lock() + defer a.beltMu.Unlock() + if run := a.beltRun; run != nil && run.row == id { + return run.spent + } + return 0 +} + +// presenceBeltRuns is the live run this conversation has out, and each hand-off +// that joined it and has not settled, one presence row each under the id its +// index row carries — the join another window makes to know the running row in +// the index is being worked ([SessionRow.Runs]). A run no longer live, and a row +// that has settled, is the index's to report. It reads the graph without +// building one, and takes the belt's lock and the graph's one after the other, +// never together. +func (a *Agent) presenceBeltRuns() []PresenceTask { + a.beltMu.Lock() + run := a.beltRun + var ids []uint64 + if run != nil { + ids = append([]uint64{run.row}, run.joined...) + } + a.beltMu.Unlock() + graph := a.tasker() + if run == nil || graph == nil { + return nil + } + var out []PresenceTask + for _, id := range ids { + for _, kept := range graph.runRows(id) { + if kept.ID != id || kept.State.settled() { + continue + } + row := PresenceTask{ + ID: strconv.FormatUint(id, 10), + Title: strings.TrimSpace(kept.Title), + State: string(kept.State), + StartedAt: kept.StartedAt, + } + if kept.Parent != 0 { + row.Parent = strconv.FormatUint(kept.Parent, 10) + } + out = append(out, row) + } + } + return out +} + +// ownRunRowIDs is every hand-off run row this conversation keeps, by id: the +// rows its tasks tool reads from the run's store and never from the index. An +// adaptive run's rows and a job's are not among them. +func (a *Agent) ownRunRowIDs() map[string]bool { + graph := a.tasker() + if graph == nil { + return nil + } + graph.mu.Lock() + defer graph.mu.Unlock() + var ids map[string]bool + for _, notice := range graph.runRowsLocked() { + if notice.Run != "" || notice.Kind == TaskKindJob { + continue + } + if ids == nil { + ids = make(map[string]bool) + } + ids[strconv.FormatUint(notice.ID, 10)] = true + } + return ids +} + +// withoutOwnRunRows is the index as this conversation's tasks tool reads it: +// every row but this conversation's own hand-off runs, which the tool reads +// from their store ([Agent.runPlanTasks]) under the numbers the rail shows. Left +// in, the same run would be listed twice in one answer, and a word for it — +// `say`, `continue` — would be answered as though it were the work of an +// earlier conversation. +func (a *Agent) withoutOwnRunRows(rows []TaskIndexEntry) []TaskIndexEntry { + own := a.ownRunRowIDs() + if len(own) == 0 { + return rows + } + a.mu.Lock() + session := a.sessionID() + a.mu.Unlock() + kept := make([]TaskIndexEntry, 0, len(rows)) + for _, row := range rows { + if row.SessionID == session && own[strings.TrimSpace(row.ID)] { + continue + } + kept = append(kept, row) + } + return kept +} diff --git a/internal/session/task_run_index_test.go b/internal/session/task_run_index_test.go new file mode 100644 index 000000000..254a56001 --- /dev/null +++ b/internal/session/task_run_index_test.go @@ -0,0 +1,125 @@ +package session + +// A hand-off's run reaches the project's index and its conversation's presence +// (task_run_index.go), so work a senior-dev run is doing is visible to the `@` +// list, to other windows and to another conversation's tasks tool. + +import ( + "context" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// A SECOND CONVERSATION ON THE PROJECT FINDS A senior-dev RUN. The run's rows +// never reached the project's index, so every other conversation's `tasks` tool, +// the `@` list and the hop's count were blind to it for its whole life and after +// it; and its conversation's presence never named it, so a row that did reach +// the index would have read as nothing behind it. The run's own conversation +// reads it once, from its store, and not a second time from the index. +func TestAnotherConversationOnTheProjectFindsAProgramsRun(t *testing.T) { + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + bucket := t.TempDir() + workspace := newTestRepo(t) + conversation := func(name string) *Agent { + dir := filepath.Join(bucket, name) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + runner, other := conversation("runner"), conversation("other") + handoff := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + clock := &fakeClock{at: handoff} + runner.taskNow = clock.now + + id, title, _, err := runner.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + key := strconv.FormatUint(id, 10) + slug := TaskSlug(title) + + // WHILE IT RUNS: the index holds it running from the hand-off, the runner's + // presence names it, and the other conversation finds it by its words. + row := indexRowFor(t, runner, key) + if row.Status != string(TaskRunning) || !row.StartedAt.Equal(handoff) { + t.Fatalf("the index's row for the run = %+v, want it running from the hand-off", row) + } + var named bool + for _, task := range runner.presenceSnapshot(time.Now()).RunningTasks { + named = named || (task.ID == key && task.StartedAt.Equal(handoff)) + } + if !named { + t.Fatalf("the runner's presence does not name the run: %+v", runner.presenceSnapshot(time.Now()).RunningTasks) + } + found, failed := runTool(t, other, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, key+" · "+slug+" · working") { + t.Fatalf("the other conversation's search answered %q (failed %v), want the run working", found, failed) + } + // The runner's own listing names it once, from its store. + own, failed := runTool(t, runner, "tasks", `{}`) + if failed || !strings.Contains(own, "#"+key+" · "+title+" · running") || strings.Contains(own, key+" · "+slug) { + t.Fatalf("the runner's own listing = %q (failed %v), want the run once, by the number its rail shows", own, failed) + } + + exited := handoff.Add(22*time.Minute + 51*time.Second) + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(spec.Store.Path()), spec.Store.RootID()), delegate.ProgramRecord{Name: "fake", StartedAt: handoff, EndedAt: exited}); err != nil { + t.Fatal(err) + } + clock.advance(30 * time.Minute) + endBeltRun(t, runner, double) + + // ONCE IT HAS ENDED: the index closes it on the run's one pair, the + // runner's presence lets it go, and the other conversation reads the ending. + row = indexRowFor(t, runner, key) + if row.Status != string(TaskDone) || !row.EndedAt.Equal(exited) || row.Duration() != 22*time.Minute+51*time.Second { + t.Fatalf("the index's closing row = %+v, want it done at the program's exit, 22m 51s in", row) + } + for _, task := range runner.presenceSnapshot(time.Now()).RunningTasks { + if task.ID == key { + t.Fatalf("the runner's presence still names the ended run: %+v", task) + } + } + found, failed = runTool(t, other, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, key+" · "+slug+" · done") || !strings.Contains(found, "22m 51s") { + t.Fatalf("the other conversation's search answered %q (failed %v), want the run done with its time", found, failed) + } + var mentioned bool + for _, entry := range other.TaskIndex() { + mentioned = mentioned || (entry.ID == key && entry.Title == title) + } + if !mentioned { + t.Fatal("the `@` list of the other conversation does not carry the run") + } +} + +// indexRowFor is the project index's last word on one of this conversation's +// rows, read the way every reader of the index reads it. +func indexRowFor(t *testing.T, agent *Agent, id string) TaskIndexEntry { + t.Helper() + agent.mu.Lock() + session := agent.sessionID() + agent.mu.Unlock() + for _, entry := range ReadTaskIndex(agent.config.taskIndexFile()) { + if entry.ID == id && entry.SessionID == session { + return entry + } + } + t.Fatalf("the project's index holds no row %s for this conversation", id) + return TaskIndexEntry{} +} diff --git a/internal/session/taskpresence.go b/internal/session/taskpresence.go index b8b15c3ab..7e95f976b 100644 --- a/internal/session/taskpresence.go +++ b/internal/session/taskpresence.go @@ -904,7 +904,10 @@ func (a *Agent) presenceSnapshot(now time.Time) SessionPresence { // would leave that window saying `waiting on you` with nothing after it. snapshot.Question = a.presenceAsk() } - snapshot.RunningTasks = append(a.presenceTasks(), a.presenceRuns()...) + // A HAND-OFF'S RUN IS WORK OUT TOO, and its row is in the project's index + // from its first breath (task_run_index.go); without it here every other + // window judged that row by the join and counted a live run as incomplete. + snapshot.RunningTasks = append(append(a.presenceTasks(), a.presenceRuns()...), a.presenceBeltRuns()...) snapshot.Jobs = a.presenceJobs() return snapshot } diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index 6d7016e5f..45fdd8283 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -273,7 +273,9 @@ func (a *Agent) taskSearchText(query string, limit int, scope string) string { if limit <= 0 && a.config.taskID != 0 { limit = taskFanLimit } - out := taskRowsTextLimit(a.taskRows(), query, limit) + // THIS CONVERSATION'S OWN RUNS ARE LEFT TO THEIR STORE'S LISTING, which the + // tool puts above this one ([Agent.withoutOwnRunRows]). + out := taskRowsTextLimit(a.withoutOwnRunRows(a.taskRows()), query, limit) if !a.tellsElsewhere() { return a.taskConversationHint(out) } @@ -656,9 +658,10 @@ func (a *Agent) oneTask(ctx context.Context, token string, parsed tasksArguments if parsed.Stop && (parsed.Continue || parsed.Forward || strings.TrimSpace(parsed.Resolve) != "") { return "stop ends the task, so it cannot be combined with continue, resolve or forward; send one action at a time.", true, nil } - // A LIVE RUN HAS NO PROJECT-INDEX ROW YET. Its rows are kept beside the - // graph's nodes and are written to the finished-work index only when the run - // ends, so a stop must resolve that live owner before asking the index. Every + // A LIVE RUN IS STOPPED THROUGH ITS OWNER, NEVER THROUGH THE INDEX. Its rows + // are kept beside the graph's nodes, not among them, and the index row this + // conversation's own run writes is left out of this reader + // ([Agent.withoutOwnRunRows]), so a stop resolves the live run first. Every // other operation keeps its existing reader: run details come from the plan // store and ordinary tasks come from the graph and project index below. if parsed.Stop { @@ -666,7 +669,7 @@ func (a *Agent) oneTask(ctx context.Context, token string, parsed tasksArguments return a.stopOneTask(entry, id, true, parsed.Say) } } - rows := a.taskRows() + rows := a.withoutOwnRunRows(a.taskRows()) entry, found := a.taskByToken(rows, token) if !found { // CONTINUE ON A MISS IS NOT "NO TASK". The person named a number and From 70a92ac0e20103680d1889ecf111fba191bc476f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:56:04 -0400 Subject: [PATCH 060/195] provider: a queued receipt is owed until it is banked, and gets a fifth ask A receipt for a call cut in the middle is fetched in the background after the call has returned, so work that read its total and closed its books the moment its last call ended read a total without that money: each of the three stopped senior-dev runs of 2026-09-23 lost its in-flight call's receipt, which landed about twenty seconds later on the fourth and last request of the schedule. provider.WithReceiptPending now lets work be told a receipt is owed before the fetch begins and answered only after the sink has banked it, and provider.ReceiptWait is the schedule's own ceiling for a caller that waits. The schedule gains a request twenty seconds after the last one, so a slightly slower receipt is no longer an unpriced marker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/models-and-cost.md | 6 +- internal/provider/billing.go | 40 +++++++++++++ internal/provider/receipt.go | 32 +++++++++- internal/provider/receipt_test.go | 77 +++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 4 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index fbc11a5a6..5ea0fe95e 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1468,8 +1468,12 @@ 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. + 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 diff --git a/internal/provider/billing.go b/internal/provider/billing.go index 01277d330..cf11db117 100644 --- a/internal/provider/billing.go +++ b/internal/provider/billing.go @@ -94,8 +94,24 @@ 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{} // 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 +133,26 @@ 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) +} + +// 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 { @@ -179,5 +215,9 @@ 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) } +// 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/receipt.go b/internal/provider/receipt.go index a58f99f07..21b0bd4f7 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,14 @@ 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. 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 @@ -67,6 +82,7 @@ var receiptRetrySchedule = [...]time.Duration{ receiptFirstRetryDelay, receiptSecondRetryDelay, receiptThirdRetryDelay, + receiptFourthRetryDelay, } // receiptWork is all the worker may retain from a call whose own context is @@ -150,11 +166,21 @@ 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) + } + } 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 diff --git a/internal/provider/receipt_test.go b/internal/provider/receipt_test.go index ec3fd255a..9126b6cb7 100644 --- a/internal/provider/receipt_test.go +++ b/internal/provider/receipt_test.go @@ -676,3 +676,80 @@ 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) + } +} From 2512ba6a662de82eca18765d96f2f7388d423849 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:57:02 -0400 Subject: [PATCH 061/195] modelapi: a run's books close with its receipts in, and name who answered A run's model API closed the moment its program exited and the worker read its total straight after, so the call a stop or the ceiling cut in the middle, priced by a receipt about twenty seconds later, missed the task's spend rows, the run's total and the conversation's books on every stopped run of 2026-09-23. Close now counts the receipts the funnel queued for the run's calls and waits for them, bounded by the provider's own schedule, and a Settling hook says how many are owed. Three more gaps close here. A turn names the model the funnel billed when that is another model than the ask, so the page no longer names qwen, deepseek and kimi for 27 calls gpt-5.6-sol answered. A ceiling with nothing left (the smallest positive figure a spent conversation limit becomes) is reached before the first call instead of letting one paid call through. And a call answered whole with no usage block and no receipt owed is told as unbilled instead of vanishing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 11 +- internal/manual/chat/senior-dev.md | 28 ++- internal/provider/modelapi/export_test.go | 11 + internal/provider/modelapi/server.go | 233 +++++++++++++++++++++- internal/provider/modelapi/server_test.go | 181 +++++++++++++++++ 5 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 internal/provider/modelapi/export_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 5ac633c5e..169530dfb 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -74,9 +74,10 @@ everything it would stop and ask is already settled. The model is told the same 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: a model call that -would cross the dollar ceiling is refused before it is made, and the task then says -`<name> reached the run's dollar ceiling of $…`. +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 `<name> 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. **It runs alone.** While one is running, no other task can join its copy, and it cannot be started under another run. Both are refused with the folder that is busy: @@ -109,7 +110,9 @@ A program that only answers works in your folder in place and changes nothing. I 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. +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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 435bf6054..b64ff5344 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -129,9 +129,12 @@ with no git history nothing is committed at all: the work is already in the fold 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 and in `/cost`, and is held to the run's dollar ceiling: **codeaf refuses the call -that would cross it**, before it is made. 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 +total and in `/cost`, and 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. @@ -142,8 +145,23 @@ 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 -conversation on the task page names the model that answered. Which models it asks for is -the next section. +conversation on the task page names 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. Which models it asks for is the next section. + +## 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). +So is a call answered whole whose answer carried no usage block at all: codeaf has no +figure for it, and does not guess one. ## Which models does senior-dev use — your crew, its own list, --high diff --git a/internal/provider/modelapi/export_test.go b/internal/provider/modelapi/export_test.go new file mode 100644 index 000000000..5a7496326 --- /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/server.go b/internal/provider/modelapi/server.go index bda516f8f..a7efdda4c 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -20,6 +20,15 @@ package modelapi // 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 @@ -100,6 +109,11 @@ type Config struct { // 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. @@ -155,6 +169,11 @@ type Server struct { // 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 @@ -229,6 +248,16 @@ func (s *Server) refuse() { // 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 @@ -244,12 +273,87 @@ func (s *Server) Close() error { 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. 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.mu.Lock() + if o.n == 0 { + o.idle = make(chan struct{}) + } + o.n++ + o.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + 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 +} + +// 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 { + o.mu.Lock() + if o.n == 0 { + o.mu.Unlock() + return true + } + idle := o.idle + o.mu.Unlock() + 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 { @@ -354,6 +458,23 @@ type record struct { mu sync.Mutex turn delegate.Turn ended bool + // owed says the funnel queued a receipt for this call: its price is on its + // way, however late. + owed bool +} + +// owe marks the call as one whose receipt the funnel has queued. +func (r *record) owe() { + r.mu.Lock() + defer r.mu.Unlock() + r.owed = true +} + +// owing reports whether the funnel queued a receipt for this call. +func (r *record) owing() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.owed } // serveOn says the call went out on the seat instead of the ask. @@ -407,7 +528,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { } entry, spent := s.open(request, thread, served) - if ceiling := s.config.Ceiling; ceiling > 0 && spent >= ceiling { + 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. @@ -446,6 +567,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { } else { said = answerOf(response, model, bill, catch, slot, out) s.arrived(thread, said.reasoning.field) + s.unpriced(response, bill, entry, model) } s.log(entry.close(func(turn *delegate.Turn) { turn.TokensIn, turn.TokensOut, turn.Cached, turn.CostUSD = bill.figures() @@ -453,6 +575,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { if err == nil { turn.Reply, turn.Calls = said.text, toolUses(said.calls) } + turn.Served = answeredBy(*turn, bill.model(), response) })) if r.Context().Err() != nil { @@ -537,6 +660,10 @@ func (s *Server) settings(ctx context.Context, request *call, bill *tally, catch 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, func() func() { + entry.owe() + return s.owed.owe() + }) ctx = provider.WithStreamObserver(ctx, catch.observe) return provider.WithServedEndpoint(ctx, slot) } @@ -570,6 +697,31 @@ func (s *Server) charge(bill *tally, billed provider.Billed, late bool) { } } +// unpriced keeps an answered call that nothing priced on the ledger as the +// marker it is. +// +// AN ANSWER WITH NO USAGE BLOCK IS NOT A FREE ONE. The funnel bills a call +// from the usage block at the end of its answer, and asks for a receipt only +// when the answer was cut; an answer 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. Such a call is told as one nobody could price +// ([Config.Unbilled]), exactly as a receipt that never came is, with no money +// invented. A call the funnel billed, or whose receipt is on its way, is not. +func (s *Server) unpriced(response *ai.Response, bill *tally, entry *record, model string) { + if s.config.Unbilled == nil || response == nil || response.Usage != nil || entry.owing() { + return + } + if _, billed := bill.metered(); billed { + return + } + answered := strings.TrimSpace(response.Model) + if answered == "" { + answered = model + } + s.config.Unbilled(answered) +} + // meter adds one charge to the run's total and answers the total. func (s *Server) meter(cost float64) float64 { s.mu.Lock() @@ -598,9 +750,58 @@ func (s *Server) receipt(bill *tally, entry *record, receipt provider.Reconciled 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`) is still it. 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 + } + build := func(long, short string) bool { + return strings.HasPrefix(long, short+"-") || strings.HasPrefix(long, short+":") + } + return a == b || build(a, b) || build(b, a) +} + // 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) { @@ -638,6 +839,14 @@ func (t *tally) add(billed provider.Billed) { } } +// 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() @@ -738,10 +947,28 @@ 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. +// 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 && amount < 0.01 { + if amount >= 0.00005 && amount < 0.01 { return fmt.Sprintf("$%.4f", amount) } return fmt.Sprintf("$%.2f", amount) diff --git a/internal/provider/modelapi/server_test.go b/internal/provider/modelapi/server_test.go index 73a9ecf4a..90d132967 100644 --- a/internal/provider/modelapi/server_test.go +++ b/internal/provider/modelapi/server_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "io" + "math" "net/http" "os" "path/filepath" @@ -668,8 +669,12 @@ 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}) @@ -699,3 +704,179 @@ func TestALateReceiptIsBankedAndItsTurnRewritten(t *testing.T) { 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. +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: "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 SAID, NOT SILENT. A call answered whole whose +// answer carried no usage block was billed nowhere and marked nowhere; it is +// told as a call nobody could price, with no money invented. A call the funnel +// billed — at a price or at none — and a call whose receipt is owed are not. +func TestAnAnsweredCallNothingPricedIsToldAsUnbilled(t *testing.T) { + for _, row := range []struct { + name string + reply func(context.Context, string, []ai.Message, ai.Request) (*ai.Response, error) + want []string + }{ + {name: "no usage block, no receipt", want: []string{"moonshotai/kimi-k2.6"}, + reply: func(_ context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + return saying(model, "whole"), nil + }}, + {name: "billed", reply: words("whole", 0.03)}, + {name: "billed with no price", reply: words("whole", 0)}, + {name: "a receipt owed", + reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + done := provider.ReceiptPendingFrom(ctx)() + go done() + return saying(model, "cut"), nil + }}, + } { + t.Run(row.name, func(t *testing.T) { + calls := &script{reply: row.reply} + var mu sync.Mutex + var unbilled []string + _, api := open(t, modelapi.Config{ + CompleterFor: calls.completerFor, + Unbilled: func(model string) { mu.Lock(); unbilled = append(unbilled, model); mu.Unlock() }, + }) + body := `{"model":"moonshotai/kimi-k2.6","messages":[{"role":"user","content":"go"}]}` + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + mu.Lock() + defer mu.Unlock() + if strings.Join(unbilled, ",") != strings.Join(row.want, ",") { + t.Fatalf("unbilled = %v, want %v", unbilled, row.want) + } + }) + } +} From 44e99d38b4a824f9617e968426266bbfb70fa878 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:59:01 -0400 Subject: [PATCH 062/195] run: a program's calls are filed under their conversation and folded whole Every ledger row a delegated run wrote named only its workspace, so the conversation's receipt and the spending page could not place senior-dev's money: on 2026-09-23 that was 94.9% of the day's spend filed under nobody. The rows now name the conversation as their Root and Session and the task as their Task, the way a task node's do, so UsageTree counts each call once under the work the conversation started. The conversation's fold took only a dollar figure per reading of the run's total and moved the running chat turn's share; each metered call now reaches it whole (tokens, cached share, model, one call) through a detached fold door, and the run's total adds only what the calls did not carry. A spend row the store refuses is written down in the task's delegate-stderr.log instead of dropped. And a run handed a limit with nothing left seats no worker and ends on the cost limit, instead of making one paid call first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/models-and-cost.md | 13 +- internal/manual/chat/senior-dev.md | 6 +- internal/run/delegate_money_test.go | 224 ++++++++++++++++++++++++ internal/run/delegateworker.go | 109 ++++++++++-- internal/run/enginewire.go | 5 + internal/run/run.go | 11 +- internal/run/worker.go | 18 ++ internal/session/loop.go | 12 ++ internal/session/task_run_belt.go | 38 ++-- internal/session/task_run_money.go | 101 +++++++++++ internal/session/task_run_money_test.go | 91 ++++++++++ 11 files changed, 591 insertions(+), 37 deletions(-) create mode 100644 internal/run/delegate_money_test.go create mode 100644 internal/session/task_run_money.go create mode 100644 internal/session/task_run_money_test.go diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 5ea0fe95e..c595132e7 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2074,7 +2074,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" | @@ -2106,6 +2106,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 @@ -2153,8 +2159,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/senior-dev.md b/internal/manual/chat/senior-dev.md index b64ff5344..968a0f463 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -129,14 +129,16 @@ with no git history nothing is committed at all: the work is already in the fold 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 and in `/cost`, and is held to the run's dollar ceiling: **once the run's spend has +total, its tokens and its call count, under `tasks` in `/cost`, and under the task on the +spend place, and 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. +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, diff --git a/internal/run/delegate_money_test.go b/internal/run/delegate_money_test.go new file mode 100644 index 000000000..75307d9c8 --- /dev/null +++ b/internal/run/delegate_money_test.go @@ -0,0 +1,224 @@ +//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 + setup.OnCharge = func(charge session.RunCharge) { + mu.Lock() + defer mu.Unlock() + folded += charge.USD + } + 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 { + mu.Unlock() + t.Fatalf("folded %v before the worker reported, want the late receipt", folded) + } + 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 index de63af4cc..2ceb6b1d3 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -112,6 +112,16 @@ type DelegateSetup struct { // (delegate.RehomeBrief), so the program is never told a path it must not // work in. Empty for a program working in the folder itself. Ground []string + // 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 @@ -209,40 +219,102 @@ func (s *delegateSink) Step(command, observation string) { func (s *delegateSink) Terminal(t delegate.Terminal) { s.terminal = &t } // delegateMeter is where the run's model API tells each charge as it is -// metered: 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. +// 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 three places. +// bank books one charge in all four places. // -// THE LEDGER ROW IS WRITTEN HERE AND ONLY HERE. The conversation that started -// the run folds the run's total into its own meter through the fold door, -// which 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. +// 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) - _ = m.store.AddSpend(m.taskID, m.name, m.role, charge.CostUSD, charge.TokensIn, charge.TokensOut) - line := session.UsageLine{ - Model: charge.Model, Calls: 1, Input: charge.TokensIn, Output: charge.TokensOut, USD: charge.CostUSD, - Reconciled: charge.Late, Workspace: m.workspace, + 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. +// 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(session.UsageLine{Model: model, Workspace: m.workspace}, roles.RoleWorker, session.SeatWorker)) + 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 { @@ -287,12 +359,13 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err role = plandb.RoleWork } meter := &delegateMeter{ - ctx: ctx, store: w.store, taskID: task.ID, role: role, + 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, @@ -363,6 +436,10 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err } // 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 diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 96e237bfb..f574b1ff1 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -55,6 +55,11 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar PlainFolder: spec.PlainFolder, Ground: spec.Ground, 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) } diff --git a/internal/run/run.go b/internal/run/run.go index 22811fbb3..cea503f69 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -269,6 +269,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. @@ -572,7 +579,7 @@ func (s *Supervisor) countLiveSpend() { } s.liveMu.Unlock() s.publishSpend() - if s.limits.CostUSD > 0 && s.spent >= s.limits.CostUSD && s.limitHit == "" { + if s.limits.costReached(s.spent) && s.limitHit == "" { s.limitHit = LimitCost for _, cancel := range s.cancels { cancel() @@ -594,7 +601,7 @@ func (s *Supervisor) settleSpend(ret workerReturn) { // THE LIMIT THAT ENDED THE RUN IS THE FIRST ONE REACHED. A return that carries // the spend past the dollar limit after the time limit already ended the run // does not rename the ending. - if s.limits.CostUSD > 0 && s.spent >= s.limits.CostUSD && s.limitHit == "" { + if s.limits.costReached(s.spent) && s.limitHit == "" { s.limitHit = LimitCost } s.publishSpend() diff --git a/internal/run/worker.go b/internal/run/worker.go index 039a5a0ab..0879fb982 100644 --- a/internal/run/worker.go +++ b/internal/run/worker.go @@ -80,6 +80,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/session/loop.go b/internal/session/loop.go index 9623daa11..36f288007 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -5231,6 +5231,18 @@ func (a *Agent) addFoldedUsageAs(response *ai.Response, model string, calls int, a.addUsageAs(response, model, calls, role, false, false) } +// addDetachedFoldedUsage is [Agent.addFoldedUsage] for work that runs BESIDE +// the conversation's turns rather than inside one: a run the conversation +// handed a task to (task_run_money.go's beltFold). It writes no ledger row, +// because the run's own worker wrote one per call, and it moves no turn's +// share ([Agent.addDetachedUsageAs]'s reason): a run's call priced while the +// person's next chat turn is running is not that turn's spending, and a turn +// abandoned then would otherwise be journaled with the run's dollars as its +// own. +func (a *Agent) addDetachedFoldedUsage(response *ai.Response, model string, calls int) { + a.addUsageAs(response, model, calls, "", false, false, detachedFromTurn) +} + // The roles an auxiliary line can name. A line is journaled with the role that // made the call so a bad answer can be traced to the model that gave it: the // session's name and a piece of work's name are the two that a person SEES, and diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 3b7a84788..545562476 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -40,7 +40,6 @@ import ( "strings" "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/roles" @@ -52,8 +51,9 @@ import ( // limits is the smaller, so a run and an adaptive run cannot come to disagree // about it. Zero means no limit. A spent or overspent limit becomes the smallest // positive figure rather than zero because the run engine reads zero as -// unlimited; its existing limit ending then stops the run before a second paid -// call if admission did not already refuse the turn. +// unlimited. Both the engine and a program's model API read a limit with +// nothing left as already reached, so such a run makes no paid call at all and +// ends on the person's cost limit. func runCostLeft(limit, spent float64) float64 { if limit <= 0 { return 0 @@ -120,6 +120,18 @@ type RunSpec struct { Serves func(model string) bool // OnSpend observes the reconciled cumulative run spend while work is live. OnSpend func(float64) + // OnCharge observes each priced call a worker that meters call by call + // makes — a delegated run's program, through its model API — with the + // call's own tokens and model, so the conversation folds the call whole + // rather than as a bare dollar figure ([RunCharge]). Nil for a worker that + // only reports its running total. + OnCharge func(RunCharge) + // Conversation is the id of the conversation that started the run: the + // journal id its own ledger rows carry as their Session. A delegated + // run's ledger rows name it as their Root and their Session, beside the + // task's id, so the conversation's receipt and the spending page can place + // the money. Empty leaves those rows naming no conversation. + Conversation string // Delegate, when set, is the program this run's root task is handed to // instead of a bash worker (delegate_door.go). No key goes with it: the // program reaches a model only through the API codeaf serves the run. Nil is @@ -529,6 +541,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { PlanModel: planSeat, CompleterFor: func(string) Completer { return a.beltRunCompleter() }, Serves: a.servesModel, + Conversation: a.runConversation(), Delegate: run.delegate, PlainFolder: run.plain, Ground: run.groundNames, @@ -694,16 +707,13 @@ func (a *Agent) cutBeltRun() { // the row the run was published under settles. The store is closed and the run // cleared once the work is home, so the next `/task` seeds a fresh plan. func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun, spec RunSpec) { - var foldedUSD float64 - foldSpend := func(total float64) { - if total <= foldedUSD { - return - } - delta := total - foldedUSD - a.addFoldedUsage(&ai.Response{Usage: &ai.Usage{Cost: &delta}}, "", 0) - foldedUSD = total - } - spec.OnSpend = foldSpend + // THE RUN'S MONEY REACHES THE CONVERSATION'S BOOKS THROUGH ONE FOLD + // (task_run_money.go): each call whole as a program's model API meters it, + // and whatever the run's running total holds beyond those — a bash + // worker's spend, which arrives only as a total. + fold := &beltFold{agent: a} + spec.OnSpend = fold.total + spec.OnCharge = fold.charge summary := engine.Start(ctx, spec) // THE RUN'S WORK IS OVER THE MOMENT THE ENGINE ANSWERS, and that instant is // taken now, before the landing, the summary refresh and the note — which @@ -713,7 +723,7 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun a.beltMu.Unlock() // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. - foldSpend(summary.USD) + fold.total(summary.USD) if run.cut != nil { defer run.cut() } diff --git a/internal/session/task_run_money.go b/internal/session/task_run_money.go new file mode 100644 index 000000000..368de4265 --- /dev/null +++ b/internal/session/task_run_money.go @@ -0,0 +1,101 @@ +package session + +// A run's money, on its way into the conversation's books. +// +// A run the conversation handed a task to spends beside the conversation, and +// the conversation's own books hold what its work cost, so the run's money is +// folded in as it is spent — through the fold door, which writes no row on the +// machine's ledger, because the run's own workers wrote one per call. +// +// TWO READINGS OF ONE ACCOUNT, FOLDED ONCE. A program's model API meters call +// by call and hands each call over whole ([RunCharge]); the run's supervisor +// hands over its reconciled running total, which is the only thing a bash +// worker reports. The fold keeps one figure — the dollars already in the books +// — and folds each call whole as it comes, then only the part of the total that +// is beyond that figure. A call is always told before the total that holds it +// (internal/run's delegateMeter.bank), so no dollar is folded by both. + +import ( + "strings" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// RunCharge is one priced call a run's worker made, as the run metered it: the +// model that answered, the tokens and the cached share, and what the provider +// charged — zero where the service reports no price, which is a call nobody +// could price and never a free one. +type RunCharge struct { + Model string + TokensIn int + TokensOut int + Cached int + USD float64 +} + +// foldDust is the smallest remainder of a run's total the fold will write as +// a line of its own. A running total reconciled from cumulative readings and +// the same calls summed one by one differ by float rounding — around 1e-17 — +// and a journal line for that is not money. +const foldDust = 1e-9 + +// beltFold is the conversation's side of one run's money. Its two doors are +// called from different goroutines — a call's charge from the model API, the +// total from the run's supervisor and then from the belt when the run +// returns — so the one figure they share is held under a lock. +type beltFold struct { + agent *Agent + mu sync.Mutex + folded float64 +} + +// charge folds one call whole: its tokens, its cached share, its model and +// its dollars, as ONE call, detached from whatever chat turn is running. +// +// It used to be a bare dollar figure per reading of the run's total, with no +// tokens, no model and no call, so a conversation whose program made 276 calls +// held none of them in its token and call totals; and a service that reports +// no price folded nothing at all. And it moved the running chat turn's share, +// so a turn abandoned while a run was spending was journaled with the run's +// dollars as its own. +func (f *beltFold) charge(charge RunCharge) { + if charge.TokensIn == 0 && charge.TokensOut == 0 && charge.USD == 0 { + return + } + f.mu.Lock() + defer f.mu.Unlock() + cost := charge.USD + f.agent.addDetachedFoldedUsage(&ai.Response{Usage: &ai.Usage{ + PromptTokens: charge.TokensIn, + CompletionTokens: charge.TokensOut, + CacheReadInputTokens: charge.Cached, + Cost: &cost, + }}, strings.TrimSpace(charge.Model), 1) + f.folded += charge.USD +} + +// total folds whatever the run's reconciled running total holds beyond what +// is already in the books: the whole of a bash worker's spend, and nothing +// for a program whose every call arrived through [beltFold.charge] first. +func (f *beltFold) total(total float64) { + f.mu.Lock() + defer f.mu.Unlock() + delta := total - f.folded + if delta <= foldDust { + return + } + f.agent.addDetachedFoldedUsage(&ai.Response{Usage: &ai.Usage{Cost: &delta}}, "", 0) + f.folded = total +} + +// runConversation is the conversation a run's ledger rows are filed under: +// the conversation at the root of this agent's family, which is this agent's +// own journal when it is the conversation — the same answer a task node's +// rows carry as their Root. +func (a *Agent) runConversation() string { + if root := strings.TrimSpace(a.config.rootSession); root != "" { + return root + } + return strings.TrimSpace(a.journalID()) +} diff --git a/internal/session/task_run_money_test.go b/internal/session/task_run_money_test.go new file mode 100644 index 000000000..0ddfcec77 --- /dev/null +++ b/internal/session/task_run_money_test.go @@ -0,0 +1,91 @@ +package session + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +// A RUN'S SPEND IS THE CONVERSATION'S, AND NOT THE RUNNING TURN'S. The run's +// dollars reach the conversation's books, but a run works beside the turns: a +// turn running (or abandoned) while the run spends is not the turn that spent +// it, so its share does not move. The run is handed the conversation's own id +// to file its ledger rows under. +func TestARunsSpendIsFoldedIntoTheConversationAndNotTheRunningTurn(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run ended") + double.summary.USD = 0.42 + registerBeltRunEngine(t, double) + + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run ended"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = Place{Dir: dir}.Transcript() + config.AskConsent = false + }) + agent.mu.Lock() + agent.turnSpend = Usage{Input: 5, Output: 2, Calls: 1, CostUSD: 0.11} + agent.mu.Unlock() + updates, stopUpdates := agent.WatchTaskUpdates() + defer stopUpdates() + + if _, _, _, err := agent.StartTask(context.Background(), "account for this run", false); err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + close(double.release) + lastTaskUpdate(t, updates) + + if got := agent.Usage().CostUSD; got != 0.42 { + t.Fatalf("conversation cost = %v, want the run's $0.42 once", got) + } + agent.mu.Lock() + running := agent.turnSpend + agent.mu.Unlock() + if running.CostUSD != 0.11 || running.Input != 5 || running.Calls != 1 { + t.Fatalf("the run's spend moved the running turn's share: %+v", running) + } + double.mu.Lock() + conversation := double.spec.Conversation + double.mu.Unlock() + if conversation == "" || conversation != agent.journalID() { + t.Fatalf("the run was handed conversation %q, want this conversation's own id %q", conversation, agent.journalID()) + } +} + +// A PROGRAM'S CALL IS FOLDED WHOLE, AND EACH DOLLAR ONCE. A call metered by a +// program's model API reaches the books with its tokens, its cached share and +// one call, even from a service that reports no price; the run's running total +// adds only what the calls did not already carry — a bash worker's spend — and +// float dust between two sums of the same calls is not a line of its own. The +// fold writes nothing to the machine's ledger: the run's worker wrote it. +func TestARunsCallsAreFoldedWholeAndEachDollarOnce(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.usageLedger = ledger + config.SessionFile = filepath.Join(t.TempDir(), "session.jsonl") + }) + fold := &beltFold{agent: agent} + fold.charge(RunCharge{Model: "deepseek/deepseek-v4-pro", TokensIn: 100, TokensOut: 10, Cached: 60, USD: 0.05}) + fold.total(0.05 + 1e-17) + // A service that reports no price: its tokens are still the conversation's. + fold.charge(RunCharge{Model: "gpt-5.6-sol", TokensIn: 200, TokensOut: 20}) + fold.total(0.05) + // A worker that reports only its total: the remainder, once. + fold.total(0.08) + fold.total(0.08) + + usage := agent.Usage() + if usage.Input != 300 || usage.Output != 30 || usage.CacheRead != 60 || usage.Calls != 2 || usage.Turns != 0 { + t.Fatalf("conversation usage = %+v, want both calls' tokens and two calls", usage) + } + if usage.CostUSD < 0.08-1e-12 || usage.CostUSD > 0.08+1e-12 { + t.Fatalf("conversation cost = %v, want $0.08: each dollar once", usage.CostUSD) + } + FlushUsage() + if lines, _ := ReadUsage(ledger, time.Time{}); len(lines) != 0 { + t.Fatalf("the fold wrote %d ledger rows, want none", len(lines)) + } +} From ff4848dd211975230036790586706e36528ee286 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:01:42 -0400 Subject: [PATCH 063/195] codeaf senior-dev: a shell run waits for its last price, files it, and says how long MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shell run closed its spending ledger the moment its program exited, so the call ctrl-c or --max-cost cut in the middle, priced by a receipt about twenty seconds later, never reached the ledger. It now waits for that receipt (through the model API's close) and says so on stderr: "waiting up to 1m 10s for the price of 1 call that was cut short". Its ledger rows, which named nothing but a workspace, are filed as one piece of work under the run's record folder name. The record folder now keeps the program record the comment always said it did, with the program's own start and end, and the last line says how long the program ran: "277 model calls · $2.30 · 22m 51s". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried.go | 98 +++++++++++++++++++++++---- cmd/codeaf/carried_money_test.go | 104 +++++++++++++++++++++++++++++ internal/manual/chat/delegates.md | 2 + internal/manual/chat/senior-dev.md | 27 +++++++- 4 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 cmd/codeaf/carried_money_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index 6e6c4e640..b1afcdce7 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -47,6 +47,7 @@ import ( "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, @@ -55,6 +56,11 @@ import ( // 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 @@ -202,6 +208,11 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { 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, @@ -213,7 +224,7 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { // 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, + Reconciled: charge.Late, Workspace: inv.Workspace, Task: subject, } session.RecordUsage(ledger, session.TagUsage(line, roles.RoleWorker, session.SeatWorker)) view.call(charge) @@ -223,10 +234,15 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { } }, Unbilled: func(model string) { - session.RecordUnbilledCall(ledger, session.TagUsage(session.UsageLine{Model: model, Workspace: inv.Workspace}, roles.RoleWorker, session.SeatWorker)) + session.RecordUnbilledCall(ledger, session.TagUsage(session.UsageLine{Model: model, Workspace: inv.Workspace, Task: subject}, roles.RoleWorker, session.SeatWorker)) }, - Role: lanes.RoleLeafAttached, - Node: inv.Program.Name, + // 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 @@ -248,9 +264,14 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { // 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(os.Stderr, "stopping %s: it has %s to say how it ended\n", inv.Program.Name, grace) + 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, @@ -262,12 +283,26 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { StderrPath: filepath.Join(record, carriedStderrName), Grace: grace, }, view) + ended := time.Now() untell() // 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. + // 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. _ = api.Close() + view.closed(ended) session.CloseUsage() - return view.end(result, runErr, limited.Load(), api.Spent()) + return view.end(result, runErr, limited.Load(), api.Spent(), ended.Sub(started)) +} + +// 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, @@ -418,6 +453,10 @@ type carriedView struct { 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 } func newCarriedView(out io.Writer, inv *delegate.Invocation, record string) *carriedView { @@ -437,11 +476,37 @@ func (v *carriedView) begin() { } 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) +} + func (v *carriedView) Stage(stage, status string) { if v.records != nil { _ = v.records.Stage(stage, status) @@ -537,8 +602,9 @@ func (v *carriedView) call(charge modelapi.Charge) { v.say(" %s", strings.Join(parts, " · ")) } -// end says how the run ended and answers its rung on the exit ladder. -func (v *carriedView) end(result delegate.Result, runErr error, limited bool, spent float64) error { +// 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) { @@ -582,16 +648,24 @@ func (v *carriedView) end(result delegate.Result, runErr error, limited bool, sp v.say(" %s observed: %s", name, observed) } } + // 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. + var summary []string if calls > 0 { word := "calls" if calls == 1 { word = "call" } - summary := fmt.Sprintf(" %d model %s", calls, word) + summary = append(summary, fmt.Sprintf("%d model %s", calls, word)) if spent > 0 { - summary += " · " + carriedDollars(spent) + summary = append(summary, carriedDollars(spent)) } - v.say("%s", summary) + } + if took >= time.Second { + summary = append(summary, reltime.Elapsed(took)) + } + if len(summary) > 0 { + v.say(" %s", strings.Join(summary, " · ")) } if _, err := os.Stat(v.record); err == nil { v.say(" the run's record is in %s", v.record) diff --git a/cmd/codeaf/carried_money_test.go b/cmd/codeaf/carried_money_test.go new file mode 100644 index 000000000..3d6bd3684 --- /dev/null +++ b/cmd/codeaf/carried_money_test.go @@ -0,0 +1,104 @@ +//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. +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()} + view := newCarriedView(printed, inv, filepath.Join(t.TempDir(), "never-written")) + 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) + } + } +} diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 169530dfb..036704e6a 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -42,6 +42,8 @@ say, and what it needs of its folder. At a shell, `codeaf <name> <brief>` 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 <name> --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. ## Which folder a program works in — a repository I have not cloned, it edited files outside its copy, a folder with no git diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 968a0f463..915ca66fd 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -180,6 +180,28 @@ glm-5.1 and minimax-m2.7. A run with no crew set uses it, and so does a shell ru **At a shell you choose**: `--high` replaces the list, `--frontier` and `--low` set the other two, 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 + +At a shell, `codeaf senior-dev` prints each stage, step and model call as it happens, then +how the run ended, then 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`. + +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 <brief>` is `codeaf senior-dev run -- <brief>`. codeaf gives every @@ -265,5 +287,6 @@ tests could run, or to where it began. 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. A run started at a shell has -no task, so its record — that log, its conversation with codeaf and its stages — is kept -in a folder of its own under `~/.codeaf/v3/carried/senior-dev/`, one per run. +no task, so its record — that log, its conversation with codeaf, 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. From 8e4e4be83b433651993359bcca5fb7ba06d01d2d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:06:07 -0400 Subject: [PATCH 064/195] provider: a run's answer with no usage block is priced by its receipt A streamed answer that arrived whole but carried no usage block was billed nowhere and marked nowhere: only a cut stream was ever settled by receipt. true-myth's call 7483768e on 2026-09-23, a 200 on kimi-k2.6 after eight seconds, is in no book. provider.WithUnmeteredReceipts opts one piece of work into settling such an answer the way a cut one is, by its receipt or as a call nobody could price, and a program's model API arms it for every call a run makes. Every other caller's road is unchanged, and a direct service still stops at settle, as services.md says it does. The modelapi marker the previous commit added for this case is replaced by the receipt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/models-and-cost.md | 3 +- internal/manual/chat/senior-dev.md | 5 +- internal/provider/billing.go | 40 ++++++++++++ internal/provider/client.go | 4 +- internal/provider/modelapi/server.go | 56 +++-------------- internal/provider/modelapi/server_test.go | 57 +++++------------ internal/provider/receipt.go | 9 ++- internal/provider/receipt_test.go | 75 +++++++++++++++++++++++ 8 files changed, 153 insertions(+), 96 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index c595132e7..3f78a2005 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1470,7 +1470,8 @@ hedged waste from its own receipt too; it is real provider money, but it is not 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. +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 that schedule, codeaf writes an `unbilled` marker with no invented diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 915ca66fd..42f633258 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -162,8 +162,9 @@ 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). -So is a call answered whole whose answer carried no usage block at all: codeaf has no -figure for it, and does not guess one. +A senior-dev call answered whole whose answer carried no usage block at all is asked +about the same way: 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, its own list, --high diff --git a/internal/provider/billing.go b/internal/provider/billing.go index cf11db117..feee7921e 100644 --- a/internal/provider/billing.go +++ b/internal/provider/billing.go @@ -112,6 +112,7 @@ 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 @@ -144,6 +145,31 @@ func WithReceiptPending(ctx context.Context, pending ReceiptPending) context.Con 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 { @@ -206,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 @@ -215,6 +252,9 @@ 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) } diff --git a/internal/provider/client.go b/internal/provider/client.go index 89af711c9..09f08c9f4 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -1124,7 +1124,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 } @@ -2160,7 +2160,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/server.go b/internal/provider/modelapi/server.go index a7efdda4c..667bb32a3 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -458,23 +458,6 @@ type record struct { mu sync.Mutex turn delegate.Turn ended bool - // owed says the funnel queued a receipt for this call: its price is on its - // way, however late. - owed bool -} - -// owe marks the call as one whose receipt the funnel has queued. -func (r *record) owe() { - r.mu.Lock() - defer r.mu.Unlock() - r.owed = true -} - -// owing reports whether the funnel queued a receipt for this call. -func (r *record) owing() bool { - r.mu.Lock() - defer r.mu.Unlock() - return r.owed } // serveOn says the call went out on the seat instead of the ask. @@ -567,7 +550,6 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { } else { said = answerOf(response, model, bill, catch, slot, out) s.arrived(thread, said.reasoning.field) - s.unpriced(response, bill, entry, model) } s.log(entry.close(func(turn *delegate.Turn) { turn.TokensIn, turn.TokensOut, turn.Cached, turn.CostUSD = bill.figures() @@ -660,10 +642,15 @@ func (s *Server) settings(ctx context.Context, request *call, bill *tally, catch 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, func() func() { - entry.owe() - return s.owed.owe() - }) + 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) } @@ -697,31 +684,6 @@ func (s *Server) charge(bill *tally, billed provider.Billed, late bool) { } } -// unpriced keeps an answered call that nothing priced on the ledger as the -// marker it is. -// -// AN ANSWER WITH NO USAGE BLOCK IS NOT A FREE ONE. The funnel bills a call -// from the usage block at the end of its answer, and asks for a receipt only -// when the answer was cut; an answer 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. Such a call is told as one nobody could price -// ([Config.Unbilled]), exactly as a receipt that never came is, with no money -// invented. A call the funnel billed, or whose receipt is on its way, is not. -func (s *Server) unpriced(response *ai.Response, bill *tally, entry *record, model string) { - if s.config.Unbilled == nil || response == nil || response.Usage != nil || entry.owing() { - return - } - if _, billed := bill.metered(); billed { - return - } - answered := strings.TrimSpace(response.Model) - if answered == "" { - answered = model - } - s.config.Unbilled(answered) -} - // meter adds one charge to the run's total and answers the total. func (s *Server) meter(cost float64) float64 { s.mu.Lock() diff --git a/internal/provider/modelapi/server_test.go b/internal/provider/modelapi/server_test.go index 90d132967..20438834f 100644 --- a/internal/provider/modelapi/server_test.go +++ b/internal/provider/modelapi/server_test.go @@ -837,46 +837,21 @@ func TestARunWhoseCeilingIsAlreadySpentMakesNoCall(t *testing.T) { } } -// AN ANSWER NOTHING PRICED IS SAID, NOT SILENT. A call answered whole whose -// answer carried no usage block was billed nowhere and marked nowhere; it is -// told as a call nobody could price, with no money invented. A call the funnel -// billed — at a price or at none — and a call whose receipt is owed are not. -func TestAnAnsweredCallNothingPricedIsToldAsUnbilled(t *testing.T) { - for _, row := range []struct { - name string - reply func(context.Context, string, []ai.Message, ai.Request) (*ai.Response, error) - want []string - }{ - {name: "no usage block, no receipt", want: []string{"moonshotai/kimi-k2.6"}, - reply: func(_ context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { - return saying(model, "whole"), nil - }}, - {name: "billed", reply: words("whole", 0.03)}, - {name: "billed with no price", reply: words("whole", 0)}, - {name: "a receipt owed", - reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { - done := provider.ReceiptPendingFrom(ctx)() - go done() - return saying(model, "cut"), nil - }}, - } { - t.Run(row.name, func(t *testing.T) { - calls := &script{reply: row.reply} - var mu sync.Mutex - var unbilled []string - _, api := open(t, modelapi.Config{ - CompleterFor: calls.completerFor, - Unbilled: func(model string) { mu.Lock(); unbilled = append(unbilled, model); mu.Unlock() }, - }) - body := `{"model":"moonshotai/kimi-k2.6","messages":[{"role":"user","content":"go"}]}` - if status, payload := post(t, api, api.Token, body); status != http.StatusOK { - t.Fatalf("status %d: %s", status, payload) - } - mu.Lock() - defer mu.Unlock() - if strings.Join(unbilled, ",") != strings.Join(row.want, ",") { - t.Fatalf("unbilled = %v, want %v", unbilled, row.want) - } - }) +// 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/receipt.go b/internal/provider/receipt.go index 21b0bd4f7..33c236772 100644 --- a/internal/provider/receipt.go +++ b/internal/provider/receipt.go @@ -72,10 +72,13 @@ 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{ diff --git a/internal/provider/receipt_test.go b/internal/provider/receipt_test.go index 9126b6cb7..a1851c49a 100644 --- a/internal/provider/receipt_test.go +++ b/internal/provider/receipt_test.go @@ -753,3 +753,78 @@ func TestAQueuedReceiptIsOwedUntilItsSinkHasBankedIt(t *testing.T) { 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) + } + }) + } +} From 1cc7452d2ef97a2fc8c51383c3197e15304c6184 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:06:53 -0400 Subject: [PATCH 065/195] manual: the dollar ceiling cannot hold on a service that reports no prices The senior-dev page promised that codeaf enforces the conversation's dollar ceiling on every run. On a service that reports no price with its answers (a local proxy, a vendor's own API, a Codex sign-in) every call is banked with its tokens and no dollars, so nothing reaches the ceiling and senior-dev's own --max-cost adds up the same missing figures: run 3d6d made 27 calls and 1.58M input tokens under a $5 ceiling that could never bind. codeaf still does not guess a price, so the pages now say so plainly and name the bound that does hold there, a time limit set with --max-hours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 5 ++++- internal/manual/chat/senior-dev.md | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 036704e6a..7bc990b8f 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -79,7 +79,10 @@ it proposes one. 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 `<name> 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. +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 runs alone.** While one is running, no other task can join its copy, and it cannot be started under another run. Both are refused with the folder that is busy: diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 42f633258..998f94638 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -64,7 +64,9 @@ ask is turned down inside the program, and after three it is told questions are available. Put everything it would stop and ask into the brief. **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. +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 @@ -151,6 +153,24 @@ conversation on the task page names the model that answered. When nothing here c that model either, the conversation's own model may answer instead, and the page names whichever model did. 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 From 963e38b07dc2dd1aad4eeec5a526fd33623e69ef Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:09:10 -0400 Subject: [PATCH 066/195] senior-dev: a step costs what the service charged, not the catalog's guess senior-dev priced each step from models.dev rates times its tokens even when the answer carried the service's own price, and its agent-summary added those up: on the runs of 2026-09-23 it read 1.2 to 10 times under what codeaf metered, so delegate-stderr.log told a reader a $2.30 run had cost $0.57. The step's cost is now the price the finish part names when it names one, and the catalog's reading only when it does not. It is an account and nothing more: senior-dev's budget already read the service's figure, and how it plans, works and submits is untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 4 +- .../seniordev/engine/steploop/processor.go | 29 +++++++++++- .../engine/steploop/steploop_test.go | 46 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 998f94638..9f7a07bba 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -307,7 +307,9 @@ tests cannot even start there, the tree is put back to the last state whose buil tests could run, or to where it began. 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. A run started at a shell has +is kept in `delegate-stderr.log` in the task's record folder. 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 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/seniordev/engine/steploop/processor.go b/internal/seniordev/engine/steploop/processor.go index 94ab97306..f158fd241 100644 --- a/internal/seniordev/engine/steploop/processor.go +++ b/internal/seniordev/engine/steploop/processor.go @@ -466,12 +466,23 @@ func (p *Processor) finish(ctx context.Context, value orclient.FinishPart) error Model: p.model.Calc, Usage: calc.AsLanguageModelUsage(value.Usage), }) + // THE SERVICE'S OWN PRICE WINS OVER THE CATALOG'S. A step's cost is the + // catalog's rates times its tokens only when the service put no price on + // it; when it did, that is what was charged. The catalog's reading ran + // 1.2 to 10 times under codeaf's meter on the runs of 2026-09-23, and the + // run's agent-summary, which adds these up, told a reader of + // delegate-stderr.log that a $2.30 run had cost $0.57. It is an account + // and nothing more: the run's budget already reads the service's figure. + cost := usage.Cost + if reported, ok := reportedCost(value); ok { + cost = reported + } finish := value.FinishReason.Unified tokens := messageTokens(usage.Tokens) part := msgmodel.StepFinishPart{ PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, Reason: finish, - Cost: float64(usage.Cost), + Cost: float64(cost), Tokens: tokens, } if value.Metadata.Provider != nil { @@ -482,7 +493,7 @@ func (p *Processor) finish(ctx context.Context, value orclient.FinishPart) error } p.mu.Lock() p.message.Finish = &finish - p.message.Cost = float64(float64(p.message.Cost) + usage.Cost) + p.message.Cost = float64(float64(p.message.Cost) + cost) p.message.Tokens = tokens p.message.Upstream = part.Upstream message := p.message @@ -490,6 +501,20 @@ func (p *Processor) finish(ctx context.Context, value orclient.FinishPart) error return p.store.UpdateMessage(ctx, message) } +// reportedCost is the price the model's service put on one step, read off the +// finish part's usage block, and whether it named one. +func reportedCost(value orclient.FinishPart) (float64, bool) { + raw, ok := value.Metadata.Usage.Get("cost") + if !ok { + return 0, false + } + var cost float64 + if json.Unmarshal(raw, &cost) != nil { + return 0, false + } + return cost, true +} + func (p *Processor) setAssistantError(classified retrysched.Err, fallback string) { message := fallback if classified.Data.Message != nil && *classified.Data.Message != "" { diff --git a/internal/seniordev/engine/steploop/steploop_test.go b/internal/seniordev/engine/steploop/steploop_test.go index 4435d7558..76ec2bee3 100644 --- a/internal/seniordev/engine/steploop/steploop_test.go +++ b/internal/seniordev/engine/steploop/steploop_test.go @@ -761,3 +761,49 @@ func TestFinishRecordsReportedUpstream(t *testing.T) { t.Fatalf("step-finish upstream = %q, want provider-b", finish.Upstream) } } + +// A step's cost is the price its service put on it when it named one, and the +// catalog's rates times its tokens only when it did not: the catalog read far +// under what codeaf metered, and the run's agent-summary adds these up. +func TestFinishCostsWhatTheServiceChargedWhenItSaid(t *testing.T) { + for _, row := range []struct { + name string + reported *float64 + want float64 + }{ + {name: "the service named a price", reported: floatPtr(0.0412), want: 0.0412}, + {name: "the service named none", want: 0}, + } { + t.Run(row.name, func(t *testing.T) { + fixedSeams(t) + store := &memoryStore{messages: []msgmodel.WithParts{baseUser("msg_0000", "build", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0000", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "hello"}, + })}} + served := finishPart(orclient.FinishStop) + if row.reported != nil { + served.Metadata.Usage = orclient.NewObject() + served.Metadata.Usage.SetNumber("cost", *row.reported) + } + client := &scriptedClient{scripts: [][]orclient.StreamPart{{ + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + served, + }}} + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: &immediateTool{}} + final, err := loop.Run(context.Background(), RunOptions{SessionID: "ses_1", Workspace: "/work", Worktree: "/work"}) + if err != nil { + t.Fatal(err) + } + if final.Cost != row.want { + t.Fatalf("assistant cost = %v, want %v", final.Cost, row.want) + } + finish := store.rawSnapshot()[1].Parts[2].(msgmodel.StepFinishPart) + if finish.Cost != row.want { + t.Fatalf("step-finish cost = %v, want %v", finish.Cost, row.want) + } + }) + } +} + +func floatPtr(value float64) *float64 { return &value } From 06ebe4cc15c00139c31c720b8d9ca34262d65fb5 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:11:47 -0400 Subject: [PATCH 067/195] modelapi: the owed-receipt count unlocks from a defer The count of receipts a run is owed took its lock and released it by hand in two places, which the guard law rejects: an absorbed panic between the two would leave the run's close waiting on a lock nobody releases. Each critical section is now its own small function with the unlock deferred under the lock, and the wait reads the idle channel through one of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/provider/modelapi/server.go | 47 ++++++++++++++++++---------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go index 667bb32a3..cd892c528 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -302,22 +302,29 @@ type receiptsOwed 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++ - o.mu.Unlock() - var once sync.Once - return func() { - once.Do(func() { - o.mu.Lock() - defer o.mu.Unlock() - o.n-- - if o.n == 0 { - close(o.idle) - } - }) +} + +// 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) } } @@ -328,18 +335,26 @@ func (o *receiptsOwed) count() int { 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 { - o.mu.Lock() - if o.n == 0 { - o.mu.Unlock() + idle := o.owing() + if idle == nil { return true } - idle := o.idle - o.mu.Unlock() left := time.Until(deadline) if left <= 0 { return false From 9b5dd81d61cdcd96f63b87644f5cf0aca0856aa8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:13:23 -0400 Subject: [PATCH 068/195] run: a program's live step goes while its last price is owed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker cleared the task's live step only after the model API had closed, and the close now waits for a cut call's receipt, up to seventy seconds after the program has exited. Through that wait the row went on reading "senior-dev: implement · running" about a process that was gone. The API's Settling hook now clears the live step the moment the wait begins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/run/delegate_money_test.go | 8 ++++++-- internal/run/delegateworker.go | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/run/delegate_money_test.go b/internal/run/delegate_money_test.go index 75307d9c8..be5b9179e 100644 --- a/internal/run/delegate_money_test.go +++ b/internal/run/delegate_money_test.go @@ -108,10 +108,14 @@ func TestDelegateWorkerBanksTheReceiptThatArrivesAfterTheProgramExited(t *testin 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())) @@ -126,9 +130,9 @@ func TestDelegateWorkerBanksTheReceiptThatArrivesAfterTheProgramExited(t *testin t.Fatalf("spend rows = %+v, want the late receipt's row", store.SpendSummary().ByModel) } mu.Lock() - if folded != 0.058188488 { + if folded != 0.058188488 || liveWhilePriced != 0 { mu.Unlock() - t.Fatalf("folded %v before the worker reported, want the late receipt", folded) + 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 { diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 2ceb6b1d3..03ef303cb 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -375,6 +375,11 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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, From bd27116c453ef08658399783533a058eda3f2700 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:13:53 -0400 Subject: [PATCH 069/195] modelapi: the call settings comment names every door it arms The comment on the per-call settings still counted four sinks after the owed-receipt count and the unmetered-answer ask joined them; it now names all of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/provider/modelapi/server.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go index cd892c528..f65306113 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -632,8 +632,9 @@ func (s *Server) complete(ctx context.Context, out *reply, request *call, model // 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, and the four -// sinks that meter it, catch its working and name its server. +// 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 == "" { From 8ca394ad142fdf6a50c6f8783eb2caaa94c31dcc Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:14:43 -0400 Subject: [PATCH 070/195] run: the delegate worker's header says where each call's price goes now The file's opening comment still said the conversation folds the run's total into its own meter; it folds each call whole, and the ledger row names the conversation and the task. The comment now says four books and which. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/run/delegateworker.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 03ef303cb..b6c8b9666 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -27,12 +27,13 @@ package run // // ── MONEY IS METERED BY THE API, NEVER REPORTED BY THE PROGRAM ────────────── // -// Each call's price reaches three books as it is metered ([delegateMeter]): -// the run's live bank, which the supervisor holds to the ceiling and the -// conversation's status line reads; 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 — the conversation folds the run's total into its own meter -// without writing a ledger row of its own (internal/session's addFoldedUsage). +// 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. From 51481d09212c0862ed6bf52dd1b90d32af815997 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:43:39 -0400 Subject: [PATCH 071/195] plandb: a run can be ended at the instant it was last seen FailRoot always wrote now, so a run whose process went away could only be ended by whoever found its store open next, at that moment: its page would count every idle hour since as time the run had worked. FailRootAt ends the run at the instant the caller names, held between the run's own start and now, and LastSpendAt answers the ledger's latest charge, one of the readings that instant is taken from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/plandb/stoproot_test.go | 70 +++++++++++++++++++++++++++++++ internal/plandb/store.go | 71 ++++++++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/internal/plandb/stoproot_test.go b/internal/plandb/stoproot_test.go index 47de568cb..bb9d2e4f2 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. @@ -76,3 +77,72 @@ func TestFailRootEndsTheRunWithoutAResult(t *testing.T) { 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) + } +} diff --git a/internal/plandb/store.go b/internal/plandb/store.go index d6f910657..29271f5d0 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -1562,6 +1562,24 @@ func (s *Store) StopRoot(reason string) error { // adopted the dead run's store as live work ([Store.StopRoot] says why an open // run is adopted). 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 { @@ -1569,6 +1587,13 @@ func (s *Store) FailRoot(reason string) error { 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 { @@ -1576,12 +1601,15 @@ func (s *Store) FailRoot(reason string) error { } task.Status, task.Error, task.ClaimedBy = StatusCancelled, reason, "" task.Owner, task.SeenAt = "", time.Time{} - task.UpdatedAt, task.CompletedAt = now, now + 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 = now, now - promote(next, now) + root.UpdatedAt, root.CompletedAt = ended, ended + promote(next, ended) return nil }) } @@ -2968,6 +2996,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 From af8547e183451b7be7a3e4a8d8a2cdbf262f217b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:51:19 -0400 Subject: [PATCH 072/195] session: a run no process holds is a record, never more work A new hand-off adopted any store whose root had not ended, and a root stays open whenever its process goes away mid-run: codeaf quit, crashed or was stopped by signal while a program worked (Close only cancelled the run, and the only ending was written after the engine answered), or an ordinary run ended on a limit. On 2026-09-24 a CSSTree senior-dev run was handed the dead happy-dom run's brief, its calls, spend, ceiling and ending were written into happy-dom's record folder, and the CSSTree task had no page. A new hand-off now always seeds a store under its own number and archives whatever store it finds. A program's run left open is first ended with "codeaf closed while <name> was running" at its last evidence of life (its program's exit, its last model call, its last charge, the store's last write), so its page stops counting there; an ordinary run is archived intact as its record. Closing the conversation or the engine writes that ending on a program's run before the run is cut, the way a stop does, rather than waiting for the run, which would hold a quit through the program's grace and still write nothing if the process died in the wait. A conversation reopened with its run row interrupted over an open program store ends it the same way. The carry-on door keeps adopting, since carrying a run on is the one thing that should. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 4 + internal/manual/chat/senior-dev.md | 21 +- internal/manual/chat/worker-harness.md | 5 +- internal/session/agent.go | 3 + internal/session/task_run_belt.go | 221 ++++++++++++++++++-- internal/session/task_run_orphan_test.go | 255 +++++++++++++++++++++++ 6 files changed, 492 insertions(+), 17 deletions(-) create mode 100644 internal/session/task_run_orphan_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 7bc990b8f..99eeb1213 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -92,6 +92,10 @@ when that work has ended`. **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 does not outlive codeaf.** If codeaf quits, crashes or is stopped while a program +works, its run ends with `codeaf closed while <name> was running`, at the last moment it +was seen working. Nothing carries it on: the next hand-off starts a run of its own. + A name your build does not carry is refused with the ones it does: `this codeaf carries no program called <name>; it carries …`. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 9f7a07bba..0de85a5ce 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -300,12 +300,31 @@ A run ends in one of these ways, and the task's ending says which: - `senior-dev crashed: …` — the program itself broke, or could not start (no brief, a refused `senior-dev.json`, no git repository at a shell without `--in-place`); - `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`. + what senior-dev said on its way out, usually `stopped before it finished`; +- `codeaf closed while senior-dev was running` — codeaf quit, crashed or was stopped + while it worked (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 + +senior-dev runs inside the codeaf that started it and ends with it. When you quit codeaf +or close the conversation, when the engine is stopped (`codeaf engine --stop`, a signal), +or when codeaf crashes while senior-dev is working, the run is over: its page reads +`incomplete` with `codeaf closed while senior-dev was running` beside it. + +**The run ends where it was last seen working**: the end of its last model call, its last +charge, or its store's last change, whichever is latest. So the time and the spend on its +page stop there, and do not count the hours codeaf was closed. When codeaf closes in an +orderly way the ending is written before senior-dev is stopped; after a crash it is +written by the next codeaf that opens that conversation, or that hands work off in it. + +**Nothing carries it on.** The next `/senior-dev` in that conversation starts a run of its +own, under its own task number, with its own brief and its own page. The old run's page +stays as the record of what it did. + 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. Its `agent-summary` there adds up each of its agents' calls, time and cost; the cost is the price codeaf's model diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 965c0e0ef..6d2b637ef 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -27,7 +27,10 @@ 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. +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, exactly as it was left, +and new work never runs inside it. **When the run ends its work comes home by itself.** The copy's work is committed and merged into the folder it was cut from, the copy is given back, and the run's page diff --git a/internal/session/agent.go b/internal/session/agent.go index d9d03d08c..58aafec16 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -376,6 +376,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { // recovery is load, reconcile with the disk, continue the frontier // (task_store.go). A fresh session has no checkpoint and this is a stat. agent.recoverTasks() + // AND A PROGRAM'S RUN THE LAST PROCESS LEFT OPEN IS ENDED, where it was last + // seen, so its page stops reading `running` (task_run_belt.go). + agent.endInterruptedProgramRun() // AND THE PROJECT'S RECORD IS RECONCILED BESIDE IT. The checkpoint above is // one conversation's graph; the project index is every window's record of // what this directory ever ran, and it holds rows that say "running" — a run diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 545562476..acff4d709 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -24,10 +24,12 @@ package session // `/task` while one is live adds its work to that same store rather than // opening a second one, because one store is one run (`plandb`'s own law: a // store belongs to one root), and the becomes-live child is dispatched by the -// supervisor already turning. A conversation that has no live run seeds a fresh -// store the way [TaskGraph.planSeed] seeds one — adopting a live store it finds, -// archiving a finished one beside the session folder — so a resumed -// conversation keeps reading its own plan. +// supervisor already turning. A conversation that has no live run seeds a +// fresh store under the new hand-off's own number, and the store already +// there is archived beside the session folder whatever its root says +// ([Agent.seedBeltRunStore]): a store no run in this process holds is a record, +// never more work, so a resumed conversation still reads its old plan and a +// new hand-off never runs inside it. import ( "context" @@ -376,7 +378,7 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie return a.joinBeltRun(g, live, id, title, brief, dependencies, stand, via) } - plan, store, err := a.openBeltRunStore(g, path, storeID, title, brief) + plan, store, err := a.seedBeltRunStore(g, path, storeID, title, brief) if err != nil { return err } @@ -596,12 +598,183 @@ func delegateGroundNames(proposed, copyDir string) []string { return names } -// openBeltRunStore opens the conversation's store for a run, creating it under -// this run's root or adopting the live one already there. It is [planSeed]'s own -// road stated for the run door: a store whose root has ended is archived beside +// seedBeltRunStore opens a fresh store for a NEW hand-off, under the hand-off's +// own number. A store already at the path is archived beside the session folder +// the way a finished one always was, and never adopted, whatever its root says. +// +// A NEW HAND-OFF ONCE ADOPTED ANY STORE WHOSE ROOT HAD NOT ENDED, and a root +// stays open whenever its process went away mid-run: codeaf quit, crashed, or +// was stopped by signal while a program worked, or an ordinary run ended on a +// limit its person set. The next `/senior-dev` in that conversation then ran +// inside the dead run's store: measured on 2026-09-24, a CSSTree run was handed +// the earlier happy-dom run's brief, its calls, spend, ceiling and ending were +// written into the happy-dom task's record folder, the happy-dom page came to +// read `stopped · $2.38 of $1.24 · 277 calls · 1h 7m` over a run that had +// failed after 29 minutes, and the CSSTree task had no page at all. This door +// is only ever reached with no run live in this process ([Agent.beltRun] is +// asked first), so any store it finds is a record, and a record is archived. +// +// A PROGRAM'S RUN LEFT OPEN IS ENDED BEFORE IT IS ARCHIVED, at its last evidence +// of life ([endOrphanedProgramRun]), because nothing can ever carry a program's +// run on and its page would read `running` for ever. An ordinary run left open +// is archived INTACT: its store is the record of what it did, and ending it +// here would write a fault over work a limit its person set had paused. +func (a *Agent) seedBeltRunStore(g *TaskGraph, path, rootID, title, brief string) (*planState, *plandb.Store, error) { + plan := &planState{path: path, chat: g.planChat()} + if _, err := os.Stat(path); os.IsNotExist(err) { + store, err := plandb.Open(path, title, rootID, title, brief, plan.chat) + return plan, store, err + } else if err != nil { + return nil, nil, err + } + old, err := plandb.Open(path, "", "", "", "") + if err != nil { + return nil, nil, err + } + endOrphanedProgramRun(old) + _ = old.Close() + archived := fmt.Sprintf("%s.%d", path, len(planArchivePaths(path))+1) + if err := os.Rename(path, archived); err != nil { + return nil, nil, err + } + store, err := plandb.Open(path, title, rootID, title, brief, plan.chat) + return plan, store, err +} + +// programClosedSentence is the ending written on a program's run that codeaf +// closed under: the conversation or the engine ended while the program worked. +// It says what happened in plain words, because nobody decided anything and the +// work did not fail on its own. +func programClosedSentence(name string) string { + return "codeaf closed while " + name + " was running" +} + +// endOrphanedProgramRun ends a program's run whose store was left open by a +// process that went away, at the run's last evidence of life. It does nothing +// to a store whose run has ended, or whose run no program worked (the task's +// record folder holds no program record, [delegate.ProgramFile]). +// +// THE ENDING IS WRITTEN WHEN THE RUN WAS LAST SEEN, NOT NOW. The process that +// finds the store can be hours later than the one that lost it, and the page +// counts a run's time to its ending ([plandb.Store.FailRootAt] says why). +func endOrphanedProgramRun(store *plandb.Store) { + rootID := store.RootID() + root := store.Task(rootID) + if root == nil || terminalStoreStatus(root.Status) { + return + } + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), rootID) + record, ok := delegate.ReadProgram(taskDir) + if !ok { + return + } + endProgramRunClosed(store, record.Name, lastEvidenceOfLife(store, root, taskDir, record)) +} + +// endProgramRunClosed writes a program's run's ending when codeaf closed under +// it: the run's task failed with the plain sentence at the instant named (zero +// is now), and the same sentence as the task's newest note, which is the line +// its page carries — the store's error is a field no page draws, and a page +// that read `incomplete` with nothing beside it would send a person looking +// for a fault in the work. +func endProgramRunClosed(store *plandb.Store, name string, at time.Time) { + sentence := programClosedSentence(name) + if err := store.FailRootAt(sentence, at); err != nil { + return + } + if root := store.Task(store.RootID()); root == nil || root.Error != sentence { + // A run that had already ended keeps its own ending and its own words. + return + } + _, _ = store.AddNote(store.RootID(), store.RootID(), sentence) +} + +// lastEvidenceOfLife is the latest instant a program's run is known to have +// been working: its program's recorded exit when it has one, the end of its +// last model call (or the start of one that never came back), its last charge, +// and the store's own last write to its task. The zero time means none of them +// is known, which the ending reads as now. +func lastEvidenceOfLife(store *plandb.Store, root *plandb.Task, taskDir string, record delegate.ProgramRecord) time.Time { + latest := root.UpdatedAt + later := func(at time.Time) { + if at.After(latest) { + latest = at + } + } + later(record.StartedAt) + later(record.EndedAt) + later(store.LastSpendAt()) + if turns, err := delegate.ReadTurns(taskDir, 0); err == nil { + for _, turn := range turns { + later(turn.Started) + later(turn.Ended) + } + } + return latest +} + +// endInterruptedProgramRun is the restore's half of the same ending: a +// conversation read back from disk whose run row comes back interrupted over a +// program's store that is still open has its run ended there, at the run's +// last evidence of life. It runs once, as the conversation is opened, when the +// process opening it is the only one that holds it (the session file's lock), +// so no run of this conversation can be live anywhere. +// +// WITHOUT IT THE PAGE READ `running` UNTIL THE NEXT HAND-OFF. codeaf closing +// under a program's run left the store open, and a reopened conversation drew +// that run's page as running, offered to stop it, and counted its clock up from +// when it started for as long as the page stayed open. +func (a *Agent) endInterruptedProgramRun() { + if a.config.InTask { + return + } + g := a.graph() + if g == nil || !g.holdsInterruptedRun() { + return + } + path := g.planPath() + if path == "" { + return + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return + } + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + return + } + defer store.Close() + row, err := strconv.ParseUint(store.RootID(), 10, 64) + if err != nil { + return + } + if kept, found := runRowOf(g, row); !found || kept.State != TaskInterrupted { + return + } + endOrphanedProgramRun(store) +} + +// holdsInterruptedRun says whether any run row this graph holds came back +// interrupted, so a conversation with none never opens its store to ask. +func (g *TaskGraph) holdsInterruptedRun() bool { + g.mu.Lock() + defer g.mu.Unlock() + for _, rows := range g.runs { + for _, row := range rows { + if row.State == TaskInterrupted { + return true + } + } + } + return false +} + +// openBeltRunStore opens the conversation's store for a run that is CARRIED ON +// ([Agent.ContinueRun]), adopting the store already there. It is [planSeed]'s own +// road stated for that door: a store whose root has ended is archived beside // the session folder and a fresh one seeded, because a finished plan is not a -// live one; a store still running is adopted, because it is this conversation's -// run and a second `/task` is more of its work. +// live one; a store still running is adopted, because the run being carried on +// is the one it holds. A new hand-off never comes here ([Agent.seedBeltRunStore]). func (a *Agent) openBeltRunStore(g *TaskGraph, path, rootID, title, brief string) (*planState, *plandb.Store, error) { plan := &planState{path: path, chat: g.planChat()} if _, err := os.Stat(path); os.IsNotExist(err) { @@ -686,16 +859,34 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { // // IT IS NOT A PERSON'S STOP AND MUST NOT BE MISTAKEN FOR ONE. A stop writes the // person's reason on the store's root and settles the row in their words -// (stoprun.go); this writes nothing and says nothing, because nobody asked for -// anything — the room simply closed. What the run did is in its store, which is -// where the next launch reads it from. +// (stoprun.go); this says nothing in the conversation, because nobody asked for +// anything — the room simply closed. What an ordinary run did is in its store, +// which is where the next launch reads it from, and its root is left open. +// +// A PROGRAM'S RUN IS ENDED IN ITS STORE FIRST, THEN CUT, the order a stop takes. +// Nothing can carry a program's run on, and the ending the run writes for itself +// comes only after the engine has answered, which on an engine being shut down +// (a signal, `codeaf engine --stop`) is after the process has gone: the store +// then said `running` for ever, and the next hand-off ran inside it. Written +// here, the ending is on disk before anything is cut. WAITING instead — holding +// Close until the run had written its own ending — was the other road, and it +// is the weaker one: it holds a person's quit for the program's grace and the +// landing behind it, and a process killed during that wait writes nothing at +// all. A crash writes nothing either way; that store is ended by the next +// process to find it ([endOrphanedProgramRun]). func (a *Agent) cutBeltRun() { a.beltMu.Lock() + run := a.beltRun var cut context.CancelFunc - if a.beltRun != nil { - cut = a.beltRun.cut + stopped := false + if run != nil { + cut, stopped = run.cut, run.stopped } a.beltMu.Unlock() + if run != nil && run.delegate != nil && !stopped { + // A run a person already stopped keeps the stop's ending and its words. + endProgramRunClosed(run.store, run.delegate.Name, time.Time{}) + } if cut != nil { cut() } diff --git a/internal/session/task_run_orphan_test.go b/internal/session/task_run_orphan_test.go new file mode 100644 index 000000000..f91ef0b31 --- /dev/null +++ b/internal/session/task_run_orphan_test.go @@ -0,0 +1,255 @@ +package session + +// A RUN NO PROCESS HOLDS IS A RECORD, NEVER MORE WORK. +// +// On 2026-09-24 a CSSTree `/senior-dev` ran inside the store an earlier +// happy-dom run had left open when codeaf went away under it: the program was +// handed happy-dom's brief in the CSSTree copy, its calls, spend, ceiling and +// ending were written into happy-dom's record folder, and the CSSTree task had +// no page. These tests pin the three doors that close that: the conversation +// closing under a program's run ends it before anything is cut, a new hand-off +// archives whatever store it finds and seeds its own, and a conversation read +// back from disk ends a program's run the last process left open — each at the +// run's last evidence of life, never at the moment somebody noticed. + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// orphanProgramStore writes, at the conversation's plan path, the store a +// program's run leaves when its process goes away mid-run: its run task still +// open, the program's record beside it, a charge on the ledger, and a model +// call that came back at lastSeen. It answers lastSeen. +func orphanProgramStore(t *testing.T, path, rootID, brief string) time.Time { + t.Helper() + store, err := plandb.Open(path, "the dead run", rootID, "the dead run", brief) + if err != nil { + t.Fatalf("seed the dead run's store: %v", err) + } + defer store.Close() + if err := store.AddSpend(rootID, "delegate/fake", "work", 0.25, 100, 20); err != nil { + t.Fatal(err) + } + taskDir := plandb.TaskDir(filepath.Dir(path), rootID) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", CeilingUSD: 3.56}); err != nil { + t.Fatal(err) + } + started := time.Now().UTC() + lastSeen := started.Add(time.Millisecond) + if err := delegate.AppendTurn(taskDir, delegate.Turn{Seq: 1, Started: started, Ended: lastSeen, Model: "vendor/m"}); err != nil { + t.Fatal(err) + } + return lastSeen +} + +// THE CRASH ROAD: a store a dead program's run left open is ended at its last +// evidence of life and archived, and the new hand-off runs in a store of its +// own, under its own number, on its own brief. +func TestANewHandOffNeverRunsInsideADeadProgramsStore(t *testing.T) { + double := newBeltRunDouble("") + registerBeltRunEngine(t, double) + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + path := agent.graph().planPath() + lastSeen := orphanProgramStore(t, path, "3", "the FIRST brief: implement happy-dom") + + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "the SECOND brief: implement csstree") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + rootID := spec.Store.RootID() + if rootID != strconv.FormatUint(id, 10) { + t.Fatalf("the new run was seated on store root %q, want its own number %d", rootID, id) + } + if got := spec.Store.Task(rootID).Description; got != "the SECOND brief: implement csstree" { + t.Fatalf("the new run's task holds the brief %q, want its own", got) + } + if _, ok := agent.PlanTaskPage(strconv.FormatUint(id, 10)); !ok { + t.Fatal("the new run has no page of its own") + } + endBeltRun(t, agent, double) + + // THE DEAD RUN IS A RECORD NOW: archived, ended where it was last seen. + archived, err := plandb.Open(path+".1", "", "", "", "") + if err != nil { + t.Fatalf("the dead run's store was not archived: %v", err) + } + defer archived.Close() + root := archived.Task("3") + if root == nil || root.Status != plandb.StatusFailed || root.Error != "codeaf closed while fake was running" { + t.Fatalf("the dead run's task = %+v, want failed with the plain sentence", root) + } + if !root.CompletedAt.Equal(lastSeen) { + t.Fatalf("the dead run ended at %v, want its last model call's end %v", root.CompletedAt, lastSeen) + } + if record, ok := delegate.ReadProgram(plandb.TaskDir(place, "3")); !ok || record.CeilingUSD != 3.56 { + t.Fatalf("the dead run's record was written over: %+v %v", record, ok) + } +} + +// AN ORDINARY RUN A LIMIT ENDED IS ARCHIVED INTACT. Its store is the record of +// what it did, so it is not ended; and the next hand-off runs its own brief +// under its own number rather than resuming the old one under the new title. +func TestALimitEndedRunIsArchivedIntactAndTheNextHandOffRunsItsOwnBrief(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + conversation := beltRunCommittedRepo(t) + dir := t.TempDir() + double := newBeltRunDouble("") + double.real = true + double.leaveOpen = true + double.summary = RunSummary{Outcome: "a limit you set stopped it", Limit: RunLimitCost, Nodes: 1, Steps: 3, USD: 1.5} + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: dir} + }) + if err := agent.startKnownTaskRun(context.Background(), 91, "the limited run", "brief one", nil, taskStand{dir: conversation, mode: TaskModeWorktree}, ""); err != nil { + t.Fatal(err) + } + <-double.entered + endBeltRun(t, agent, double) + + second := newBeltRunDouble("second") + second.real = true + registerBeltRunEngine(t, second) + if err := agent.startKnownTaskRun(context.Background(), 92, "an unrelated change", "brief two", nil, taskStand{dir: conversation, mode: TaskModeWorktree}, ""); err != nil { + t.Fatal(err) + } + <-second.entered + second.mu.Lock() + store := second.spec.Store + second.mu.Unlock() + if root := store.Task(store.RootID()); store.RootID() != "92" || root.Description != "brief two" || store.Task("91") != nil { + t.Fatalf("the next hand-off runs on root %q with brief %q (holds 91: %v), want its own", store.RootID(), root.Description, store.Task("91") != nil) + } + endBeltRun(t, agent, second) + + archived, err := plandb.Open(filepath.Join(dir, planStoreFilename)+".1", "", "", "", "") + if err != nil { + t.Fatalf("the limited run's store was not archived: %v", err) + } + defer archived.Close() + if root := archived.Task("91"); root == nil || terminalStoreStatus(root.Status) || root.Description != "brief one" { + t.Fatalf("the limited run's task = %+v, want it archived as it was left", root) + } +} + +// THE CLOSE ROAD: the conversation (or the engine) closing under a program's +// run writes its ending before anything is cut, so the store never outlives +// the process saying `running`, and the next hand-off gets its own store. +func TestClosingUnderAProgramsRunEndsItInItsStoreFirst(t *testing.T) { + place := t.TempDir() + workspace := newTestRepo(t) + first := newBeltRunDouble("") + registerBeltRunEngine(t, first) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "the FIRST brief: implement happy-dom"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-first.entered + first.mu.Lock() + firstRoot := first.spec.Store.RootID() + first.mu.Unlock() + // The double plays a program still inside its grace when Close returns: + // the process that closed would be gone before the run wrote anything. + _ = agent.Close() + store := beltRunStoreAt(t, place) + root := store.Task(firstRoot) + _ = store.Close() + if root.Status != plandb.StatusFailed || root.Error != "codeaf closed while fake was running" || root.CompletedAt.IsZero() { + t.Fatalf("after Close the run's task = %s (%q, ended %v), want it ended in the store", root.Status, root.Error, root.CompletedAt) + } + // AND ITS PAGE SAYS WHY: the page draws the task's newest note. + if page, ok := agent.PlanTaskPage(firstRoot); !ok || page.Row.Note != "codeaf closed while fake was running" { + t.Fatalf("the closed run's page row = %+v (%v), want the plain sentence beside it", page.Row, ok) + } + + second := newBeltRunDouble("") + registerBeltRunEngine(t, second) + again, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := again.StartDelegate(context.Background(), "fake", "the SECOND brief: implement csstree") + if err != nil { + t.Fatalf("second StartDelegate: %v", err) + } + <-second.entered + second.mu.Lock() + spec := second.spec + second.mu.Unlock() + if spec.Store.RootID() != strconv.FormatUint(id, 10) || spec.Store.Task(spec.Store.RootID()).Description != "the SECOND brief: implement csstree" { + t.Fatalf("the second run is on root %q with brief %q", spec.Store.RootID(), spec.Store.Task(spec.Store.RootID()).Description) + } + endBeltRun(t, again, second) + close(first.release) + <-first.finished +} + +// THE RESTORE ROAD: a conversation read back from disk whose run row comes +// back interrupted over a program's store the last process left open has that +// run ended at its last evidence of life, before anybody hands off again. +func TestAReopenedConversationEndsTheProgramRunItsLastProcessLeftOpen(t *testing.T) { + place := t.TempDir() + workspace := newTestRepo(t) + registerBeltRunEngine(t, newBeltRunDouble("")) + open := func() *Agent { + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: place} + config.SessionFile = filepath.Join(place, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + life := open() + g := life.graph() + id := g.reserve() + rootID := strconv.FormatUint(id, 10) + lastSeen := orphanProgramStore(t, g.planPath(), rootID, "the brief") + // The row as the process that died last wrote it down: running. + life.publishRunRow(g, TaskNotice{ID: id, Title: "the dead run", State: TaskRunning, StartedAt: time.Now()}) + _ = life.Close() + + reopened := open() + if rows := reopened.graph().runRows(id); len(rows) != 1 || rows[0].State != TaskInterrupted { + t.Fatalf("the row came back as %+v, want interrupted", rows) + } + store := beltRunStoreAt(t, place) + defer store.Close() + root := store.Task(rootID) + if root.Status != plandb.StatusFailed || root.Error != "codeaf closed while fake was running" { + t.Fatalf("the reopened conversation left the dead run's task %s (%q)", root.Status, root.Error) + } + if !root.CompletedAt.Equal(lastSeen) { + t.Fatalf("the dead run ended at %v, want where it was last seen, %v", root.CompletedAt, lastSeen) + } + if _, err := os.Stat(filepath.Join(place, planStoreFilename+".1")); !os.IsNotExist(err) { + t.Fatalf("opening a conversation archived its store: %v", err) + } +} From e5e8d42208989d2b0305e5c3e48110a670e1575a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:52:58 -0400 Subject: [PATCH 073/195] run: 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. Go picks either: picked first, the return reached the pass that ends a failed run and wrote "context canceled" over the run's task as though its work had failed, on a run the caller's wall deliberately leaves open for a later pass. A root that came home with a context's ending is now recorded as cut, and the pass answers incomplete and leaves the store as the run left it, the same ending the wall gives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/run/cut_root_test.go | 38 +++++++++++++++++++++++++++++++++++ internal/run/run.go | 18 ++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 internal/run/cut_root_test.go diff --git a/internal/run/cut_root_test.go b/internal/run/cut_root_test.go new file mode 100644 index 000000000..4be974f6f --- /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/run.go b/internal/run/run.go index cea503f69..fbd914c8d 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -152,6 +152,10 @@ type Supervisor struct { // 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 @@ -256,6 +260,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 @@ -390,12 +395,22 @@ func (s *Supervisor) pass(ctx context.Context, rootID string) Outcome { } if s.inFlight == 0 && (s.rootFailed || s.limitHit != "") { - if 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 the next hand-off // would adopt it 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 @@ -713,6 +728,7 @@ func (s *Supervisor) absorb(ret workerReturn) { } 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]). From b820b749bca5f0094003c857eb160062e06b678b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:57:27 -0400 Subject: [PATCH 074/195] session: a program's work lands on codeaf's task branch wherever it left HEAD The landing squashed and committed on whatever branch HEAD was on, while the row, the note and the carry home all named codeaf's task branch. A program that switched branches in its copy (senior-dev ran git checkout -b four times in one run) was reported as landed on a task branch that held nothing; one on a detached HEAD landed on no branch at all; and one that checked out a person's existing branch had that branch reset to the copy's first commit, taking the person's own commits off it. Before the squash, the copy's HEAD is now pointed back at the task's branch with git symbolic-ref, which moves nothing but HEAD, so the finished tree is squashed and committed on the task's branch and codeaf never resets the branch the program moved to. The landing note names that branch, says any commit the program made there is still on it, and warns when the work was not built on the commit the copy started from, because the squash would then also undo what that commit had. The landing line counts "1 file" in the singular. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 4 + internal/manual/chat/senior-dev.md | 21 +++ internal/session/delegate_door.go | 67 ++++++++- internal/session/delegate_landing_test.go | 158 ++++++++++++++++++++++ internal/session/task_run_belt.go | 13 +- 5 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 internal/session/delegate_landing_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 99eeb1213..7d4b12530 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -112,6 +112,10 @@ nothing was merged into your checkout`. Ask the chat to merge it, or merge it yo (`git merge <branch>`), when you are ready. Nothing can conflict when the run ends, because the landing writes nothing of yours; a conflict only appears when you merge. +If the program switched branches in its copy, its work still lands on the task's own +branch, and the branch it had moved to (even one of yours) is never reset by codeaf; the +task's page names that branch. + When there is nothing to land, it says `nothing to land: the run's working copy holds no change`. A folder with no git history is the exception: the program works in it directly. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 0de85a5ce..465450e33 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -127,6 +127,27 @@ it pinned and its session database. That folder is kept out of git, so it never When a run changed nothing, there is nothing to land and the task says so. On a folder with no git history nothing is committed at all: the work is already in the folder. +## When it moved to another branch in its copy — "work on a new branch", my own branch, a detached HEAD + +Its shell can run `git checkout` in its copy, and a brief that says "work on a new +branch" makes that likely. It changes nothing about where the work lands: when the run +ends, codeaf puts the copy back on the task's own branch without touching its files, and +squashes the finished tree onto it. The branch it had moved to is never reset by codeaf, +even when that is one of your own branches, so what it left there stays. + +The task's page says so beside the landing, in these words after the program's name: +`had moved its copy to the branch <branch>; its work was committed on <task branch>, and +any commit it made on <branch> is still on that branch`, or `had left its copy on no +branch; its work was committed on <task branch>`. + +When the work was not built on where the copy started (it cut its own branch from +somewhere else), the squash also undoes whatever the copy's starting point had and its +work did not, and the page adds `its work was not built on the commit its copy started +from, so the commit on <task branch> may also undo changes that commit had; read its diff +before you merge it`. + +So a brief need not ask for a branch: codeaf already gives the work one. + ## 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 diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index cf2b98e07..b0c43d6fc 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -280,9 +280,11 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { return RunLanding{Home: mergeInPlace} } dir := run.workspace + // THE SQUASH LANDS ON CODEAF'S BRANCH, WHEREVER THE PROGRAM LEFT HEAD. + moved := delegateHeadHome(dir, run.tree.branch, run.startSha, m.Name) if run.startSha != "" { - head, err := git(dir, "rev-parse", "HEAD") - if err == nil && strings.TrimSpace(head) != run.startSha { + head, err := git(dir, "rev-parse", "--verify", "-q", "HEAD") + if err != nil || strings.TrimSpace(head) != run.startSha { if out, err := git(dir, "reset", "--soft", run.startSha); err != nil { if g := a.graph(); g != nil { g.planNote(m.Name + "'s commits could not be squashed: " + firstLine(out)) @@ -306,7 +308,10 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { } note := landing.Refused if note == "" { - note = fmt.Sprintf("landed on %s: %d files", landing.Branch, len(landing.Changed)) + note = fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) + } + if moved != "" { + note += " · " + moved } if _, err := run.store.AddNote(run.root, run.root, note); err != nil { if g := a.graph(); g != nil { @@ -315,3 +320,59 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { } return a.bringBeltRunHome(run, landing) } + +// delegateHeadHome puts a tree program's copy back on the task's own branch +// before its work is squashed, and answers the sentence the landing note adds +// when it had to: which branch the program had moved the copy to, and whether +// its work stood on the commit the copy started from. It answers "" for the +// ordinary run, whose HEAD never left the task's branch. +// +// A PROGRAM'S SHELL CAN MOVE HEAD, AND ONE DID. A brief said "work on a new +// branch", and senior-dev ran `git checkout -b` four times in one run. The +// landing squashed and committed on whatever branch HEAD was on, while the row, +// the note and the carry home all named codeaf's task branch, which held +// nothing: the person was told their work was on a branch that was empty. And +// where the program had checked out one of the PERSON'S OWN branches, the +// squash's `reset --soft` moved that branch back to the copy's first commit, +// taking the person's own commits off it. +// +// `git symbolic-ref` moves HEAD alone: the index and the files stay exactly as +// the program left them, so the squash and the commit that follow land its +// finished tree on the task's branch, and the branch the program moved to is +// never reset by codeaf. Any commit the program made there stays on that +// branch, which the note says. +// +// A PROGRAM WHOSE WORK DID NOT STAND ON THE COPY'S FIRST COMMIT is said out +// loud too. The squash commits the program's finished tree over that commit, +// so work the program built on some other commit (a branch cut from `main`, +// say) also undoes whatever the copy's first commit had and that one did not, +// and the diff is the only place that would show. +func delegateHeadHome(dir, branch, startSha, name string) string { + branch = strings.TrimSpace(branch) + if branch == "" { + return "" + } + current := currentBranch(dir) + if current == branch { + return "" + } + head, _ := git(dir, "rev-parse", "--verify", "-q", "HEAD") + head = strings.TrimSpace(head) + if _, err := git(dir, "symbolic-ref", "HEAD", "refs/heads/"+branch); err != nil { + return "" + } + var said string + if current == "" { + said = name + " had left its copy on no branch; its work was committed on " + branch + } else { + said = name + " had moved its copy to the branch " + current + "; its work was committed on " + branch + + ", and any commit it made on " + current + " is still on that branch" + } + if startSha != "" && head != "" { + if _, err := git(dir, "merge-base", "--is-ancestor", startSha, head); err != nil { + said += " · its work was not built on the commit its copy started from, so the commit on " + branch + + " may also undo changes that commit had; read its diff before you merge it" + } + } + return said +} diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go new file mode 100644 index 000000000..848156cd8 --- /dev/null +++ b/internal/session/delegate_landing_test.go @@ -0,0 +1,158 @@ +package session + +// WHERE A PROGRAM'S WORK LANDS, WHATEVER IT DID WITH HEAD. +// +// A program's shell can switch branches in its copy, and senior-dev did, four +// times in one run. The landing squashed onto whatever branch HEAD was on while +// the row and the note named codeaf's task branch, which then held nothing; and +// where the program checked out one of the person's own branches, the squash +// reset that branch and took the person's commits off it. These pin the work to +// the task's branch and the person's branches to their own history. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// delegatedRunThatDid runs one program whose work is play, in a repository +// newTestRepo makes (prepare may give it branches first), and answers the +// repository, its first commit, the run's row and the notes on its page. +func delegatedRunThatDid(t *testing.T, prepare func(repo string), play func(t *testing.T, workspace string)) (string, string, TaskNotice, []string) { + t.Helper() + double := newBeltRunDouble("done") + double.work = func(workspace string) { play(t, workspace) } + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + if prepare != nil { + prepare(conversation) + } + base := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "HEAD")) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + var row TaskNotice + for _, kept := range agent.graph().runRows(id) { + if kept.ID == id { + row = kept + } + } + return conversation, base, row, beltRunNotes(t, filepath.Dir(spec.Store.Path()), spec.Store.RootID()) +} + +// commitIn writes each file and commits it the way senior-dev commits an edit. +func commitIn(t *testing.T, workspace string, names ...string) { + t.Helper() + for _, name := range names { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit(t, workspace, "add", name) + mustGit(t, workspace, "-c", "user.name=p", "-c", "user.email=p@p", "commit", "-q", "-m", "wip(edit): "+name) + } +} + +// taskBranchHolds asserts the task's one branch holds exactly one `task:` +// commit above base carrying every named file, and answers the branch. +func taskBranchHolds(t *testing.T, repo, base string, names ...string) string { + t.Helper() + branches := strings.Fields(gitOut(t, repo, "branch", "--format=%(refname:short)", "--list", "task/*")) + if len(branches) != 1 { + t.Fatalf("want the task's one branch in the repository, got %q", branches) + } + commits := strings.Fields(gitOut(t, repo, "rev-list", base+".."+branches[0])) + subject := strings.TrimSpace(gitOut(t, repo, "log", "-1", "--format=%s", branches[0])) + if len(commits) != 1 || !strings.HasPrefix(subject, "task: ") { + t.Fatalf("the task's branch holds %d commits above the base (last %q), want one `task:` commit", len(commits), subject) + } + files := gitOut(t, repo, "ls-tree", "--name-only", branches[0]) + for _, name := range names { + if !strings.Contains(files, name) { + t.Fatalf("the task's branch does not hold %s:\n%s", name, files) + } + } + return branches[0] +} + +// A PROGRAM THAT SWITCHED TO A BRANCH OF ITS OWN still lands on the task's +// branch, and the note names the branch it had moved to. +func TestADelegatedRunThatSwitchedBranchLandsOnTheTaskBranch(t *testing.T) { + repo, base, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + mustGit(t, workspace, "checkout", "-q", "-b", "senior-own") + commitIn(t, workspace, "one.txt", "two.txt") + }) + branch := taskBranchHolds(t, repo, base, "one.txt", "two.txt") + if row.Branch != branch || row.Merge != mergeKept { + t.Fatalf("the row names branch %q (%s), want the task's %q, kept", row.Branch, row.Merge, branch) + } + joined := strings.Join(notes, "\n") + if !strings.Contains(joined, "landed on "+branch+": 2 files · fake had moved its copy to the branch senior-own; its work was committed on "+branch) { + t.Fatalf("the page's notes do not say where the program had moved: %q", notes) + } + if strings.Contains(joined, "may also undo") { + t.Fatalf("work built on the copy's first commit was warned about: %q", notes) + } +} + +// A PROGRAM THAT LEFT HEAD ON NO BRANCH still lands on the task's branch. +func TestADelegatedRunOnADetachedHeadLandsOnTheTaskBranch(t *testing.T) { + repo, base, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + mustGit(t, workspace, "checkout", "-q", "--detach") + commitIn(t, workspace, "one.txt") + }) + branch := taskBranchHolds(t, repo, base, "one.txt") + if row.Branch != branch { + t.Fatalf("the row names %q, want the task's branch %q", row.Branch, branch) + } + if !strings.Contains(strings.Join(notes, "\n"), "fake had left its copy on no branch; its work was committed on "+branch) { + t.Fatalf("the page's notes do not say HEAD was on no branch: %q", notes) + } +} + +// A PROGRAM THAT CHECKED OUT THE PERSON'S OWN BRANCH never has codeaf rewrite +// it: the person's commit is still on their branch afterwards, and the work +// lands on the task's branch. +func TestADelegatedRunThatCheckedOutThePersonsBranchLeavesItsHistory(t *testing.T) { + repo, base, _, notes := delegatedRunThatDid(t, func(repo string) { + mustGit(t, repo, "checkout", "-q", "-b", "persons-feature") + commitIn(t, repo, "mine.txt") + mustGit(t, repo, "-c", "user.name=p", "-c", "user.email=p@p", "commit", "-q", "--amend", "-m", "the person's own commit") + mustGit(t, repo, "checkout", "-q", "-") + }, func(t *testing.T, workspace string) { + mustGit(t, workspace, "checkout", "-q", "persons-feature") + commitIn(t, workspace, "one.txt") + }) + if log := gitOut(t, repo, "log", "--format=%s", "persons-feature"); !strings.Contains(log, "the person's own commit") { + t.Fatalf("codeaf's landing took the person's own commit off their branch:\n%s", log) + } + branch := taskBranchHolds(t, repo, base, "one.txt") + if !strings.Contains(strings.Join(notes, "\n"), "fake had moved its copy to the branch persons-feature; its work was committed on "+branch) { + t.Fatalf("the page's notes do not name the person's branch the program moved to: %q", notes) + } +} + +// WORK NOT BUILT ON THE COPY'S FIRST COMMIT is warned about, because its squash +// may also undo what that commit had. +func TestADelegatedRunBuiltOnAnotherCommitIsWarnedAbout(t *testing.T) { + _, _, _, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + mustGit(t, workspace, "checkout", "-q", "--orphan", "fresh") + commitIn(t, workspace, "one.txt") + }) + if !strings.Contains(strings.Join(notes, "\n"), "its work was not built on the commit its copy started from") { + t.Fatalf("work built on another commit landed without a word: %q", notes) + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index acff4d709..06eca4397 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1312,11 +1312,16 @@ func beltLandingLine(landing RunLanding) string { if landing.Branch == "" { return "" } - files := "files" - if len(landing.Changed) == 1 { - files = "file" + return fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) +} + +// fileCount is a count of files in words, `1 file` and `2 files`, so every +// landing line that counts them counts them the same way. +func fileCount(n int) string { + if n == 1 { + return "1 file" } - return fmt.Sprintf("landed on %s: %d %s", landing.Branch, len(landing.Changed), files) + return strconv.Itoa(n) + " files" } func (a *Agent) missingRunDependencies(ids []uint64) []uint64 { From 0ee788f8a504ec1423d946b0a120aa3fed7d8b1c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:58:23 -0400 Subject: [PATCH 075/195] session: a program run that changed nothing leaves no branch behind A program's work lands as its branch, and the branch was kept whatever it held: every look-only, failed or crashed program run left one more task/* branch at the commit it started from in the person's repository. A kept task branch whose tip is still the commit its copy started from is now deleted, and the run says what it always said about a copy that holds no change. A branch holding even one commit of the program's is never touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 2 +- internal/manual/chat/senior-dev.md | 3 ++- internal/session/delegate_landing_test.go | 16 +++++++++++++ internal/session/task_run_belt.go | 29 +++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 7d4b12530..4485a9561 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -117,7 +117,7 @@ branch, and the branch it had moved to (even one of yours) is never reset by cod task's page names that branch. When there is nothing to land, it says `nothing to land: the run's working copy holds no -change`. A folder with no git history is the exception: the program works in it directly. +change`, and the task's branch, which would hold nothing, is deleted. A folder with no git history is the exception: the program works in it directly. 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. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 465450e33..9483baf86 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -124,7 +124,8 @@ project's build and tests (`senior-dev observed: …`). Read the second for "did Its own notes live in `.senior-dev/` in the copy: the brief, its checklist, the command it pinned and its session database. That folder is kept out of git, so it never lands. -When a run changed nothing, there is nothing to land and the task says so. On a folder +When a run changed nothing, there is nothing to land and the task says so; its branch, +which would hold nothing, is deleted rather than left in your repository. On a folder with no git history nothing is committed at all: the work is already in the folder. ## When it moved to another branch in its copy — "work on a new branch", my own branch, a detached HEAD diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index 848156cd8..40e255c25 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -156,3 +156,19 @@ func TestADelegatedRunBuiltOnAnotherCommitIsWarnedAbout(t *testing.T) { t.Fatalf("work built on another commit landed without a word: %q", notes) } } + +// A PROGRAM RUN THAT CHANGED NOTHING LEAVES NO BRANCH. There is nothing on an +// empty branch to merge, and every look-only, failed or crashed run used to +// leave one more `task/*` in the person's repository. +func TestADelegatedRunThatChangedNothingLeavesNoBranch(t *testing.T) { + repo, _, row, notes := delegatedRunThatDid(t, nil, func(*testing.T, string) {}) + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a run that changed nothing left a branch behind: %q", branches) + } + if row.Branch != "" { + t.Fatalf("the row names a branch %q over no work", row.Branch) + } + if !strings.Contains(strings.Join(notes, "\n"), "nothing to land: the run's working copy holds no change") { + t.Fatalf("the page does not say there was nothing to land: %q", notes) + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 06eca4397..08ff9943e 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1013,6 +1013,17 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { return landing } merge, said, _, _ := run.tree.comeHome(run.title, nil, a.signsGitWork()) + if merge == mergeKept && run.tree.keepsBranch && dropEmptyTaskBranch(run.tree, run.startSha) { + // AN EMPTY BRANCH IS NOT A LANDING. The branch was kept for the person + // to merge, and there is nothing on it to merge: every look-only, + // failed or crashed program run left one more `task/*` branch at the + // commit it started from in the person's repository. It is deleted, and + // the run says what it always said about a copy that holds no change. + if landing.Refused == "" { + landing.Refused = runNothingToLand + } + return landing + } if landing.Refused != "" { // NOTHING TO LAND IS STILL AN ENDING: the copy was given back above, and // the sentence the engine answered is the whole account. @@ -1056,6 +1067,24 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { return landing } +// dropEmptyTaskBranch deletes a kept task branch that holds nothing past the +// commit its copy started from, and reports whether it did. Only a branch +// whose tip IS that commit goes, so a branch holding even one commit of the +// program's is never touched; the repository's lock is taken the way every +// landing's branch work takes it. +func dropEmptyTaskBranch(tree taskTree, startSha string) bool { + if strings.TrimSpace(tree.root) == "" || strings.TrimSpace(tree.branch) == "" || startSha == "" { + return false + } + defer lockGitRoot(tree.place, tree.root)() + tip, err := git(tree.root, "rev-parse", "--verify", "-q", "refs/heads/"+tree.branch) + if err != nil || strings.TrimSpace(tip) != startSha { + return false + } + _, err = git(tree.root, "branch", "-D", tree.branch) + return err == nil +} + // deliverBeltRunLanding writes the run's digest into the conversation record. // A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED. func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { From f565eee6889db0b4e7e98aedfc6129985ca71a68 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:01:08 -0400 Subject: [PATCH 076/195] session: the chat is told a program's work was not merged, where it is, and how to bring it in A program's work is left on the task's own branch and merged by nobody, but the one line the conversation's model is given about a landing read "landed on task/x: 2 files", the shape of a run whose work is already in the person's folder; the receipt said the program "lands when it ends" and the hand-off rule said "only that copy lands". The model had no way to know that nothing was merged, which repository held the branch or what brings it in. A branch-only landing's line now says the branch, the repository folder, the number of files, that nothing was merged, and the exact command, git -C '<folder>' merge <branch>. The receipt says where the work will be (on the task's own branch, in the folder itself for a folder with no history, or in the conversation for a program that answers), and the rule says only the copy's work is kept, on a branch nothing merges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 13 +++-- internal/manual/chat/senior-dev.md | 12 ++-- internal/session/delegate_door.go | 34 ++++++++++-- internal/session/delegate_door_test.go | 8 +-- internal/session/delegate_landing_test.go | 67 +++++++++++++++++++++++ internal/session/task.go | 2 +- internal/session/task_run_belt.go | 18 +++++- 7 files changed, 135 insertions(+), 19 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 4485a9561..9a5782395 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -48,8 +48,9 @@ calls, the dollars and how long the program ran. ## Which folder a program works in — a repository I have not cloned, it edited files outside its copy, a folder with no git A program that edits code works in a copy of one folder: the one this conversation works -in, or the one the task names. **Only what it changes inside that copy lands.** Anything it -changed anywhere else is not part of the task, and the task's ending does not see it. +in, or the one the task names. **Only what it changes inside that copy is kept**, on the +task's own branch. Anything it changed anywhere else is not part of the task, and the +task's ending does not see it. **The brief it reads names its copy.** Wherever the brief names the task's folder, codeaf rewrites that path to the copy's before the program reads it, so it is never pointed at @@ -108,9 +109,11 @@ title, and its body is the program's own account of the ending. **That commit stays on the task's own branch** (`task/<title>-<id>`) in your repository, and **codeaf does not merge it into your checkout**. Your files and your branch are exactly as you left them. The task's page says `its work is on the branch <branch> in <folder>; -nothing was merged into your checkout`. Ask the chat to merge it, or merge it yourself -(`git merge <branch>`), when you are ready. Nothing can conflict when the run ends, -because the landing writes nothing of yours; a conflict only appears when you merge. +nothing was merged into your checkout`, and the conversation is told the same with the +number of files and the command that brings it in, `git -C '<folder>' merge <branch>`. +Ask the chat to merge it, or run that yourself, when you are ready. Nothing can conflict +when the run ends, because the landing writes nothing of yours; a conflict only appears +when you merge. If the program switched branches in its copy, its work still lands on the task's own branch, and the branch it had moved to (even one of yours) is never reset by codeaf; the diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 9483baf86..d0c3a1ef4 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -39,7 +39,8 @@ 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 a copy of the folder it is handed, and only what it changes in that -copy lands. So it has to be handed the repository the work belongs in. +copy is kept, on the task's own branch. 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, onto a branch at that commit, and hands @@ -113,9 +114,12 @@ whose subject is `task:` and the task's title, and whose body is senior-dev's ow **That commit is left on the task's own branch in your repository, and nothing is merged into your checkout.** The task's page says `its work is on the branch <branch> in -<folder>; nothing was merged into your checkout`. Merge it when you are ready, or ask the -chat to. A run can take an hour, and a merge at its end used to meet whatever changed in -your checkout meanwhile; now nothing can clash until you choose to merge. +<folder>; nothing was merged into your checkout`, and the conversation is told +``its work is on the branch <branch> in <folder>, N files; nothing was merged into your +checkout, and `git -C '<folder>' merge <branch>` brings it in``. Merge it when you are +ready, or ask the chat to. A run can take an hour, and a merge at its end used to meet +whatever changed in your checkout meanwhile; now nothing can clash until you choose to +merge. The ending keeps two witnesses apart: what senior-dev's model said it did when it submitted (`senior-dev's model said: …`) and what senior-dev itself saw when it ran the diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index b0c43d6fc..f5c8831bf 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -119,10 +119,15 @@ var delegateFact = beltFact{ // The copy is cut from the folder the proposal names, so the folder is the one // thing the model has to get right, and fetching a repository that is not here // is its job, done before the proposal. -const delegateFolderRule = "\nIt works in a copy of the task's folder and only that copy lands, so hand it the\n" + - "repository the work belongs in: clone one this machine lacks into a new folder, on a\n" + - "branch at the commit the work names, and pass it as `ground`. Never brief it to work\n" + - "elsewhere." +// +// AND IT PROMISES NO MERGE, because there is none. It said "only that copy +// lands", and a model told its work lands tells the person the work is in their +// folder; a program's work is left on the task's own branch +// ([delegateKeepsBranch]), and bringing it in is a separate step. +const delegateFolderRule = "\nIt works in a copy of the task's folder, and only that copy's work is kept, on a branch\n" + + "nothing merges, so hand it the repository the work belongs in: clone one this machine\n" + + "lacks into a new folder, on a branch at the commit the work names, and pass it as\n" + + "`ground`. Never brief it to work elsewhere." // carriesTreeProgram says whether any program this conversation can hand work // to edits files, which is when [delegateFolderRule] is true of it. @@ -169,6 +174,27 @@ func branchOnlySentence(branch, root string) string { return "its work is on the branch " + branch + " in " + root + "; nothing was merged into your checkout" } +// delegateReceipt is the sentence an approved hand-off to a program adds to +// its receipt: who has the work and where it will be when it ends. It is read +// off the run just started under row, which knows whether its folder had a +// history to copy. +// +// IT NEVER SAYS THE WORK LANDS. It said "lands when it ends", and a program's +// work is left on the task's own branch and merged by nobody; a model that read +// "lands" told the person their folder held work it did not. +func (a *Agent) delegateReceipt(row uint64, via delegate.Delegate) string { + if !via.LandsTree() { + return "It is " + via.Name + "'s: it works alone, and its answer arrives when it ends." + } + a.beltMu.Lock() + plain := a.beltRun != nil && a.beltRun.row == row && a.beltRun.plain + a.beltMu.Unlock() + if plain { + return "It is " + via.Name + "'s: it works alone in the folder itself, which has no git history, so its changes are there as it makes them." + } + return "It is " + via.Name + "'s: it works alone in a copy, and when it ends its work is left on the task's own branch; nothing is merged into the checkout." +} + // DelegateUnknownError is the refusal for a `via` or a command naming no // program this build carries. It names the ones it does, sorted, so the next // attempt has the words in front of it. diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 596fe4ed4..08a5c92e7 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -380,10 +380,10 @@ func TestTheFolderRuleIsSaidWhereAProgramEditsFilesAndOnlyThere(t *testing.T) { tree := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} page := promptWithBeltFacts(tree) for _, want := range []string{ - "It works in a copy of the task's folder and only that copy lands", - "clone one this machine lacks into a new folder", - "branch at the commit the work names, and pass it as `ground`.", - "Never brief it to work\nelsewhere.", + "It works in a copy of the task's folder, and only that copy's work is kept, on a branch\nnothing merges", + "clone one this machine\nlacks into a new folder", + "branch at the commit the work names, and pass it as\n`ground`.", + "Never brief it to work elsewhere.", } { if !strings.Contains(page, want) { t.Fatalf("a build carrying a program that edits files is not told %q:\n%s", want, page) diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index 40e255c25..fbc0aa5a8 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -15,6 +15,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" ) // delegatedRunThatDid runs one program whose work is play, in a repository @@ -172,3 +174,68 @@ func TestADelegatedRunThatChangedNothingLeavesNoBranch(t *testing.T) { t.Fatalf("the page does not say there was nothing to land: %q", notes) } } + +// THE CONVERSATION IS TOLD NOTHING WAS MERGED, WHERE THE BRANCH IS, AND HOW TO +// BRING IT IN. The line a landing delivers is the one account the chat's model +// gets, and it read like a merged run's: `landed on task/x: 2 files`. +func TestTheConversationIsToldABranchOnlyLandingWasNotMerged(t *testing.T) { + double := newBeltRunDouble("done") + double.work = func(workspace string) { commitIn(t, workspace, "one.txt", "two.txt") } + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "add two files"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + endBeltRun(t, agent, double) + branches := strings.Fields(gitOut(t, conversation, "branch", "--format=%(refname:short)", "--list", "task/*")) + if len(branches) != 1 { + t.Fatalf("want the task's one branch, got %q", branches) + } + root := canonicalPath(conversation) + want := "its work is on the branch " + branches[0] + " in " + root + ", 2 files; nothing was merged into your checkout, and `git -C '" + + root + "' merge " + branches[0] + "` brings it in" + if got := conversationJournalLines(agent, want); got != 1 { + t.Fatalf("the conversation was told %d times %q", got, want) + } + if got := conversationJournalLines(agent, "landed on "+branches[0]); got != 0 { + t.Fatal("the conversation was told the work landed, the shape of a merged run") + } +} + +// THE RECEIPT PROMISES NO MERGE. An approved hand-off to a program says who has +// the work and where it will be: on the task's own branch for a copy, in the +// folder itself for a folder with no history, in the conversation for one that +// only answers. +func TestAProgramsReceiptSaysWhereTheWorkWillBeAndPromisesNoMerge(t *testing.T) { + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, nil) + tree := testPrograms("fake")[0] + if got := agent.delegateReceipt(4, tree); got != "It is fake's: it works alone in a copy, and when it ends its work is left on the task's own branch; nothing is merged into the checkout." { + t.Fatalf("the receipt for a copy = %q", got) + } + agent.beltMu.Lock() + agent.beltRun = &beltRun{row: 4, plain: true} + agent.beltMu.Unlock() + if got := agent.delegateReceipt(4, tree); !strings.Contains(got, "in the folder itself, which has no git history") { + t.Fatalf("the receipt for a plain folder = %q", got) + } + agent.beltMu.Lock() + agent.beltRun = nil + agent.beltMu.Unlock() + reader := tree + reader.Lands = delegate.LandsText + if got := agent.delegateReceipt(4, reader); got != "It is fake's: it works alone, and its answer arrives when it ends." { + t.Fatalf("the receipt for a program that answers = %q", got) + } + for _, got := range []string{agent.delegateReceipt(4, tree), agent.delegateReceipt(4, reader)} { + if strings.Contains(got, "lands") { + t.Fatalf("a receipt promises a landing: %q", got) + } + } +} diff --git a/internal/session/task.go b/internal/session/task.go index 482e6e912..d585c2ba5 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -890,7 +890,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) switch { case via != nil: - receipt = withReport(receipt, "It is "+via.Name+"'s: the program works alone in the copy and lands when it ends.") + receipt = withReport(receipt, a.delegateReceipt(p.id, *via)) case joined: receipt = withReport(receipt, "It joined the work already underway and shares its copy.") } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 08ff9943e..a1911ee87 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -215,6 +215,10 @@ type RunLanding struct { // brought back to its ground ([Agent.landBeltRun]). Empty is an engine's own // landing, which commits on the copy's branch and merges nothing. Home string + // Root is the repository a branch-only landing left its branch in + // ([delegateKeepsBranch]), set only for that landing: the folder the merge + // that brings the work in runs in ([beltLandingLine]). + Root string } // RunEngine is the run engine as this door reaches it. Start drives one store @@ -1032,7 +1036,7 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { if merge == mergeKept && run.tree.keepsBranch { // A BRANCH-ONLY LANDING IS A LANDING, not a refusal: the work is on its // branch in the person's repository, which is where it was promised. - landing.Home = merge + landing.Home, landing.Root = merge, run.tree.root if run.tree.branch != "" { landing.Branch = run.tree.branch } @@ -1334,6 +1338,14 @@ func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, // beltLandingLine is what a landing is in one line: where the work went and how // much of it, or the refusal that says why it did not. It is empty only when // there is nothing to say — a landing with no branch and no refusal. +// +// A BRANCH-ONLY LANDING SAYS NOTHING WAS MERGED, WHERE, AND HOW TO BRING IT IN. +// This line is the one account of a landing the conversation's model is given, +// and it read `landed on task/x: 2 files`, the shape of a run whose work is +// already in the person's folder: the model had no way to know that nothing was +// merged, which repository held the branch, or what brings it in, and would tell +// the person their folder held the work. The folder is quoted for a shell the +// way every path this package hands one is ([shellQuoted]). func beltLandingLine(landing RunLanding) string { if landing.Refused != "" { return landing.Refused @@ -1341,6 +1353,10 @@ func beltLandingLine(landing RunLanding) string { if landing.Branch == "" { return "" } + if landing.Home == mergeKept && landing.Root != "" { + return fmt.Sprintf("its work is on the branch %s in %s, %s; nothing was merged into your checkout, and `git -C %s merge %s` brings it in", + landing.Branch, landing.Root, fileCount(len(landing.Changed)), shellQuoted(landing.Root), landing.Branch) + } return fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) } From 905dcb01981db2f385c0891c03e827f2424a0af0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:04:49 -0400 Subject: [PATCH 077/195] delegate: a brief names the same place in the copy, once The brief a program reads is rewritten to name its copy, and two things were wrong with it. The copy is cut at the repository's root, but a task proposed on a subfolder had that subfolder mapped to the copy's root, which sent every path under it to a file that does not exist, while the repository's own spelling was left as it was, so git -C <the person's checkout> reached the program intact. And the rewrite ran one spelling at a time over its own output: a copy that lives inside the folder (a repository at the home folder, copies under ~/.codeaf) was expanded again on every pass. Each spelling of the proposed folder now maps to the same subfolder in the copy, and each spelling of the repository around it (its own, and the folder's spellings with the subfolder taken off) maps to the copy's root. The spellings are deduplicated and the brief is read once, left to right, longest match first, never rereading what it wrote; a path the brief already spells inside the copy is left as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delegate/rehome.go | 114 ++++++++++++++-------- internal/delegate/rehome_test.go | 58 ++++++++++- internal/manual/chat/delegates.md | 3 +- internal/manual/chat/senior-dev.md | 4 +- internal/run/delegateworker.go | 11 ++- internal/run/delegateworker_test.go | 2 +- internal/session/delegate_door_test.go | 2 +- internal/session/delegate_landing_test.go | 41 ++++++++ internal/session/task_run_belt.go | 80 +++++++++++---- 9 files changed, 248 insertions(+), 67 deletions(-) diff --git a/internal/delegate/rehome.go b/internal/delegate/rehome.go index 164aa0266..7b61bed08 100644 --- a/internal/delegate/rehome.go +++ b/internal/delegate/rehome.go @@ -5,9 +5,18 @@ import ( "strings" ) -// RehomeBrief rewrites every mention of the folder a task was proposed on into -// the working copy the program was handed, so the brief a program reads names -// only the folder it works in. +// Rehome is one spelling of a folder a brief may name, and the folder in the +// program's working copy it stands for: the folder the task was proposed on +// maps to where that folder is inside the copy, and the repository around it +// maps to the copy's root. +type Rehome struct { + From string + To string +} + +// RehomeBrief rewrites every mention of the folders a task was proposed on +// into the working copy the program was handed, so the brief a program reads +// names only the folder it works in. // // IT EXISTS BECAUSE A PROGRAM DID WHAT ITS BRIEF SAID. A conversation briefed // senior-dev on "the checkout at /Users/…/happy-dom-task", which was the task's @@ -19,51 +28,80 @@ import ( // say so: the one fact the program needs to find its work is where it stands, // and a path it cannot use is a path it is better never told. // -// from is every spelling of the folder (as proposed, resolved, under ~); to is -// the copy. A mention is replaced only where it is the whole path or a path -// inside it — `/a/b` is rewritten in `/a/b` and `/a/b/src`, never in `/a/bc` -// or `/x/a/b` — so a sibling folder or a longer path is left as it was. -func RehomeBrief(brief string, from []string, to string) string { - to = strings.TrimRight(strings.TrimSpace(to), "/") - if to == "" || brief == "" { +// A mention is replaced only where it is the whole path or a path inside it — +// `/a/b` is rewritten in `/a/b` and `/a/b/src`, never in `/a/bc` or `/x/a/b` — +// so a sibling folder or a longer path is left as it was. Where several +// spellings match at one place the longest wins, so a subfolder's spelling is +// read whole before the repository around it can be. +// +// THE BRIEF IS READ ONCE, LEFT TO RIGHT, AND WHAT IS WRITTEN IS NEVER READ +// AGAIN. It was rewritten one spelling at a time, each pass over the text the +// last had produced; a copy that lives INSIDE the folder (a repository at the +// home folder, whose copies sit under ~/.codeaf) starts with that folder's own +// spelling, so every pass found its own output again and a brief came back +// naming `…/trees/3/.codeaf/…/trees/3/.codeaf/…`. A path the brief already +// spells inside the copy is copied through as it stands for the same reason. +func RehomeBrief(brief string, moves []Rehome) string { + if brief == "" { return brief } - spellings := make([]string, 0, len(from)) - for _, spelling := range from { - spelling = strings.TrimRight(strings.TrimSpace(spelling), "/") - if spelling != "" && spelling != to && strings.ContainsRune(spelling, '/') { - spellings = append(spellings, spelling) + type spelling struct { + text string + to string + keep bool + } + seen := map[string]bool{} + var spellings []spelling + add := func(text, to string, keep bool) { + text = strings.TrimRight(strings.TrimSpace(text), "/") + if text == "" || !strings.ContainsRune(text, '/') || seen[text] { + return + } + seen[text] = true + spellings = append(spellings, spelling{text: text, to: to, keep: keep}) + } + // THE COPY'S OWN PATHS FIRST, so a spelling of the folder that is also the + // start of a path already in the copy never claims it. + for _, move := range moves { + to := strings.TrimRight(strings.TrimSpace(move.To), "/") + if to != "" { + add(to, to, true) } } - // THE LONGEST SPELLING FIRST, so a resolved path that contains a shorter - // one is rewritten whole rather than half by the shorter. - sort.Slice(spellings, func(i, j int) bool { return len(spellings[i]) > len(spellings[j]) }) - for _, spelling := range spellings { - brief = rehomeOne(brief, spelling, to) + for _, move := range moves { + to := strings.TrimRight(strings.TrimSpace(move.To), "/") + if to != "" { + add(move.From, to, false) + } } - return brief -} + if len(spellings) == 0 { + return brief + } + sort.SliceStable(spellings, func(i, j int) bool { return len(spellings[i].text) > len(spellings[j].text) }) -// rehomeOne rewrites one spelling wherever it stands as a whole path. -func rehomeOne(brief, from, to string) string { var out strings.Builder - rest := brief - for { - at := strings.Index(rest, from) - if at < 0 { - out.WriteString(rest) - return out.String() + for at := 0; at < len(brief); { + matched := false + if at == 0 || !pathByte(brief[at-1]) { + for _, one := range spellings { + if strings.HasPrefix(brief[at:], one.text) && endsName(brief[at+len(one.text):]) { + if one.keep { + out.WriteString(one.text) + } else { + out.WriteString(one.to) + } + at += len(one.text) + matched = true + break + } + } } - end := at + len(from) - whole := (at == 0 || !pathByte(rest[at-1])) && endsName(rest[end:]) - out.WriteString(rest[:at]) - if whole { - out.WriteString(to) - } else { - out.WriteString(from) + if !matched { + out.WriteByte(brief[at]) + at++ } - rest = rest[end:] } + return out.String() } // endsName says the text after a match does not continue its last name: it is diff --git a/internal/delegate/rehome_test.go b/internal/delegate/rehome_test.go index a42da96d3..90b41d727 100644 --- a/internal/delegate/rehome_test.go +++ b/internal/delegate/rehome_test.go @@ -2,12 +2,22 @@ package delegate import "testing" +// moveAll maps every spelling to one folder, the shape a task proposed on a +// repository's own root takes. +func moveAll(to string, from ...string) []Rehome { + moves := make([]Rehome, 0, len(from)) + for _, spelling := range from { + moves = append(moves, Rehome{From: spelling, To: to}) + } + return moves +} + // The brief a program reads names the copy it works in wherever it named the // folder the task was proposed on, in every spelling, and leaves every other // path alone. func TestRehomeBriefNamesTheCopyWhereverTheFolderWasNamed(t *testing.T) { - from := []string{"~/Code/app", "/Users/p/Code/app", "/private/Users/p/Code/app/"} const to = "/Users/p/.codeaf/trees/3" + moves := moveAll(to, "~/Code/app", "/Users/p/Code/app", "/private/Users/p/Code/app/") for _, c := range []struct{ in, want string }{ {"work in the checkout at /Users/p/Code/app (a git repo).", "work in the checkout at /Users/p/.codeaf/trees/3 (a git repo)."}, {"cd /Users/p/Code/app/packages/x && npm test", "cd /Users/p/.codeaf/trees/3/packages/x && npm test"}, @@ -20,11 +30,53 @@ func TestRehomeBriefNamesTheCopyWhereverTheFolderWasNamed(t *testing.T) { {"/mnt/Users/p/Code/app", "/mnt/Users/p/Code/app"}, {"nothing to rewrite", "nothing to rewrite"}, } { - if got := RehomeBrief(c.in, from, to); got != c.want { + if got := RehomeBrief(c.in, moves); got != c.want { t.Errorf("RehomeBrief(%q) = %q, want %q", c.in, got, c.want) } } - if got := RehomeBrief("at /a/b", []string{"/a/b"}, "/a/b"); got != "at /a/b" { + if got := RehomeBrief("at /a/b", moveAll("/a/b", "/a/b")); got != "at /a/b" { t.Errorf("a copy that is the folder itself rewrote the brief: %q", got) } } + +// A TASK PROPOSED ON A SUBFOLDER names that subfolder inside the copy, and the +// repository around it names the copy's root. The copy is cut at the +// repository's root, so a subfolder mapped to the copy's root sent every path +// in the brief to a file that does not exist, and the repository's own +// spelling was left pointing at the person's checkout. +func TestRehomeBriefMapsASubfolderIntoTheCopyAndTheRepositoryToItsRoot(t *testing.T) { + moves := []Rehome{ + {From: "/Users/p/Code/app/packages/foo", To: "/c/trees/3/packages/foo"}, + {From: "~/Code/app/packages/foo", To: "/c/trees/3/packages/foo"}, + {From: "/Users/p/Code/app", To: "/c/trees/3"}, + {From: "~/Code/app", To: "/c/trees/3"}, + } + for _, c := range []struct{ in, want string }{ + {"fix /Users/p/Code/app/packages/foo/src/a.ts, then run git -C /Users/p/Code/app status", + "fix /c/trees/3/packages/foo/src/a.ts, then run git -C /c/trees/3 status"}, + {"cd ~/Code/app/packages/foo && make", "cd /c/trees/3/packages/foo && make"}, + {"see ~/Code/app/packages/bar/x.go", "see /c/trees/3/packages/bar/x.go"}, + } { + if got := RehomeBrief(c.in, moves); got != c.want { + t.Errorf("RehomeBrief(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// A COPY INSIDE THE FOLDER IS NAMED ONCE. A repository at the home folder keeps +// its copies under ~/.codeaf, so the copy's path starts with the folder's own +// spelling; rewriting spelling by spelling found its own output again on every +// pass. The spellings are deduplicated, the text is read once, and a path the +// brief already spells inside the copy is left as it is. +func TestRehomeBriefNamesACopyInsideTheFolderOnce(t *testing.T) { + const to = "/Users/p/.codeaf/v3/projects/x/s/trees/3" + moves := moveAll(to, "/Users/p", "/Users/p", "/Users/p/") + for _, c := range []struct{ in, want string }{ + {"work in /Users/p on the dotfiles", "work in " + to + " on the dotfiles"}, + {"read " + to + "/notes and /Users/p/.zshrc", "read " + to + "/notes and " + to + "/.zshrc"}, + } { + if got := RehomeBrief(c.in, moves); got != c.want { + t.Errorf("RehomeBrief(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 9a5782395..bf603e5e8 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -54,7 +54,8 @@ task's ending does not see it. **The brief it reads names its copy.** Wherever the brief names the task's folder, codeaf rewrites that path to the copy's before the program reads it, so it is never pointed at -your checkout. +your checkout. The copy is of the whole repository: a subfolder is rewritten to the same +subfolder in the copy, and the repository around it to the copy's root. So when the work belongs in a repository that is not on this machine (a benchmark task that names a repository and a commit, or a project you have not cloned), the model clones diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index d0c3a1ef4..c5ac8f03a 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -52,7 +52,9 @@ At a shell, clone the repository yourself, then run `codeaf senior-dev` inside i the folder with `--dir`. A brief that names the folder you proposed is fine: codeaf rewrites that path to senior-dev's -copy before senior-dev reads it, so its commands run in the copy. +copy before senior-dev reads it, so its commands run in the copy. The copy is of the whole +repository, so a subfolder you proposed becomes the same subfolder in the copy, and the +repository around it becomes the copy's root. A brief that tells senior-dev to make a checkout of its own somewhere else does not work. It has no copy of that folder, so nothing it does there lands: its file tools refuse to diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index b6c8b9666..952686c1c 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -107,12 +107,13 @@ type DelegateSetup struct { // 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 - // Ground is every spelling of the folder the task was proposed on, when the - // program works in a copy of it (session.RunSpec.Ground). The brief is - // rewritten to name the copy wherever it named that folder + // Ground is every spelling of the folder the task was proposed on and of + // the repository around it, each paired with where it stands in the copy, + // when the program works in a copy (session.RunSpec.Ground). The brief is + // rewritten to name the copy wherever it named either // (delegate.RehomeBrief), so the program is never told a path it must not // work in. Empty for a program working in the folder itself. - Ground []string + Ground []delegate.Rehome // 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 @@ -404,7 +405,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err if brief == "" { brief = strings.TrimSpace(task.Title) } - brief = delegate.RehomeBrief(brief, w.setup.Ground, w.workspace) + brief = delegate.RehomeBrief(brief, w.setup.Ground) started = time.Now() sink.record.StartedAt = started result, err := delegate.Run(launchCtx, delegate.Launch{ diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go index 91d4e1dd0..6efd04626 100644 --- a/internal/run/delegateworker_test.go +++ b/internal/run/delegateworker_test.go @@ -359,7 +359,7 @@ func TestDelegateWorkerHandsTheProgramABriefThatNamesItsCopy(t *testing.T) { t.Setenv("FAKE_ARGS", args) workspace := t.TempDir() m, setup := fakeDelegate(t, passLine("done")) - setup.Ground = []string{ground} + setup.Ground = []delegate.Rehome{{From: ground, To: workspace}} worker := run.NewDelegateWorker(store, workspace, m, setup, 0, 0) if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil { t.Fatal(err) diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 08a5c92e7..30560dd0b 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -80,7 +80,7 @@ func TestADelegatedRunSquashesTheProgramsCommitsAndLandsThemAsABranch(t *testing } // The folder the task was proposed on is handed over in its spellings, so // the program's brief names its copy wherever it named the folder. - if !slices.Contains(spec.Ground, canonicalPath(conversation)) || canonicalPath(spec.Workspace) == canonicalPath(conversation) { + if !slices.Contains(spec.Ground, delegate.Rehome{From: canonicalPath(conversation), To: spec.Workspace}) || canonicalPath(spec.Workspace) == canonicalPath(conversation) { t.Fatalf("spec.Ground = %q for a copy at %q, want the proposed folder's spellings", spec.Ground, spec.Workspace) } endBeltRun(t, agent, double) diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index fbc0aa5a8..6efd0cfc6 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -239,3 +239,44 @@ func TestAProgramsReceiptSaysWhereTheWorkWillBeAndPromisesNoMerge(t *testing.T) } } } + +// A PROGRAM HANDED A SUBFOLDER'S TASK READS PATHS THAT EXIST IN ITS COPY. The +// copy is of the whole repository, so the subfolder is the same subfolder in +// it and the repository is the copy's root; mapping the subfolder to the copy's +// root sent every path to a file that is not there, and left the repository's +// own spelling pointing at the person's checkout. +func TestAProgramHandedASubfoldersTaskReadsPathsThatExistInItsCopy(t *testing.T) { + double := newBeltRunDouble("") + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + sub := filepath.Join(repo, "packages", "foo") + if err := os.MkdirAll(filepath.Join(sub, "src"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(sub, "src", "a.ts"), "export {}\n") + mustGit(t, repo, "add", "-A") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "the package") + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = sub + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + brief := "fix " + sub + "/src/a.ts, then run git -C " + repo + " status" + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", brief); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + copyRoot := spec.Workspace + want := "fix " + copyRoot + "/packages/foo/src/a.ts, then run git -C " + copyRoot + " status" + if got := delegate.RehomeBrief(spec.Brief, spec.Ground); got != want { + t.Fatalf("the program would read %q, want %q", got, want) + } + if _, err := os.Stat(filepath.Join(copyRoot, "packages", "foo", "src", "a.ts")); err != nil { + t.Fatalf("the path the program reads is not in its copy: %v", err) + } + endBeltRun(t, agent, double) +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index a1911ee87..ba7b9e8a3 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -144,10 +144,11 @@ type RunSpec struct { // (delegate.Delegate.PlainFolder). False for every other run. PlainFolder bool // Ground is every spelling of the folder a delegated run's task was - // proposed on, when the program works in a copy of it: the brief it is - // handed names the copy wherever it named the folder - // (delegate.RehomeBrief). Empty for every other run. - Ground []string + // proposed on, and of the repository around it, each paired with where it + // stands in the copy, when the program works in a copy: the brief it is + // handed names the copy wherever it named either (delegate.RehomeBrief). + // Empty for every other run. + Ground []delegate.Rehome // Crew is the conversation's crew as a delegated run's program is handed it // ([conversationCrew]), so the program works on the models the person // chose. Zero for every other run. @@ -306,10 +307,11 @@ type beltRun struct { // ([delegateOnPlainFolder]): it is told so on its line, and its landing // commits nothing, because the work is already where it belongs. plain bool - // groundNames is every spelling of the folder a tree program's task was - // proposed on, when the program works in a copy of it - // ([delegateGroundNames]); empty otherwise. - groundNames []string + // groundMoves is every spelling of the folder a tree program's task was + // proposed on and of the repository around it, each paired with where it + // stands in the copy, when the program works in a copy of it + // ([delegateGroundMoves]); empty otherwise. + groundMoves []delegate.Rehome } // startTaskRun is StartTask's second road, taken whenever the bash belt is asked @@ -415,7 +417,7 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie plain: delegateOnPlainFolder(tree, via), } if via != nil && via.LandsTree() && !run.plain { - run.groundNames = delegateGroundNames(stand.dir, tree.dir) + run.groundMoves = delegateGroundMoves(stand.dir, tree.root, tree.dir) } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because @@ -550,7 +552,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { Conversation: a.runConversation(), Delegate: run.delegate, PlainFolder: run.plain, - Ground: run.groundNames, + Ground: run.groundMoves, Crew: a.delegateCrew(run), } } @@ -577,19 +579,63 @@ func (a *Agent) delegateCrew(run *beltRun) delegate.Crew { return delegate.Crew{Brain: seat(roles.TierMastermind), Hands: seat(roles.TierWorker), Light: seat(roles.TierLow)} } -// delegateGroundNames is every way a brief is likely to spell the folder a -// tree program's task was proposed on: as the proposal named it, absolute, -// with its links resolved, and under ~. It is empty when the program works in -// that folder itself, where there is nothing to rewrite. -func delegateGroundNames(proposed, copyDir string) []string { +// delegateGroundMoves is every way a brief is likely to spell the folder a +// tree program's task was proposed on (as the proposal named it, absolute, +// with its links resolved, and under ~) and the repository it is in, each +// paired with where it stands in the program's copy. It is empty when the +// program works in that folder itself, where there is nothing to rewrite. +// +// THE COPY IS CUT AT THE REPOSITORY'S ROOT, NOT AT THE FOLDER. A task proposed +// on a subfolder of a repository (a conversation opened in one package of a +// monorepo) gets a copy of the whole repository, so the subfolder is the same +// subfolder inside the copy, and the repository's own root is the copy's root. +// Both used to be wrong: the subfolder was mapped to the copy's root, which sent +// every path under it to a file that does not exist, and the repository's +// spelling was left as it was, so `git -C <the person's checkout>` reached the +// program intact — the exact failure the rewrite exists to stop. +func delegateGroundMoves(proposed, root, copyDir string) []delegate.Rehome { proposed = strings.TrimSpace(proposed) if proposed == "" || canonicalPath(proposed) == canonicalPath(copyDir) { return nil } - names := []string{proposed, canonicalPath(proposed)} + ground := canonicalPath(proposed) + target, rel := copyDir, "" + if root = strings.TrimSpace(root); root != "" { + if within, err := filepath.Rel(canonicalPath(root), ground); err == nil && within != "." && + within != ".." && !strings.HasPrefix(within, "../") { + target, rel = filepath.Join(copyDir, within), within + } + } + groundSpellings := pathSpellings(proposed) + moves := make([]delegate.Rehome, 0, 2*len(groundSpellings)) + for _, spelling := range groundSpellings { + moves = append(moves, delegate.Rehome{From: spelling, To: target}) + } + if rel == "" { + return moves + } + // THE REPOSITORY IN EVERY SPELLING THE BRIEF COULD USE: its own, and the + // folder's spellings with the subfolder taken off, so `~/Code/app` is found + // wherever `~/Code/app/packages/foo` was how the task was named. + rootSpellings := pathSpellings(root) + for _, spelling := range groundSpellings { + if trimmed, ok := strings.CutSuffix(strings.TrimRight(spelling, "/"), "/"+filepath.ToSlash(rel)); ok && trimmed != "" { + rootSpellings = append(rootSpellings, trimmed) + } + } + for _, spelling := range rootSpellings { + moves = append(moves, delegate.Rehome{From: spelling, To: copyDir}) + } + return moves +} + +// pathSpellings is every way a brief is likely to spell one folder: as given, +// absolute, with its links resolved, and under ~. +func pathSpellings(path string) []string { + names := []string{path, canonicalPath(path)} home, _ := os.UserHomeDir() home = strings.TrimRight(home, "/") - if abs, err := filepath.Abs(proposed); err == nil && !strings.HasPrefix(proposed, "~") { + if abs, err := filepath.Abs(path); err == nil && !strings.HasPrefix(path, "~") { names = append(names, abs) } if home != "" { From 6a8c85353155084c9763be3e0042ca2df0e95a35 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:07:39 -0400 Subject: [PATCH 078/195] session: a program on a plain folder leaves its records in the task, not the folder A program handed a folder with no git history works in the person's folder itself, and senior-dev's records were left there when it ended: 46 files in .senior-dev/, its session database and its whole model conversation among them. The manual said only the brief and checklist stayed, and its advice for isolation next time, git init and git add -A, would have committed all of it. A program now names the folder it keeps its records in (delegate.Delegate Notes, ".senior-dev" for senior-dev), and when a plain-folder run ends or is stopped codeaf moves that folder into the task's record folder beside its conversation with codeaf, and the page says where. A notes folder that was already there when the run began is left alone. The manual says what is kept and where, that a file changed in the folder after senior-dev submits is put back, and to delete a shell run's .senior-dev/ before git add -A. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delegate/delegate.go | 13 ++++ internal/manual/chat/delegates.md | 3 +- internal/manual/chat/senior-dev.md | 15 +++- internal/seniordev/notes_test.go | 14 ++++ internal/seniordev/seniordev.go | 12 ++-- internal/session/delegate_door.go | 54 +++++++++++++- internal/session/delegate_landing_test.go | 86 +++++++++++++++++++++++ internal/session/stoprun.go | 7 ++ internal/session/task_run_belt.go | 9 +++ 9 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 internal/seniordev/notes_test.go diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index cdd10d70f..e58a63faf 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -89,6 +89,19 @@ type Delegate struct { // where it is, 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. + // + // A PLAIN FOLDER'S NOTES ARE MOVED OUT OF IT. A program handed a folder with + // no git history 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. + // codeaf moves the folder this names into the task's own record folder when + // such a run ends. In a copy they stay in the copy, which the person's + // folder never sees. + 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 diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index bf603e5e8..54a4a892e 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -66,7 +66,8 @@ another folder, because nothing the program did there could land. A folder with no git history (a plain folder, or a repository with no commit yet) has nothing to copy from, so the program works in that folder itself, and codeaf tells it so on the line it starts it with (senior-dev is given `--in-place`). Nothing is committed: its -changes are already in the folder when it ends. +changes are already in the folder when it ends. Its own records (senior-dev's +`.senior-dev/`) are moved out of the folder into the task's record folder when it ends. At a shell nobody does that for you: clone the repository, then run `codeaf <name>` inside it, or name the folder with `--dir`. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index c5ac8f03a..496c474f8 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -97,15 +97,24 @@ nothing, and keeps its checkpoints outside the folder. When it ends there is nothing to commit, because its changes are already in the folder. The task's page says `its work is in <folder>, which has no git history, so nothing was -committed`. Its `.senior-dev/` notes (the brief, its checklist) stay in the folder -afterwards; delete them when you are done with them. +committed`. **Its own records are moved out of your folder** when the run ends or you +stop it: `.senior-dev/` (the brief, its checklist, the command it pinned, its session +database and its whole conversation with its model) goes into the task's record folder, +beside `delegate-conversation.jsonl`, and the page adds `its notes (.senior-dev/) are kept +in <path>`. A `.senior-dev/` already in the folder when the run began is left where it is. + +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. At a shell, pass `--in-place` yourself. Without it senior-dev stops at once with `workspace is not a git repository: <folder>; run with --in-place to work in a plain -folder`. +folder`. A shell run moves nothing: delete its `.senior-dev/` when you are done with it. To have its work isolated and left on a branch as one commit instead, make the folder a repository with a first commit (`git init`, `git add -A`, `git commit`) before you ask. +Delete any `.senior-dev/` a shell run left there first, or `git add -A` commits its +database and its conversation. ## Where senior-dev's work lands — its own branch, not merged, one squashed commit diff --git a/internal/seniordev/notes_test.go b/internal/seniordev/notes_test.go new file mode 100644 index 000000000..0c1bf8351 --- /dev/null +++ b/internal/seniordev/notes_test.go @@ -0,0 +1,14 @@ +//go:build !windows + +package seniordev + +import "testing" + +// THE FOLDER CODEAF MOVES OUT OF A PLAIN FOLDER IS THE ONE SENIOR-DEV WRITES +// ITS RECORDS TO: its brief, checklist, session database and conversation all +// live under app's seniorDevDataDirectory, which is this name. +func TestSeniorDevNamesTheFolderItKeepsItsRecordsIn(t *testing.T) { + if Program.Notes != ".senior-dev" { + t.Fatalf("senior-dev's notes folder = %q, want .senior-dev", Program.Notes) + } +} diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index b344886de..5ed264575 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -50,10 +50,14 @@ var Program = delegate.Delegate{ // checkpoints outside the folder and commits nothing; a folder with no git // history has nothing else it can run on. PlainFolder: []string{"--in-place"}, - CrewFlags: crewFlags, - Default: "run", - Page: "senior-dev", - Commands: []delegate.Command{runCommand}, + // Where it keeps its records in the folder it works in: the brief, the + // checklist, the pinned command, its session database and its model + // conversation (app's seniorDevDataDirectory, which git never sees). + Notes: ".senior-dev", + CrewFlags: crewFlags, + Default: "run", + Page: "senior-dev", + Commands: []delegate.Command{runCommand}, } // crewFlags is the conversation's crew as senior-dev's own flags: the working diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index f5c8831bf..6fcf60efc 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -23,10 +23,13 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "sort" "strings" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" ) // DelegateRow is one program as a surface lists it: the command word, the @@ -296,8 +299,12 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { if run.plain { // A PLAIN FOLDER HAS NO HISTORY TO COMMIT TO, and the program worked in // it where it stands: its changes are already the person's, and the - // landing is only the note that says where they are. + // landing is only the note that says where they are — and where the + // program's own records went, which are not the person's. note := "its work is in " + run.ground + ", which has no git history, so nothing was committed" + if kept := a.keepPlainFolderNotes(run); kept != "" { + note += "; " + kept + } if _, err := run.store.AddNote(run.root, run.root, note); err != nil { if g := a.graph(); g != nil { g.planNote("the run's landing note failed: " + err.Error()) @@ -402,3 +409,48 @@ func delegateHeadHome(dir, branch, startSha, name string) string { } return said } + +// keepPlainFolderNotes moves a program's notes folder ([delegate.Delegate.Notes]) +// out of the plain folder it worked in and into the task's own record folder, +// beside its conversation with codeaf, and answers the sentence that says +// where they went ("" when nothing moved). +// +// THE PERSON'S FOLDER GETS BACK ONLY THE WORK. A senior-dev run left 46 files +// in `.senior-dev/` there — its session database and its whole conversation +// with its model among them — and the manual's own advice for isolation next +// time, `git init` then `git add -A`, would have committed every one. A notes +// folder that was already there when the run began is left alone, because it +// is not this run's alone. A move across disks falls back to a copy and then a +// removal, and a move that fails leaves the folder where it was, whole. +func (a *Agent) keepPlainFolderNotes(run *beltRun) string { + m := run.delegate + if m == nil || m.Notes == "" || run.notesWereThere || run.tree.dir == "" { + return "" + } + from := filepath.Join(run.tree.dir, m.Notes) + if info, err := os.Lstat(from); err != nil || !info.IsDir() { + return "" + } + taskDir := plandb.TaskDir(filepath.Dir(run.store.Path()), run.root) + if err := os.MkdirAll(taskDir, 0o700); err != nil { + return "" + } + to := filepath.Join(taskDir, m.Name) + for n := 1; ; n++ { + if _, err := os.Lstat(to); os.IsNotExist(err) { + break + } + to = filepath.Join(taskDir, fmt.Sprintf("%s.%d", m.Name, n)) + } + if err := os.Rename(from, to); err != nil { + if err := copyPath(from, to); err != nil { + _ = os.RemoveAll(to) + if g := a.graph(); g != nil { + g.planNote(m.Name + "'s notes could not be moved out of " + run.ground + ": " + err.Error()) + } + return "" + } + _ = os.RemoveAll(from) + } + return "its notes (" + m.Notes + "/) are kept in " + to +} diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index 6efd0cfc6..e894d629d 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -17,6 +17,7 @@ import ( "testing" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" ) // delegatedRunThatDid runs one program whose work is play, in a repository @@ -280,3 +281,88 @@ func TestAProgramHandedASubfoldersTaskReadsPathsThatExistInItsCopy(t *testing.T) } endBeltRun(t, agent, double) } + +// plainFolderRun runs a program that keeps notes in `.fake` on a folder with +// no git history, playing a run that writes its work and its notes there, and +// answers the folder and the task's record folder. +func plainFolderRun(t *testing.T, before func(folder string)) (string, string, []string) { + t.Helper() + double := newBeltRunDouble("done") + double.work = func(workspace string) { + if err := os.MkdirAll(filepath.Join(workspace, ".fake", "storage"), 0o755); err != nil { + t.Error(err) + return + } + for name, body := range map[string]string{ + "made.txt": "made\n", + ".fake/spec.md": "the brief\n", + ".fake/storage/session.json": "{}\n", + } { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o644); err != nil { + t.Error(err) + } + } + } + registerBeltRunEngine(t, double) + folder := t.TempDir() + if before != nil { + before(folder) + } + programs := testPrograms("fake") + programs[0].Notes = ".fake" + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = folder + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = programs + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file in this folder"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + root := spec.Store.RootID() + return folder, plandb.TaskDir(place, root), beltRunNotes(t, place, root) +} + +// A PROGRAM THAT WORKED IN A PLAIN FOLDER LEAVES ONLY ITS WORK THERE. Its own +// records (a session database and its whole model conversation, for +// senior-dev) are moved into the task's record folder, where the page says +// they are, instead of waiting in the person's folder for a `git add -A`. +func TestAPlainFolderRunsNotesAreMovedIntoTheTasksRecordFolder(t *testing.T) { + folder, taskDir, notes := plainFolderRun(t, nil) + 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, ".fake")); !os.IsNotExist(err) { + t.Fatalf("the program's notes were left in the person's folder: %v", err) + } + for _, name := range []string{"spec.md", filepath.Join("storage", "session.json")} { + if _, err := os.Stat(filepath.Join(taskDir, "fake", name)); err != nil { + t.Fatalf("the program's %s is not in the task's record folder: %v", name, err) + } + } + if !strings.Contains(strings.Join(notes, "\n"), "its notes (.fake/) are kept in "+filepath.Join(taskDir, "fake")) { + t.Fatalf("the page does not say where the notes went: %q", notes) + } +} + +// NOTES THAT WERE THERE BEFORE THE RUN ARE NOT THIS RUN'S TO TAKE. +func TestAPlainFolderRunLeavesNotesThatWereThereBeforeIt(t *testing.T) { + folder, taskDir, _ := plainFolderRun(t, func(folder string) { + if err := os.MkdirAll(filepath.Join(folder, ".fake"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(folder, ".fake", "old.md"), "an earlier run's\n") + }) + if _, err := os.Stat(filepath.Join(folder, ".fake", "old.md")); err != nil { + t.Fatalf("notes that were there before the run were taken: %v", err) + } + if _, err := os.Stat(filepath.Join(taskDir, "fake")); !os.IsNotExist(err) { + t.Fatalf("notes that were not this run's alone were moved: %v", err) + } +} diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index ce2ae41c6..0e6d2cf22 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -228,6 +228,13 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { if merge != mergeInPlace { report += " · " + beltStoppedWhere(run.tree.branch, run.ground, changed) } + // A PROGRAM STOPPED IN A PLAIN FOLDER leaves the person's folder its work + // and nothing of its own, exactly as one that ended does. + if run.plain { + if kept := a.keepPlainFolderNotes(run); kept != "" { + report += " · " + kept + } + } if _, err := run.store.AddNote(run.root, run.root, report); err != nil { if g := a.graph(); g != nil { g.planNote("the run's outcome note failed: " + err.Error()) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index ba7b9e8a3..b00931ca0 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -307,6 +307,11 @@ type beltRun struct { // ([delegateOnPlainFolder]): it is told so on its line, and its landing // commits nothing, because the work is already where it belongs. plain bool + // notesWereThere says the program's notes folder ([delegate.Delegate.Notes]) + // was already in a plain folder when the run began — left by a run started + // at a shell, say — so its landing leaves it where it is rather than take + // records that are not this run's alone. + notesWereThere bool // groundMoves is every spelling of the folder a tree program's task was // proposed on and of the repository around it, each paired with where it // stands in the copy, when the program works in a copy of it @@ -419,6 +424,10 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie if via != nil && via.LandsTree() && !run.plain { run.groundMoves = delegateGroundMoves(stand.dir, tree.root, tree.dir) } + if run.plain && via.Notes != "" { + _, err := os.Lstat(filepath.Join(tree.dir, via.Notes)) + run.notesWereThere = err == nil + } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because // the branch it names exists only in this variable until it is: the road that From 5a062fe852d692bf5cae67bb4f8bceed7a77b317 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:08:18 -0400 Subject: [PATCH 079/195] seniordev: the crew's planning seat is not passed, because nothing would use it codeaf handed the conversation's mastermind model to senior-dev as --frontier, and the manual said it handled senior-dev's hardest calls. But senior-dev routes exactly two kinds of call, the coder's turns on the high pool and the history summary on the low one; no call rides the frontier tier, so the flag changed nothing. The crew now reaches senior-dev as --high and --low only, the flag's own help says it changes nothing, and the manual says the mastermind model is not used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 16 +++++++++------- internal/seniordev/crew_test.go | 7 ++++--- internal/seniordev/seniordev.go | 20 +++++++++++++------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 496c474f8..3890269bc 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -225,9 +225,10 @@ never guesses a figure for either. ## Which models does senior-dev use — your crew, its own list, --high -**From the chat it uses your crew.** codeaf hands senior-dev the conversation's crew: the -worker (hands) model is the one it works with, the mastermind (brain) model its hardest -calls, and the low model its history summaries. Change the crew and the next run follows. +**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. @@ -235,8 +236,8 @@ if that leaves no working model, it uses its own list instead. 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, `--frontier` and `--low` set the -other two, and `--variant` sets the reasoning effort every call asks for. +**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 @@ -275,8 +276,9 @@ senior-dev's own flags on `run`: `xhigh`; unset leaves the model's own default; - `--in-place` — work in a folder without git: no commits, and its checkpoints kept outside the folder; -- `--high`, `--low`, `--frontier` — comma-separated models it routes among; `--low` - (its history summaries) and `--frontier` fall back to `--high`; +- `--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. diff --git a/internal/seniordev/crew_test.go b/internal/seniordev/crew_test.go index ac1356fd6..d00adbe65 100644 --- a/internal/seniordev/crew_test.go +++ b/internal/seniordev/crew_test.go @@ -10,11 +10,12 @@ import ( ) // The crew reaches senior-dev as its own flags: the working seat is the pool -// it routes on, the planning seat its frontier, the light seat its summaries, -// and a seat left unset keeps senior-dev's own default. +// 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 --frontier openrouter/vendor/brain --low openrouter/vendor/light"; got != want { + 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" { diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 5ed264575..6ffbb9329 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -61,15 +61,21 @@ var Program = delegate.Delegate{ } // crewFlags is the conversation's crew as senior-dev's own flags: the working -// seat is the pool the coder routes on (--high), the planning seat its frontier -// tier, and the light seat the history summaries (--low). --crew says the pools -// came from a crew, so a model senior-dev's catalog cannot size is left out -// rather than failing the run. A seat the crew leaves unset keeps senior-dev's -// own default for it. +// seat is the pool the coder routes on (--high), and the light seat the +// history summaries (--low). --crew says the pools came from a crew, so a +// model senior-dev's catalog cannot size is left out rather than failing the +// run. A seat the crew leaves unset keeps senior-dev's own default for it. +// +// THE PLANNING SEAT IS NOT HANDED OVER, BECAUSE NOTHING WOULD USE IT. It went +// to senior-dev's frontier tier, and senior-dev routes exactly two kinds of +// call: the coder's turns on the high pool and the history summary on the low +// one (baked/tier.go). No call rides the frontier tier, so the flag changed +// nothing, while the manual told the person their mastermind model handled +// senior-dev's hardest calls. func crewFlags(crew delegate.Crew) []string { flags := []string{"--crew"} for _, seat := range []struct{ flag, model string }{ - {"--high", crew.Hands}, {"--frontier", crew.Brain}, {"--low", crew.Light}, + {"--high", crew.Hands}, {"--low", crew.Light}, } { if model := app.CrewModel(seat.model); model != "" { flags = append(flags, seat.flag, model) @@ -94,7 +100,7 @@ func bindRun(fs *flag.FlagSet) delegate.Body { inPlace := fs.Bool("in-place", false, "work without git: no commits; checkpoints kept outside") high := fs.String("high", app.DefaultHighModels, "models the coder routes among, comma-separated") low := fs.String("low", "", "models for the history summary (default: --high)") - frontier := fs.String("frontier", "", "models for the frontier tier (default: --high)") + frontier := fs.String("frontier", "", "models for the frontier tier; no call uses it, so it changes nothing") crew := fs.Bool("crew", false, "the models came from codeaf's crew: skip any it cannot size") return func(ctx context.Context, host delegate.Host, args []string) error { run(ctx, host, app.Options{ From 122987a20fd2f1a50ce08c87859972e9376686e1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:08:57 -0400 Subject: [PATCH 080/195] plandb: the comments stop saying the next hand-off adopts an open run StopRoot and FailRoot explained themselves by the next hand-off adopting a store whose run was left open. A new hand-off in a conversation now never adopts a store; what an open run still does is read as running, and be taken up by the doors that do adopt (the headless errand's, the carry-on door). The comments say that instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/plandb/store.go | 17 +++++++++-------- internal/session/stoprun.go | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/internal/plandb/store.go b/internal/plandb/store.go index 29271f5d0..a7002b75e 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -1520,11 +1520,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 { s.mu.Lock() defer s.mu.Unlock() @@ -1558,9 +1559,9 @@ func (s *Store) StopRoot(reason string) error { // 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 the next hand-off would have -// adopted the dead run's store as live work ([Store.StopRoot] says why an open -// run is adopted). A run that already ended is left as it ended. +// 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{}) } diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index 0e6d2cf22..43b19d645 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -26,7 +26,7 @@ package session // home after that finds its task already ended and writes // nothing over it, so no part of a stopped run reads as a // failure with a cut call's error for its reason. And a store -// whose run is over is one the next hand-off cannot adopt. +// whose run is over reads as over on its page. // the context next every worker and every call a worker has out was handed // this context, so the spend ends here and not at the next // pass of anybody's loop. From f83bcf999dfe2b36f6717c93f6ad2919db248de0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:09:38 -0400 Subject: [PATCH 081/195] seniordev: the frontier flag's help fits eighty columns The help line said the flag changes nothing in more words than a terminal line holds; it now says no call uses it, inside the eighty columns every carried program's help is held to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/seniordev/seniordev.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 6ffbb9329..1430ad987 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -100,7 +100,7 @@ func bindRun(fs *flag.FlagSet) delegate.Body { inPlace := fs.Bool("in-place", false, "work without git: no commits; checkpoints kept outside") high := fs.String("high", app.DefaultHighModels, "models the coder routes among, comma-separated") low := fs.String("low", "", "models for the history summary (default: --high)") - frontier := fs.String("frontier", "", "models for the frontier tier; no call uses it, so it changes nothing") + frontier := fs.String("frontier", "", "models for the frontier tier (no call uses it)") crew := fs.Bool("crew", false, "the models came from codeaf's crew: skip any it cannot size") return func(ctx context.Context, host delegate.Host, args []string) error { run(ctx, host, app.Options{ From eac21fb5bdf4345866dd60a63bc12bdd60fb4ae8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:12:02 -0400 Subject: [PATCH 082/195] session: a branch-only landing is a road of its own bringBeltRunHome had grown to eighteen decisions once an empty branch was deleted and the branch's repository named, over the task engine's ceiling of fifteen. The branch-only road (the empty branch dropped, or the branch and its repository named and the homecoming written) is now branchOnlyLanding, split along the line its own comments already drew; nothing it does changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/task_run_belt.go | 60 ++++++++++++++++++------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index b00931ca0..3e43c06a7 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1072,36 +1072,14 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { return landing } merge, said, _, _ := run.tree.comeHome(run.title, nil, a.signsGitWork()) - if merge == mergeKept && run.tree.keepsBranch && dropEmptyTaskBranch(run.tree, run.startSha) { - // AN EMPTY BRANCH IS NOT A LANDING. The branch was kept for the person - // to merge, and there is nothing on it to merge: every look-only, - // failed or crashed program run left one more `task/*` branch at the - // commit it started from in the person's repository. It is deleted, and - // the run says what it always said about a copy that holds no change. - if landing.Refused == "" { - landing.Refused = runNothingToLand - } - return landing + if merge == mergeKept && run.tree.keepsBranch { + return a.branchOnlyLanding(run, landing, said) } if landing.Refused != "" { // NOTHING TO LAND IS STILL AN ENDING: the copy was given back above, and // the sentence the engine answered is the whole account. return landing } - if merge == mergeKept && run.tree.keepsBranch { - // A BRANCH-ONLY LANDING IS A LANDING, not a refusal: the work is on its - // branch in the person's repository, which is where it was promised. - landing.Home, landing.Root = merge, run.tree.root - if run.tree.branch != "" { - landing.Branch = run.tree.branch - } - if _, err := run.store.AddNote(run.root, run.root, said); err != nil { - if g := a.graph(); g != nil { - g.planNote("the run's homecoming note failed: " + err.Error()) - } - } - return landing - } if merge != mergeMerged && merge != mergeInPlace { // THE WORK DID NOT GO IN, AND THE OUTCOME NOTE SAYS SO IN THE ROAD'S OWN // SENTENCE, which names the kept branch and what it clashed with. It is @@ -1126,6 +1104,40 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { return landing } +// branchOnlyLanding is the landing of a copy whose work lands AS ITS BRANCH +// ([delegateKeepsBranch]), once the copy has come home and been given back: +// the branch named, with the repository it is in, and the homecoming written +// on the run's page; or, for a branch holding nothing, the branch deleted. +func (a *Agent) branchOnlyLanding(run *beltRun, landing RunLanding, said string) RunLanding { + if dropEmptyTaskBranch(run.tree, run.startSha) { + // AN EMPTY BRANCH IS NOT A LANDING. The branch was kept for the person + // to merge, and there is nothing on it to merge: every look-only, + // failed or crashed program run left one more `task/*` branch at the + // commit it started from in the person's repository. It is deleted, and + // the run says what it always said about a copy that holds no change. + if landing.Refused == "" { + landing.Refused = runNothingToLand + } + return landing + } + if landing.Refused != "" { + // NOTHING TO LAND IS STILL AN ENDING, as on every other road. + return landing + } + // A BRANCH-ONLY LANDING IS A LANDING, not a refusal: the work is on its + // branch in the person's repository, which is where it was promised. + landing.Home, landing.Root = mergeKept, run.tree.root + if run.tree.branch != "" { + landing.Branch = run.tree.branch + } + if _, err := run.store.AddNote(run.root, run.root, said); err != nil { + if g := a.graph(); g != nil { + g.planNote("the run's homecoming note failed: " + err.Error()) + } + } + return landing +} + // dropEmptyTaskBranch deletes a kept task branch that holds nothing past the // commit its copy started from, and reports whether it did. Only a branch // whose tip IS that commit goes, so a branch holding even one commit of the From 5544b35e2409852a8faa99c8cce3530bb9061374 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:15:28 -0400 Subject: [PATCH 083/195] session: a moved HEAD's note says only what is true of the landing The note about a program that had moved its copy's HEAD said "its work was committed on <task branch>" even when the landing committed nothing, beside "nothing to land". The facts delegateHeadHome finds are now a headMove, and the sentence names where HEAD had been in every case, says the work was committed and warns about work built on another commit only when a commit was made. The supervisor's comment stops saying the next hand-off adopts a run left open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/run/run.go | 8 ++-- internal/session/delegate_door.go | 70 +++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/internal/run/run.go b/internal/run/run.go index fbd914c8d..c7553fd45 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -397,10 +397,10 @@ 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 the next hand-off - // would adopt it 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. + // 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 diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 6fcf60efc..06b40d9e4 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -314,7 +314,7 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { } dir := run.workspace // THE SQUASH LANDS ON CODEAF'S BRANCH, WHEREVER THE PROGRAM LEFT HEAD. - moved := delegateHeadHome(dir, run.tree.branch, run.startSha, m.Name) + moved := delegateHeadHome(dir, run.tree.branch, run.startSha) if run.startSha != "" { head, err := git(dir, "rev-parse", "--verify", "-q", "HEAD") if err != nil || strings.TrimSpace(head) != run.startSha { @@ -343,8 +343,8 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { if note == "" { note = fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) } - if moved != "" { - note += " · " + moved + if said := moved.sentence(m.Name, run.tree.branch, landing.Refused == ""); said != "" { + note += " · " + said } if _, err := run.store.AddNote(run.root, run.root, note); err != nil { if g := a.graph(); g != nil { @@ -354,11 +354,20 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { return a.bringBeltRunHome(run, landing) } +// headMove is what a tree program had done with its copy's HEAD by the time +// it ended, as [delegateHeadHome] found it: the branch it had moved to (empty +// with detached set for no branch at all), and whether its work stood on the +// commit the copy started from. The zero value is a HEAD that never left the +// task's branch. +type headMove struct { + moved bool + from string + detached bool + unrelated bool +} + // delegateHeadHome puts a tree program's copy back on the task's own branch -// before its work is squashed, and answers the sentence the landing note adds -// when it had to: which branch the program had moved the copy to, and whether -// its work stood on the commit the copy started from. It answers "" for the -// ordinary run, whose HEAD never left the task's branch. +// before its work is squashed, and answers what it found ([headMove]). // // A PROGRAM'S SHELL CAN MOVE HEAD, AND ONE DID. A brief said "work on a new // branch", and senior-dev ran `git checkout -b` four times in one run. The @@ -380,32 +389,51 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { // so work the program built on some other commit (a branch cut from `main`, // say) also undoes whatever the copy's first commit had and that one did not, // and the diff is the only place that would show. -func delegateHeadHome(dir, branch, startSha, name string) string { +func delegateHeadHome(dir, branch, startSha string) headMove { branch = strings.TrimSpace(branch) if branch == "" { - return "" + return headMove{} } current := currentBranch(dir) if current == branch { - return "" + return headMove{} } head, _ := git(dir, "rev-parse", "--verify", "-q", "HEAD") head = strings.TrimSpace(head) if _, err := git(dir, "symbolic-ref", "HEAD", "refs/heads/"+branch); err != nil { + return headMove{} + } + move := headMove{moved: true, from: current, detached: current == ""} + if startSha != "" && head != "" { + _, err := git(dir, "merge-base", "--is-ancestor", startSha, head) + move.unrelated = err != nil + } + return move +} + +// sentence is what the landing note adds about a HEAD the program had moved: +// where it had left the copy, where its work was committed when anything was, +// and the warning about work built on another commit. Empty for a HEAD that +// never moved. A landing that committed nothing says only where HEAD had been, +// because "its work was committed" would be a claim about a commit that does +// not exist. +func (move headMove) sentence(name, branch string, landed bool) string { + if !move.moved { return "" } - var said string - if current == "" { - said = name + " had left its copy on no branch; its work was committed on " + branch - } else { - said = name + " had moved its copy to the branch " + current + "; its work was committed on " + branch + - ", and any commit it made on " + current + " is still on that branch" + said := name + " had moved its copy to the branch " + move.from + if move.detached { + said = name + " had left its copy on no branch" } - if startSha != "" && head != "" { - if _, err := git(dir, "merge-base", "--is-ancestor", startSha, head); err != nil { - said += " · its work was not built on the commit its copy started from, so the commit on " + branch + - " may also undo changes that commit had; read its diff before you merge it" - } + if landed { + said += "; its work was committed on " + branch + } + if !move.detached { + said += ", and any commit it made on " + move.from + " is still on that branch" + } + if landed && move.unrelated { + said += " · its work was not built on the commit its copy started from, so the commit on " + branch + + " may also undo changes that commit had; read its diff before you merge it" } return said } From be29f76021e9277d1f8610ebf0f4566f8fd2a0be Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:15:46 -0400 Subject: [PATCH 084/195] session: pin what a moved HEAD's note claims over nothing to land The sentence about a program that had moved its copy's HEAD names where HEAD had been either way and claims a commit, with the warning about work built elsewhere, only when one was made; this test holds it there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/delegate_landing_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index e894d629d..4cf81adf1 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -366,3 +366,22 @@ func TestAPlainFolderRunLeavesNotesThatWereThereBeforeIt(t *testing.T) { t.Fatalf("notes that were not this run's alone were moved: %v", err) } } + +// A MOVED HEAD OVER NOTHING TO LAND CLAIMS NO COMMIT. The note names where the +// program had left HEAD either way, and says its work was committed, with the +// warning about work built elsewhere, only when a commit was made. +func TestAMovedHeadsNoteClaimsACommitOnlyWhenOneWasMade(t *testing.T) { + moved := headMove{moved: true, from: "senior-own", unrelated: true} + if got := moved.sentence("fake", "task/x", false); got != "fake had moved its copy to the branch senior-own, and any commit it made on senior-own is still on that branch" { + t.Fatalf("over nothing to land the note says %q", got) + } + if got := moved.sentence("fake", "task/x", true); !strings.Contains(got, "its work was committed on task/x") || !strings.Contains(got, "read its diff before you merge it") { + t.Fatalf("over a landing the note says %q", got) + } + if got := (headMove{moved: true, detached: true}).sentence("fake", "task/x", false); got != "fake had left its copy on no branch" { + t.Fatalf("a detached HEAD over nothing to land says %q", got) + } + if got := (headMove{}).sentence("fake", "task/x", true); got != "" { + t.Fatalf("a HEAD that never moved says %q", got) + } +} From 63070a7768a0ef80376ab12d728a2bdd362d3131 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:09:52 -0400 Subject: [PATCH 085/195] tui3: a run's clock counts from the record's start and its landed span is the record's A run's rows carry the record's start and publish no age, so the rail anchored a run's clock at the moment this window first saw it: a window reopened twenty minutes into a senior-dev run read it as eight and a half minutes at twenty-eight and a half, and the landed card measured the same short span ("9m08s") under its own stamps saying 29 minutes. After landing the room's clock read nothing at all, because the run's rows report no age. A running notice's StartedAt now anchors the rail's clock (the reported age only when the start is missing or stamped by a clock running ahead of this one), and a landed node's span is the record's end less its start, rounded to the second, falling back to the reported age. The landed card and the room's clock read that one span, so the two formatters spell one number ("29m08s" on the card, "29m 8s" in the header). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/reading-a-task-page.md | 6 ++ internal/tui3/room.go | 24 +++++-- internal/tui3/runclock_test.go | 79 +++++++++++++++++++++ internal/tui3/task.go | 54 +++++++++++++- internal/tui3/taskdone.go | 2 +- 5 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 internal/tui3/runclock_test.go diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index d059bb3a2..d0a58d913 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -40,6 +40,12 @@ 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.** When the record carries both instants, the +settled page's header and the completion card show the landing less the start, rounded to +the second — one figure, spelled `29m 8s` in the header and `29m08s` on the card. 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/tui3/room.go b/internal/tui3/room.go index 8d207a443..d512a8180 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -3383,13 +3383,29 @@ const ( roomDoneWord = "done" ) -// roomClock is the node's age: counting up while it runs, frozen at what the -// update that ended it reported. +// roomClock is the node's age: counting up while it runs, and once it has +// landed the span the landed card draws ([taskNode.ranFor]) — the record's +// own start and end, so the page and the card read one number for one run. func (a *app) roomClock(node *taskNode) string { + word, _ := a.nodeClock(node) + return word +} + +// nodeClock is [app.roomClock] with whether this window holds any clock for +// the node at all, which is what lets a page that has another source for the +// figure ([app.taskPlanAge]'s store stamps) fall back to it only when the rail +// has nothing to say. +func (a *app) nodeClock(node *taskNode) (string, bool) { + if node == nil { + return "", false + } if node.state == session.TaskRunning && !node.began.IsZero() { - return countUpWord(a.now().Sub(node.began)) + return countUpWord(a.now().Sub(node.began)), true + } + if span := node.ranFor(); span > 0 { + return countUpWord(span), true } - return countUpWord(node.elapsed) + return "", false } // roomSpend is what this node has cost, or "" when nobody has published a price diff --git a/internal/tui3/runclock_test.go b/internal/tui3/runclock_test.go new file mode 100644 index 000000000..4badea97a --- /dev/null +++ b/internal/tui3/runclock_test.go @@ -0,0 +1,79 @@ +package tui3 + +// ONE CLOCK FOR ONE RUN. A run's rows publish the record's start and, when it +// lands, its end, and every surface that draws how long the run has taken reads +// those two instants: the side list's clock counts from the start whenever this +// window met the run, and the landed card's span is the end less the start. + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// A RUN'S ROW COUNTS FROM THE RECORD'S START, NOT FROM WHEN THIS WINDOW MET IT. +// A run's rows carry no age (session's task_run_belt.go), and a window that +// attached twenty minutes into senior-dev's run started the rail's clock at +// that moment: at twenty-eight and a half minutes the rail read `8m 30s`. +func TestARunsRailClockCountsFromTheRecordsStart(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + now := started.Add(20 * time.Minute) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskRunning, + session.TaskNotice{StartedAt: started})}) + now = started.Add(28*time.Minute + 30*time.Second) + node := a.tasks[7] + if got := plain(a.railTelemetry(node, 40)); !strings.HasPrefix(got, "28m 30s") { + t.Fatalf("the rail reads %q twenty-eight and a half minutes into the run, want 28m 30s", got) + } + // A START STAMPED BY A CLOCK AHEAD OF THIS ONE IS NOT TRUSTED over the age the + // update reported, which needs no agreement between two clocks. + b, _ := planAppWith(t, nil, nil) + at := taskFixtureNow + b.clock = func() time.Time { return at } + drive(t, b, streamEventMsg{gen: b.gen, ev: update(8, "ahead", session.TaskRunning, + session.TaskNotice{StartedAt: at.Add(time.Minute), Elapsed: 5 * time.Second})}) + if got := b.tasks[8].began; !got.Equal(at.Add(-5 * time.Second)) { + t.Fatalf("a start from a clock ahead anchored the row at %s, want the reported age", got) + } +} + +// THE LANDED CARD MEASURES THE RECORD'S OWN SPAN. The same window, met twenty +// minutes late, landed a twenty-nine-minute run as `9m08s` under a card whose +// own stamps said 29 minutes; and with the age the session now reports, the +// card and the stamps agree. +func TestALandedCardMeasuresTheRecordsOwnSpan(t *testing.T) { + started := taskFixtureNow + ended := started.Add(29*time.Minute + 8*time.Second + 400*time.Millisecond) + for _, tc := range []struct { + name string + elapsed time.Duration + }{ + {"stamps alone", 0}, + {"stamps and the reported age", ended.Sub(started)}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + now := started.Add(20 * time.Minute) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskRunning, + session.TaskNotice{StartedAt: started})}) + now = ended.Add(2 * time.Second) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskDone, + session.TaskNotice{StartedAt: started, EndedAt: ended, Elapsed: tc.elapsed})}) + card := a.doneCardAt(len(a.entries) - 1) + if card == nil { + t.Fatal("the run's landing drew no card") + } + if tail := plain(a.doneTail(card)); !strings.Contains(tail, "29m08s") { + t.Fatalf("the landed card reads %q, want the record's span 29m08s", tail) + } + if got := a.roomClock(a.tasks[7]); got != "29m 8s" { + t.Fatalf("the landed run's clock reads %q, want 29m 8s", got) + } + }) + } +} diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 48754b741..a8088288e 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -494,6 +494,53 @@ func (n *taskNode) spawnedAt() time.Time { return n.met } +// ranFor is how long this node's work took once it has landed: the record's +// own two stamps when it carries both, the age the landing reported when it +// does not, and zero — which every surface draws as nothing — when neither is +// known. +// +// THE STAMPS OUTRANK THE REPORTED AGE, AND BOTH OUTRANK THIS WINDOW'S CLOCK. +// A run's rows used to publish no age at all, so the landed card measured from +// the moment this window first saw the run, and a window reopened twenty +// minutes into a senior-dev run read a twenty-nine-minute run as nine. The +// stamps are the record's facts about the work; the window's own moments are +// not. +// +// IT IS WHOLE SECONDS, ROUNDED, because the three surfaces that draw a landed +// node's span spell it through two formatters — the landed card's +// [taskSpanWord], which rounds, and the room's and the page's [countUpWord], +// which cuts — and a span handed to both unrounded read `22m52s` on the card +// and `22m 51s` on the page for one run. +func (n *taskNode) ranFor() time.Duration { + if !n.started.IsZero() && n.ended.After(n.started) { + return n.ended.Sub(n.started).Round(time.Second) + } + if n.elapsed > 0 { + return n.elapsed.Round(time.Second) + } + return 0 +} + +// noticeBegan is the instant a node's running clock counts from, anchored +// from the first running update this window receives about it. +// +// THE RECORD'S START IS THE ANCHOR WHEN THE UPDATE CARRIES ONE. A run's rows +// report no age (session's task_run_belt.go publishes StartedAt and a zero +// Elapsed), and a run's row is replayed to a window that attaches mid-run as +// it was first published — so an anchor taken from the age alone started the +// rail's clock at the moment this window opened, and a window reopened while +// senior-dev worked read the run as however long the window had been open. A +// start stamped later than this window's own clock is another machine's clock +// running ahead, and it falls back to the reported age, which needs no +// agreement between two clocks. +func (a *app) noticeBegan(notice session.TaskNotice) time.Time { + now := a.now() + if !notice.StartedAt.IsZero() && !notice.StartedAt.After(now) { + return notice.StartedAt + } + return now.Add(-notice.Elapsed) +} + // spent is what this node has cost, in dollars, from whichever of its two lanes // knows the most — the engine's published figure, or the pilot's running sum. // Zero means nobody has priced it, which is not "it was free", and a surface @@ -6003,10 +6050,11 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { if !notice.EndedAt.IsZero() { node.ended = notice.EndedAt } - // The clock is anchored ONCE, from the age the update reported, so the row - // counts on the frame tick instead of standing still between events. + // The clock is anchored ONCE, from the record's start or the age the update + // reported ([app.noticeBegan]), so the row counts on the frame tick instead + // of standing still between events. if notice.State == session.TaskRunning && node.began.IsZero() { - node.began = a.now().Add(-notice.Elapsed) + node.began = a.noticeBegan(*notice) } // A node that started is a proposal that was approved, whatever answered it: // the card stops asking here for the case where the engine's clock, and not diff --git a/internal/tui3/taskdone.go b/internal/tui3/taskdone.go index 9adefb7ca..786d222e6 100644 --- a/internal/tui3/taskdone.go +++ b/internal/tui3/taskdone.go @@ -228,7 +228,7 @@ func (a *app) landedCard(node *taskNode) { title: title, subtitle: taskSubtitleOf(title, node.assignment), status: session.ProjectTask(doneNodeFacts(node)), - span: node.elapsed, + span: node.ranFor(), started: node.spawnedAt(), landed: landed, outcome: firstProseLine(node.report), From 8d5e75ffca07a475e4e3d2f1745e0372719f1d4c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:11:34 -0400 Subject: [PATCH 086/195] tui3: the task pages the belt switch draws over the conversation obey the tab bar The work tab and a run's page opened from the side list are drawn over the conversation, before any place, and nothing on the tab bar knew they were there. With the work tab up both it and the conversation's tab drew selected, a press on the conversation's tab did nothing, and Home opened underneath while the work tab went on drawing a strip Home had left; ctrl+c on it was handed to the page, which took nothing. The side-list page draws no strip, but presses were still answered against the strip the conversation last drew, so a press where its close mark had been raised the close-tab card under the page; the wheel scrolled the rail and the transcript under it; and after enter on a run's row the first esc was spent giving back a side list nobody could see. Now every place stands both pages down (standDownRest), the work tab is the one selected tab while it is up and the conversation's tab goes back to the conversation, ctrl+c passes the work tab as it passes every other page, a press on the side-list page does nothing and a press on the work tab reaches only its strip, the wheel scrolls the page that is drawn, the frame forgets the last place bar before drawing either page, and the side list gives the keyboard back when the page opens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/home.md | 6 +- internal/manual/chat/worker-harness.md | 10 +- internal/tui3/app.go | 28 +++ internal/tui3/chattabs.go | 16 +- internal/tui3/input.go | 4 +- internal/tui3/settings.go | 4 + internal/tui3/tabbar_test.go | 301 +++++++++++++++++++++++++ internal/tui3/taskplan.go | 3 + internal/tui3/view.go | 16 +- internal/tui3/worktab.go | 19 ++ 10 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 internal/tui3/tabbar_test.go diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3ed79f62a..2d68f91af 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2369,9 +2369,11 @@ 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 -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 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 `Home`, or `esc` leaves it. ## Why does a closed conversation say another window diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 6d2b637ef..c9f91e8ce 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -95,7 +95,7 @@ shows, in order, each section left out when nothing is behind it: 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 is a @@ -103,8 +103,12 @@ door: click the run's row, or select it and press `enter`, and its page opens ov conversation; click a part's row or a check's row and THAT task's page opens. The page is the one the tasks place opens: what the task was asked, its notes, its steps, and the box that leaves a note — except a program's page, which is its conversation and has no -box (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. +box (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, and stops nothing. The page takes the whole window: while it is up it +covers the tab strip, the side list and the transcript, and a press on any of them does +nothing. The page can take a moment to arrive. From the press on, what you type belongs to the page and never to the conversation: the keys are kept in order and land in the page's diff --git a/internal/tui3/app.go b/internal/tui3/app.go index b79f64bbd..b1f4ad07e 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3718,6 +3718,17 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd, took := a.placeTabWheel(msg.Mouse().Y, placeWheelDelta(msg.Mouse().Button)); took { return a, cmd } + // AND THE TWO TASK PAGES THE BELT SWITCH DRAWS OVER THE CONVERSATION, on + // the press's terms: the wheel scrolls a run's page, the work tab's list + // keeps no offset of its own, and neither moves the tab names, the side + // list or the transcript under it. + if a.railTaskPlanOn { + a.taskPlanScroll(placeWheelDelta(msg.Mouse().Button)) + return a, nil + } + if a.workTabOn { + return a, nil + } // The settings panel is modal for the pointer too: it is the whole // screen, so there is no conversation under it for a wheel to reach. if a.at(pageSettings) { @@ -3913,6 +3924,23 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // not being drawn (firstrun.go). return a, nil } + // A TASK PAGE THE BELT SWITCH DRAWS OVER THE CONVERSATION IS MODAL FOR THE + // POINTER. The work tab answers its strip and nothing else, and a run's + // page opened from the side list draws no strip and answers nothing. A + // press that fell through was answered by the strip, the side list or the + // transcript underneath: a press where the conversation's ✕ had been + // closed the conversation's tab under a page that went on covering it. + if a.railTaskPlanOn { + return a, nil + } + if a.workTabOn { + if msg.Mouse().Button == tea.MouseLeft { + if cmd, took := a.tabPress(msg.Mouse().X, msg.Mouse().Y); took { + return a, cmd + } + } + return a, nil + } if msg.Mouse().Button == tea.MouseLeft { // THE TAB BAR IS READ BEFORE EVERY PLACE'S OWN ROWS, because it is // the router's row and not any place's: it is drawn on every one of diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index bdff65158..21af29bf4 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -388,7 +388,10 @@ func keysHold(keys []string, key string) bool { // title the surface is showing for the one in front, the agent's own for one the // keeper holds, and the last name it went by for one that is neither. func (a *app) tabAs(tab chatTab, held *kept, front string) chatTab { - tab.here = tab.key == front + // ONE TAB IS DRAWN SELECTED. While the work tab is up it is the selected one + // (worktab.go), and the conversation's own tab is a door back to the + // conversation ([app.tabGo]); both used to draw selected at once. + tab.here = tab.key == front && !a.workTabOn tab.held = held != nil switch { case tab.here: @@ -1000,6 +1003,17 @@ func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { if tab.work { return a.openWorkTab() } + // THE CONVERSATION'S OWN TAB, PRESSED FROM A PAGE DRAWN OVER IT, IS THE WAY + // BACK TO IT: the page stands down and the conversation is what is drawn, + // exactly as `esc` would leave it. The press used to reach a switch to the + // conversation already in front, which did nothing. + if tab.key != "" && !tab.start && tab.key == a.frontTabKey() && (a.workTabOn || a.railTaskPlanOn) { + a.leaveTaskOverlays() + a.closeRoom() + a.tabReveal() + a.touch() + return nil + } a.workTabOn = false a.tabReveal() if tab.start { diff --git a/internal/tui3/input.go b/internal/tui3/input.go index ce7d70a19..4b95e4b28 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -359,7 +359,9 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { } return cmd } - if a.workTabOn { + // AND THE WORK TAB IS READ BELOW THE DOOR TOO, on the same law: ctrl+c on it + // was handed to the page, which took nothing. + if a.workTabOn && !door { return a.workTabKey(msg) } if cmd, taken := a.pasteChipKey(msg); taken { diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 301868298..ad4602394 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1371,6 +1371,10 @@ func (a *app) standDownRest() { a.closeRewindSheet(true) } a.closeJobPage() + // AND THE TASK PAGES THE BELT SWITCH DRAWS OVER THE CONVERSATION, which are + // drawn before any place is: a place opened under one of them was a place + // nobody could see (worktab.go's [app.leaveTaskOverlays]). + a.leaveTaskOverlays() } func (s *sheet) searching() bool { return strings.TrimSpace(s.query.String()) != "" } diff --git a/internal/tui3/tabbar_test.go b/internal/tui3/tabbar_test.go new file mode 100644 index 000000000..e877fc571 --- /dev/null +++ b/internal/tui3/tabbar_test.go @@ -0,0 +1,301 @@ +package tui3 + +// THE TAB BAR KEEPS WORKING WHATEVER IS OPEN UNDER IT. These tests drive the +// surface's own Update loop and read what the FRAME shows after each gesture: +// how many tabs are drawn selected, whether Home is on the bar, and whether a +// page is still covering whatever the gesture chose. +// +// An ordinary task's room is the contrast: it is drawn under the +// conversation's own strip, with that conversation's tab the one selected tab +// and Home beside it, and `esc`, a press on the conversation's tab and a press +// on Home all leave it. The two pages the belt switch still draws over the +// conversation (CODEAF_TASK_BELT) — the work tab, and a run's page opened from +// the side list — are held to the same laws here. A program's task, which used +// to be one of those pages, is a room now and has its own file +// (programtab_test.go). + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// tabShot is one frame as a person reads its top: whether the conversation's +// strip was drawn on THIS frame, which of its tabs are drawn selected, whether +// Home is on it, and the whole frame's text. +type tabShot struct { + drawn bool + selected []string + home bool + hits []tabHit + text string +} + +// shootTabs draws one frame and reads the strip the frame itself laid out. The +// strip's hit map is cleared first, so a frame that drew no strip reads as +// none rather than as the last strip some earlier frame drew — and it is put +// back as it was when no strip was drawn, because the surface itself never +// clears it and a press is answered against whatever it holds. +func shootTabs(a *app) tabShot { + held := a.chatTabHits + a.chatTabHits = nil + f, _, _ := a.frame() + shot := tabShot{text: plain(f), hits: append([]tabHit(nil), a.chatTabHits...)} + shot.drawn = len(shot.hits) > 0 + if !shot.drawn { + a.chatTabHits = held + } + for _, hit := range shot.hits { + switch hit.kind { + case tabHere: + shot.selected = append(shot.selected, hit.tab.word) + case tabHome: + shot.home = true + } + } + return shot +} + +// tabLab is a window in one conversation, "the run", with the strip as a +// person last saw it drawn. +type tabLab struct { + a *app + last []tabHit +} + +func (l *tabLab) shoot() tabShot { + shot := shootTabs(l.a) + if shot.drawn { + l.last = shot.hits + } + return shot +} + +// press clicks the strip where a person sees the piece `want` picks out: on the +// strip this frame drew, or, when the frame drew none, where the last drawn +// strip had it — which is where a person who just watched it vanish aims. +func (l *tabLab) press(t *testing.T, want func(tabHit) bool) { + t.Helper() + for _, hit := range l.last { + if want(hit) { + x := hit.span.from + (hit.span.to-hit.span.from)/2 + drive(t, l.a, tea.MouseClickMsg{X: x, Y: placeTabRow, Button: tea.MouseLeft}) + drive(t, l.a, tea.MouseReleaseMsg{X: x, Y: placeTabRow, Button: tea.MouseLeft}) + return + } + } + t.Fatalf("no such piece on the strip: %+v", l.last) +} + +// conversationTab is the conversation's own tab — selected while nothing is +// drawn over it, and a door back to it while something is. Every fixture here +// names the conversation "the run". +func conversationTab(hit tabHit) bool { + return !hit.tab.work && (hit.kind == tabHere || hit.kind == tabOther) && hit.tab.word == "the run" +} +func homeTab(hit tabHit) bool { return hit.kind == tabHome } +func workTabPiece(hit tabHit) bool { return hit.tab.work && hit.kind != tabClose } + +// escOut is `esc` the way a person leaves a room: once, and once more when the +// first one only handed the keyboard back from the side list, which is what an +// ordinary task's room opened by `enter` on its row asks for too. +func escOut(t *testing.T, a *app) { + t.Helper() + held := a.railHold + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) + if held { + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) + } +} + +// tabWaysOut are the three gestures that leave any page inside a conversation. +var tabWaysOut = []struct { + name string + leave func(t *testing.T, l *tabLab) + home bool +}{ + {"esc", func(t *testing.T, l *tabLab) { escOut(t, l.a) }, false}, + {"a press on the conversation's tab", func(t *testing.T, l *tabLab) { l.press(t, conversationTab) }, false}, + {"a press on Home", func(t *testing.T, l *tabLab) { l.press(t, homeTab) }, true}, +} + +// checkLeft asserts what the frame shows after a way out: Home drawn as Home, +// or the conversation under its own strip — and the page's words gone. +func checkLeft(t *testing.T, l *tabLab, way string, home bool, said string) { + t.Helper() + shot := l.shoot() + if strings.Contains(shot.text, said) { + t.Errorf("SYMPTOM: the page is still drawn after %s (page=%v railTaskPlanOn=%v workTabOn=%v)", + way, l.a.page, l.a.railTaskPlanOn, l.a.workTabOn) + } + if home { + if !l.a.at(pageHome) { + t.Errorf("the press on Home did not open Home (page=%v)", l.a.page) + } + if shot.drawn && !shot.home { + t.Errorf("SYMPTOM: the conversation's strip is still drawn and Home has left it:\n%s", firstRows(shot.text, 4)) + } + if l.a.tabRow != placeTabRow { + t.Errorf("SYMPTOM: Home is not what is drawn; the frame drew no place bar:\n%s", firstRows(shot.text, 4)) + } + return + } + if l.a.pageShowing() { + t.Errorf("%s landed on place %v, want the conversation", way, l.a.page) + } + if !shot.drawn || len(shot.selected) != 1 || !shot.home { + t.Errorf("SYMPTOM: after %s the strip is drawn=%v with selected=%q and home=%v, want the conversation's own strip:\n%s", + way, shot.drawn, shot.selected, shot.home, firstRows(shot.text, 4)) + } +} + +func firstRows(text string, n int) string { + rows := strings.Split(text, "\n") + if len(rows) > n { + rows = rows[:n] + } + return strings.Join(rows, "\n") +} + +// AN ORDINARY TASK IS THE CONTRAST, and it holds: its room is drawn under the +// conversation's strip with one selected tab and Home, and `esc`, the tab press +// and Home all leave it. +func TestAnOrdinaryTasksRoomKeepsTheConversationsTab(t *testing.T) { + for _, way := range tabWaysOut { + t.Run(way.name, func(t *testing.T) { + a, _ := railTaskPageApp(t, false) + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + l := &tabLab{a: a} + l.shoot() + clickRail(t, a, 0) + if !a.roomOpen() { + t.Fatal("the ordinary task did not open its room") + } + if shot := l.shoot(); !shot.drawn || len(shot.selected) != 1 || !shot.home { + t.Fatalf("an ordinary room's strip: drawn=%v selected=%q home=%v", shot.drawn, shot.selected, shot.home) + } + way.leave(t, l) + if !way.home && a.roomOpen() { + t.Fatalf("%s did not leave the room", way.name) + } + checkLeft(t, l, way.name, way.home, "esc/← main") + }) + } +} + +// ── THE PAGES THE BELT SWITCH STILL DRAWS OVER THE CONVERSATION ───────────── + +// beltTabLab is [workTabFixture] under the strip a person sees. +func beltTabLab(t *testing.T) *tabLab { + t.Helper() + a, _ := workTabFixture(t) + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + l := &tabLab{a: a} + l.shoot() + l.shoot() + return l +} + +// THE WORK TAB IS THE ONE SELECTED TAB WHILE IT IS UP, and the strip leaves it: +// the conversation's own tab goes back to the conversation and Home goes Home. +// It drew both tabs selected, a press on the conversation's tab did nothing, +// and Home opened under the work tab, which then drew the strip without Home. +func TestTheWorkTabIsTheOneSelectedTabAndTheStripLeavesIt(t *testing.T) { + l := beltTabLab(t) + l.press(t, workTabPiece) + if !l.a.workTabOn { + t.Fatal("the work tab did not open") + } + if shot := l.shoot(); len(shot.selected) != 1 || !shot.home { + t.Errorf("SYMPTOM: with the work tab up the strip draws selected=%q home=%v, want one selected tab and Home", shot.selected, shot.home) + } + for _, way := range tabWaysOut { + t.Run(way.name, func(t *testing.T) { + l := beltTabLab(t) + l.press(t, workTabPiece) + l.shoot() + way.leave(t, l) + checkLeft(t, l, way.name, way.home, taskPlanNoteWord) + }) + } +} + +// LEAVING IS NEVER MODAL (input.go's first rung), and the work tab was read +// above that law: ctrl+c on it was handed to the page, which took nothing. +func TestCtrlCIsNeverTakenByTheWorkTab(t *testing.T) { + l := beltTabLab(t) + l.press(t, workTabPiece) + if !l.a.workTabOn { + t.Fatal("the work tab did not open") + } + control := beltTabLab(t) + want := control.a.key(key("ctrl+c")) != nil + if got := l.a.key(key("ctrl+c")) != nil; got != want { + t.Errorf("SYMPTOM: ctrl+c on the work tab answered a command=%v, on the conversation %v", got, want) + } +} + +// A PRESS NEVER REACHES WHAT A TASK'S PAGE COVERS. A run's part opened from the +// rail took the whole frame and drew no strip, but the pointer was still +// answered against the strip the conversation last drew: a press where that +// strip's ✕ had been closed the conversation's tab and moved the window to +// Home, under a page that went on covering both. +func TestAPressNeverReachesTheStripATaskPageCovers(t *testing.T) { + a, _ := railTaskPageApp(t, true) + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + l := &tabLab{a: a} + l.shoot() + clickRail(t, a, 0) + if !a.railTaskPlanOn { + t.Fatal("the run's row did not open its page") + } + if shot := l.shoot(); shot.drawn { + t.Skip("the page draws the strip, so a press on it is a press on something drawn") + } + l.press(t, func(hit tabHit) bool { return hit.kind == tabClose && !hit.tab.work }) + if len(a.tabShut) != 0 || a.at(pageHome) || a.closingTab() { + t.Errorf("SYMPTOM: a press on a strip nobody can see closed tabs %v, moved to place %v, raised the close card %v", + a.tabShut, a.page, a.closingTab()) + } +} + +// AND THE WHEEL SCROLLS THE PAGE THAT IS DRAWN. Turned over a run's page opened +// from the side list, it was answered by the side list, the tab names or the +// transcript under the page; it moves the page now, which lets go of the +// page's live edge the way a scroll up always does. +func TestTheWheelScrollsARunsPageAndNothingUnderIt(t *testing.T) { + a, _ := railTaskPageApp(t, true) + a.width, a.height = 160, 40 + clickRail(t, a, 0) + if !a.railTaskPlanOn || !a.taskSheet.planStick { + t.Fatalf("the run's page did not open at its live edge: page=%v stick=%v", a.railTaskPlanOn, a.taskSheet.planStick) + } + offset, railTop := a.offset, a.railTop + drive(t, a, tea.MouseWheelMsg{X: a.width - 2, Y: 10, Button: tea.MouseWheelUp}) + if a.taskSheet.planStick { + t.Error("the wheel over the page did not scroll it") + } + if a.offset != offset || a.railTop != railTop { + t.Errorf("the wheel over the page moved what it covers: offset %d→%d, rail %d→%d", offset, a.offset, railTop, a.railTop) + } +} + +// ONE `esc` LEAVES A RUN'S PAGE OPENED BY `enter` ON ITS ROW. The side list kept +// the keyboard it was handed, under a page that covers it, so the first `esc` +// was spent giving back a keyboard nobody could see and the page stayed up. +func TestOneEscLeavesARunsPageOpenedByEnterOnItsRow(t *testing.T) { + a, _ := railTaskPageApp(t, true) + a.railWhere, a.railHold = railSpot{id: 2}, true + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !a.railTaskPlanOn { + t.Fatal("enter on the run's row did not open its page") + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) + if a.railTaskPlanOn || a.taskSheet.planOn { + t.Fatalf("one esc left the run's page up (railHold=%v)", a.railHold) + } +} diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 12e408d1d..bb7f37307 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -2090,6 +2090,9 @@ func (a *app) finishRailPlan(id string) tea.Cmd { keys := a.railPlanPending.keys a.railPlanPending = railPlanPending{} a.railTaskPlanOn = true + // THE SIDE LIST GIVES THE KEYBOARD BACK, because the page covers it: a list + // holding keys nobody can see would spend the page's first `esc` on itself. + a.railHold = false var cmds []tea.Cmd for _, key := range keys { // A KEY THAT LEFT THE PAGE ENDS THE REPLAY. The keys were kept for the diff --git a/internal/tui3/view.go b/internal/tui3/view.go index c93551424..9c03b84bd 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -282,6 +282,15 @@ func (a *app) frameBody() (string, int, int) { // nothing to type into (home at rest). Set here so every path below starts // from the same answer and only the ones that hide it say so. a.caret = true + // AND THERE IS NO TAB BAR UNTIL A FRAME DRAWS ONE. Every place goes through + // [placeFrame], which records the row it put the bar on; the frames that do + // not — home's phone inbox and sheet, the task record card — draw something + // else in those cells entirely, and a press resolved against the last bar + // this window happened to paint would open a place for a click on a rule + // (placemouse.go's [app.placeTabPress]). IT IS SAID ABOVE THE TWO TASK PAGES + // THE BELT SWITCH DRAWS OVER THE CONVERSATION as well, because neither draws + // the place bar either. + a.tabRow = -1 if a.railTaskPlanOn { lines, caretX, caretY := a.taskPlanFrame(width, height) return strings.Join(lines, "\n"), caretX, caretY @@ -290,13 +299,6 @@ func (a *app) frameBody() (string, int, int) { lines := a.workTabFrame(width, height) return strings.Join(lines, "\n"), 2, max(len(lines)-1, 0) } - // AND THERE IS NO TAB BAR UNTIL A FRAME DRAWS ONE. Every place goes through - // [placeFrame], which records the row it put the bar on; the frames that do - // not — home's phone inbox and sheet, the task record card — draw something - // else in those cells entirely, and a press resolved against the last bar - // this window happened to paint would open a place for a click on a rule - // (placemouse.go's [app.placeTabPress]). - a.tabRow = -1 // THE FIRST-RUN SETUP IS DECIDED BEFORE EVERY OTHER FULLSCREEN SURFACE, // because it is the one that may be open before any of them exists and it // goes away to reveal whichever of them was decided underneath (firstrun.go). diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 6bfd72dae..909ca2ecc 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -155,3 +155,22 @@ func (a *app) workTabFrame(width, height int) []string { } return append(out, prompt+a.pal.dim(text)) } + +// leaveTaskOverlays stands down the two task pages the belt switch still draws +// over the conversation — the work tab, and a run's page opened from the side +// list — and is a no-op when neither is up. +// +// EVERY DOOR OUT OF THE CONVERSATION'S FRAME CALLS IT, because both pages are +// drawn before any place is (view.go's [app.frameBody]): a place opened under +// one of them was a place nobody could see, and Home opened that way dropped +// off the strip the work tab went on drawing. Every place opens through +// [app.standDownRest], and the conversation's own tab calls it on the way back +// ([app.tabGo]). +func (a *app) leaveTaskOverlays() { + if !a.workTabOn && !a.railTaskPlanOn { + return + } + a.workTabOn, a.railTaskPlanOn = false, false + a.closeTaskPlan() + a.chatTabBar = tabBar{} +} From c994ffbbcc3d36fc5c69bfa5cfa4a26209fa28b1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:16:22 -0400 Subject: [PATCH 087/195] tui3: a program's task is a room in its conversation's tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit senior-dev's task opened as a full-frame page drawn over the conversation: the side list, the card, a task link, the task strip, the home panel, the sessions place and a reopened room all led to the tasks place's page with no tab strip, and the strip offered every live program run a work tab of its own that drew itself selected beside the conversation's and that the conversation's tab never left. The held-row check behind the doors also compared the store's `t-7` with a bare `7`, so no real row ever matched. A program's task is now a room (taskRoom.program, modelled on the run page's taskRoom.orch) opened by every door: the conversation's strip stays over it with the conversation's tab the one selected tab and Home beside it, esc, that tab and Home leave it, its body is the program's conversation with codeaf, and its facts row pins the stage, the spend of the ceiling, the calls and the age read off the rail's own clock for the node. It follows the store on the plan page's beat while the run works, reads the landing once, and stops. x, /stop and the facts row's Stop raise the card aimed at the run's own task through the store's door, as the stored page's x did. The box sends nothing: its placeholder and enter both say "senior-dev reads no messages — say it to main" and keep the words in the box. A program's run has no work tab, and the program branches of the work tab are gone. The stored page in the tasks place is unchanged, and when it names a program its pinned age reads the same clock as the room. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 5 +- internal/manual/chat/home.md | 6 +- internal/manual/chat/senior-dev.md | 19 ++ internal/manual/chat/worker-harness.md | 86 ++++-- internal/tui3/app.go | 13 +- internal/tui3/programroom.go | 384 +++++++++++++++++++++++++ internal/tui3/programroom_test.go | 281 ++++++++++++++++++ internal/tui3/programtab_test.go | 144 ++++++++++ internal/tui3/room.go | 147 ++++++---- internal/tui3/roompanel.go | 25 +- internal/tui3/stop.go | 7 + internal/tui3/taskconversation.go | 61 +++- internal/tui3/taskconversation_test.go | 158 +++++----- internal/tui3/taskplan.go | 22 +- internal/tui3/worktab.go | 39 +-- 15 files changed, 1184 insertions(+), 213 deletions(-) create mode 100644 internal/tui3/programroom.go create mode 100644 internal/tui3/programroom_test.go create mode 100644 internal/tui3/programtab_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 54a4a892e..33dc785d5 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -17,8 +17,9 @@ none of them runs on its own outside codeaf. Each is a command in the chat, `/<n 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 shown as one turn of a conversation on the -run's task page. When your services cannot serve the model the program asks for, the -run's own work model answers, and the page names the model that did. +run's task page, which opens inside the conversation's own tab like any task's. When your +services cannot serve the model the program asks for, the run's own work model answers, +and the page names the model that did. 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. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 2d68f91af..d9f3f08b8 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2369,11 +2369,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 — a run's tab +## Does a run create another conversation in Sessions or the chats menu — a run's tab, senior-dev's task has no tab No. Home’s Sessions list and the chats menu keep one row for the conversation, using its conversation title. -A run 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 `Home`, or `esc` leaves it. +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 `Home`, 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 `Home` leave it. ## Why does a closed conversation say another window diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 3890269bc..af36c3347 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -18,6 +18,25 @@ Use it for one change big enough to want an agent of its own for an hour, and sp well enough that nobody will be asked anything: a rewrite across a package, a migration, a feature with its tests. A change you would make in a few steps is not worth it. +## Watching senior-dev work — open its task, its conversation with codeaf, 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 with the stage it is in and what it has spent so far, 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 `Home` beside it. senior-dev gets no tab of its own. + +The task shows senior-dev's conversation with codeaf: its brief, each model call with +what senior-dev sent and what the model answered, and the call in flight. The line over +it pins the stage, the spend of the run's ceiling, the number of 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. + +`esc`, a press on the conversation's tab, or a press on `Home` 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. + ## 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 diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index c9f91e8ce..53d1d1793 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -102,8 +102,9 @@ With the switch on, a run is drawn in the conversation's side list as its own ro door: click the run's row, or select it and press `enter`, and its page opens over the conversation; click a part's row or a check's row and THAT task's page opens. The page is the one the tasks place opens: what the task was asked, its notes, its steps, and the -box that leaves a note — except a program's page, which is its conversation and has no -box (see *A program's task page is a conversation, not steps*). +box that leaves a note. A task handed to a program such as senior-dev is different: it +opens inside the conversation's own tab, as a task does (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, and stops nothing. The page takes the whole window: while it is up it @@ -120,9 +121,10 @@ every three seconds, so a new step shows within that, and it stops reading when has settled. A page 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? @@ -196,17 +198,17 @@ steps When the command ends the store clears the live step and the next read draws it as an ordinary step, with its number and the head of what came back. -## A program's task page is a conversation, not steps — a delegate's page: what the program sent, what the model answered, the call in flight, no note box +## A program's task page is a conversation, 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 (`/<name> <brief>`) has its own page. Every model -call the program makes goes through codeaf, so the page is that conversation: the program on one -side, like a very particular person asking codeaf things, and the model that answered on the -other. +A task handed to a program codeaf carries (`/<name> <brief>`, 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 `Home` beside +it, and the program gets no tab of its own. ``` - rewrite the auth middleware - implement · $1.24 · 3 calls · 14m 3s - ─────────────────────────────────── + the run ▸ rewrite the auth middleware esc/← main +─ implement · $1.24 of $5.00 · 3 calls · 14m 3s ──────────────────── Stop ─ <program> rewrite the auth middleware to use the new session store deepseek-v4-flash I'll read the middleware and the store first. ▤ read internal/auth/middleware.go @@ -214,23 +216,47 @@ other. ◐ deepseek-v4-flash · 12s ``` -The line under the title stays put while you scroll: the stage the program says it is in (the -task's own word, such as `running` or `done`, when there is none), what the run has spent so -far, how many model calls it has made, and how long it has been going — from the moment you -handed it off to the moment the program's process ended, the same span its row and its card -show. A figure with nothing behind it is left out. A run nothing is driving any more, because -codeaf closed while the program worked, reads `incomplete` with its time stopped at the last -thing it did. The conversation opens on the brief. Each call is the program's side — a -tool's result as `<tool>: <first line>`, 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 +`esc`, a press on the conversation's tab and a press on `Home` leave it; none of them +stops the run. `ctrl+o` opens and folds a long brief. `x` over an empty box, `/stop`, or +`Stop` at the end of the line over the conversation asks `Stop this task?` and ends the +whole run. + +**The box sends nothing.** A program reads no message. The box says `<program> 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 conversation as a page +of that place, with no box at all. + +## Reading a program's conversation — what the program sent, what the model answered, the call in flight, how long it has run + +Every model call a program makes goes through codeaf, so its task's page is that +conversation: the program on one side, like a very particular person asking codeaf +things, and the model that answered on the other. + +The line over the conversation stays put while you scroll: the stage the program says it +is in (the task's own word, such as `running` or `done`, when there is none), 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 is the one the side list and the landed card +show for the run: it counts from the moment codeaf handed the work over, and once the run +has ended it is the whole span, up to the moment the program's own process ended. A run +nothing is driving any more, because codeaf closed while the program worked, reads +`incomplete` with its time stopped at the last thing it did. On a tall window with the side list open, the line sits +beside the task's title instead. + +The conversation opens on the brief. Each call is the program's side — a tool's result as +`<tool>: <first line>`, 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 · <why>`; a failed one is `the call failed · <why>`. The call in flight is the last -line, `◐`, the model and its seconds, gone when the call returns. +`refused · <why>`; a failed one is `the call failed · <why>`. The call in flight is the +last line, `◐`, the model and its seconds, gone when the call returns. The page reads the +store again every three seconds while the run works, and once more when it ends. -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. -The page has no note box: a program reads no note, so nothing typed there would reach it. While -the run goes, `x` stops it. On the side list the run's row says the stage and the spend so far. +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. On +the side list the run's row says the stage and the spend so far. ## Why is a step missing, the step numbers skip, the cd at the front of a command is gone @@ -348,7 +374,9 @@ stop 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 own page and press `x stop it` there. Both raise the same card, `Stop this task?`, with `stop it` and `keep going`; the page steps aside so the card -is drawn in the conversation. A digit moves the choice, `enter` takes it, and `esc` is +is drawn in the conversation. A task handed to a program opens in the conversation's +tab rather than over it, so there `x` over an empty box, `/stop`, or `Stop` at the end +of the line over its conversation raises the card above the box without leaving it. 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/tui3/app.go b/internal/tui3/app.go index b1f4ad07e..e1226e982 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -4949,8 +4949,10 @@ func (a *app) paint() tea.Cmd { // AND THE PLAN PAGE'S OWN READING IS TAKEN ON THE SAME CLOCK, for the same // reason: a page left open on a running task follows its newest step // ([app.taskPlanFollow]), and a page on a settled task is not read at all — - // the clock stops with the task, one row down. - kick = tea.Batch(kick, a.taskPlanFollow()) + // the clock stops with the task, one row down. A program's room reads its + // stored page on the same beat and under the same law + // ([app.programRoomFollow]). + kick = tea.Batch(kick, a.taskPlanFollow(), a.programRoomFollow()) // A TOOL THAT HAS JUST ENDED IS ASKED ABOUT ON THIS FRAME, not at the next // tenth ([app.usageOwed]) — the ask alone, because nothing else on this // beat has moved with it. ONLY WHILE THE WORK IS STILL RUNNING: the ask is @@ -5138,7 +5140,12 @@ func (a *app) paint() tea.Cmd { // fourth that can be the whole of what is happening: the page follows a // live edge the store writes from another process, and no turn of ours // runs while it moves (taskplan.go's [app.taskPlanFollow]). - a.taskPlanRunning() + a.taskPlanRunning() || + // AND A PROGRAM'S ROOM ON WORK THAT CAN STILL MOVE IS THE EIGHTEENTH, for + // the plan page's reason exactly: senior-dev writes its conversation from + // another process, and the room's age ticks on this clock + // (programroom.go's [app.programRoomFollow]). + a.programRoomFollows() // THE WAIT ON THE MODEL is the only term that can hold this clock while // the screen shows nothing but the spinner and the ellipsis, and a spinner // glyph only changes every spinnerStep-th paint (styles.go). A wait whose diff --git a/internal/tui3/programroom.go b/internal/tui3/programroom.go new file mode 100644 index 000000000..ca2dabb4c --- /dev/null +++ b/internal/tui3/programroom.go @@ -0,0 +1,384 @@ +package tui3 + +// programroom.go opens a PROGRAM'S TASK the way every other task opens: as a +// room inside the conversation's own tab. +// +// A task handed to a program codeaf carries (senior-dev) has no worker +// transcript. What the program did is its conversation with codeaf, on the +// task's stored page ([session.PlanTaskPage.Program]), and taskconversation.go +// draws it. That page used to be drawn by the tasks place's machinery OVER the +// conversation — a full frame with no tab strip, reached from the side list, +// the card, a task link, the task strip, the home panel and the sessions place +// — and the strip's hit map under it kept answering presses nobody could see; +// the strip also offered the run a tab of its own that drew itself selected +// beside the conversation's, which a press on the conversation's tab never +// left, and Home opened underneath it and vanished from the strip. The owner +// met all of it on the first senior-dev run of 2026-09-24. +// +// SO IT IS A [taskRoom] NOW, the way an adaptive run's graph is (roomorch.go): +// the conversation's own strip stays over it with the conversation's tab the +// one selected tab, the trail and the rail stay beside it, and `esc`, a press +// on the conversation's tab and a press on Home leave it exactly as they leave +// any room. What fills the body is the program's conversation, the facts row +// is the line the stored page pins under its title, and `x` stops the run +// through the store's own door, as the stored page's `x` does. +// +// THE BOX SENDS NOTHING. A program reads no message — nothing a person types +// reaches senior-dev once it is running — so the placeholder says so, and +// enter over a sentence says it again on the page and keeps the sentence in the +// box, where the refusal's door (the conversation) can still take it. + +import ( + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// programRoom is what a program's room holds instead of a lane: the stored page +// as last read, when it was read and whether a read is out, the room's own fold +// of the brief, the width its body was last laid out at (which `ctrl+o` +// measures the brief against), and the lines the page itself has said. +type programRoom struct { + page session.PlanTaskPage + readAt time.Time + reading bool + briefFull bool + inner int + said []string +} + +// programRoomRefusal is what a program's room says about its box: the fact, and +// the one place the words can still go ([refusal]'s law). The fact names the +// program when the page knows its name ([app.programRoomRefusal]). +var programRoomRefusal = refusal{ + what: "this task's program reads no messages", + shortWhat: "reads no messages", + door: refusalMainDoor, +} + +// programRoomNoMessages is the fact's tail after the program's own name. +const programRoomNoMessages = " reads no messages" + +// programOf is the open room's program, and nil on every other page. +func (a *app) programOf() *programRoom { + if a.room == nil { + return nil + } + return a.room.program +} + +// programTask reports whether the surface holds this conversation's task as a +// program's run: its held row names the program. It reads only what is held, +// never the store, because it is asked on the loop at a key or a click. +func (a *app) programTask(id uint64) bool { + row, ok := a.heldProgramRow(id) + return ok && strings.TrimSpace(row.Program) != "" +} + +// heldProgramRow is the row the surface holds for one of this conversation's +// tasks, if it holds one. +func (a *app) heldProgramRow(id uint64) (session.PlanTaskRow, bool) { + rows, ok := a.heldPlanRows() + if !ok { + return session.PlanTaskRow{}, false + } + want := strconv.FormatUint(id, 10) + for _, row := range rows { + if planTaskIDWord(row.ID) == want { + return row, true + } + } + return session.PlanTaskRow{}, false +} + +// planTaskIDWord is a store id in the spelling a node's number has: the store +// answers `7` or `t-7` for the run rooted at task 7. +func planTaskIDWord(id string) string { + return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(id), "t-")) +} + +// heldProgramPage is the page a door can open on before the store answers: the +// row the surface holds for the task, and nothing else yet. +func (a *app) heldProgramPage(id uint64) session.PlanTaskPage { + row, _ := a.heldProgramRow(id) + return session.PlanTaskPage{Row: row} +} + +// programRowNode is the node of this conversation a program's store row is +// about, or nil for a row that is not a program's or not this conversation's. +// A program's run is always its store's root, and the root is rooted at the +// task's own number (session's startKnownTaskRun), so the id is the node's. +func (a *app) programRowNode(row session.PlanTaskRow) *taskNode { + if strings.TrimSpace(row.Program) == "" { + return nil + } + n, err := strconv.ParseUint(planTaskIDWord(row.ID), 10, 64) + if err != nil || n == 0 { + return nil + } + return a.tasks[n] +} + +// openProgramRoom opens the program's room on the page the caller holds — the +// store's page when a gesture already read it, the held row alone otherwise — +// and asks the store for the whole page off the loop. +// +// IT NEEDS NO ROOM DOORS. There is no lane to subscribe to and no journal to +// read, so the room opens on a hosted conversation exactly as on a local one: +// the one door it reads through, [session.Agent.PlanTaskPage], is on the wire. +func (a *app) openProgramRoom(id uint64, title string, page session.PlanTaskPage) { + if a.startingChat() { + a.parkChatStart() + } + room := a.newRoom(id, firstNonEmpty(title, page.Row.Title, taskIDWord(id))) + room.program = &programRoom{page: page} + a.room = room + room.done = a.programRoomDone() + // AND THE BOX POINTS AT THIS TASK, as every room's does (recipient.go): what + // was being written for the conversation is stashed under its own reader and + // comes back with the conversation. Nothing typed here is sent anywhere. + a.retargetComposer(taskRecipient(id)) + // The rail's clock stops being reported while the person is looking into + // the work, as it does for every room (task.go's [app.taskNow]). + a.freezeNode(id) + a.sel = -1 + a.dropHover() + a.touch() + a.roomPump = tea.Batch(a.programRoomRead(), a.wake()) +} + +// programRoomDone is whether the open program room's work is over: by the +// conversation's own row when it holds one, and by the stored page's state +// otherwise, and by either one when both are known. A room whose node this +// window never saw is not taken for finished on that absence alone +// ([roomRowDone] answers true for no node at all). +func (a *app) programRoomDone() bool { + p := a.programOf() + if p == nil { + return false + } + if node := a.roomNode(); node != nil && roomRowDone(node) { + return true + } + return planEnded(p.page.Row) +} + +// programRoomRead re-reads the open program room's page off the loop. The +// answer lands only on the room that asked. +func (a *app) programRoomRead() tea.Cmd { + agent, ok := a.planReader() + room := a.room + if !ok || room == nil || room.program == nil || room.program.reading || room.id == 0 { + return nil + } + id, gen, p := strconv.FormatUint(room.id, 10), room.gen, room.program + p.reading = true + p.readAt = a.now() + return a.offLoop(func() func(bool) tea.Cmd { + page, found := agent.PlanTaskPage(id) + return func(here bool) tea.Cmd { + p.reading = false + if !here || !found || a.room != room || room.gen != gen { + return nil + } + p.page = page + if room.title == "" || room.title == taskIDWord(room.id) { + room.title = firstNonEmpty(page.Row.Title, room.title) + } + room.done = a.programRoomDone() + room.dirty = true + a.touch() + return nil + } + }) +} + +// programRoomFollows reports whether the open program room is on work that can +// still move, which is when the paint clock keeps turning for it: the room's +// age ticks and its page is read on a beat. +func (a *app) programRoomFollows() bool { + p := a.programOf() + if p == nil || a.room.done { + return false + } + switch planStateWord(p.page.Row) { + case "queued", "running": + return true + } + // A page whose read has not come back yet names no state, and a node that is + // still running is work that can still move. + node := a.roomNode() + return node != nil && !roomRowDone(node) +} + +// programRoomFollow is the paint clock's read, on the stored page's own beat +// ([app.taskPlanFollow], [elsewhereEvery]). +// +// THE LANDING IS READ ONCE MORE. The conversation's row settles on a notice, +// and the page it closes over is the page as it was a beat ago — without the +// notes the run left or the state its store ended on. So the moment the room +// learns the work is over it reads the page one last time, and after that +// never again. +func (a *app) programRoomFollow() tea.Cmd { + p := a.programOf() + if p == nil || p.reading { + return nil + } + if done := a.programRoomDone(); done != a.room.done { + a.room.done = done + a.room.dirty = true + if done { + return a.programRoomRead() + } + } + if !a.programRoomFollows() || a.now().Sub(p.readAt) < elsewhereEvery { + return nil + } + return a.programRoomRead() +} + +// programRoomRows is the room's body: the program's conversation, the notes +// the run left, what the page itself has said, and the foot a landed task's +// room draws — laid out inside the reading gutter the conversation keeps. +func (a *app) programRoomRows(width int) []row { + p := a.programOf() + if p == nil { + return nil + } + inner := gutterInner(width) + p.inner = inner + pal := a.pal + var out []row + for _, line := range a.programBody(p.page, inner, p.briefFull) { + out = append(out, row{text: line, entry: -1}) + } + if len(p.said) > 0 { + out = append(out, row{entry: -1}) + for _, said := range p.said { + for _, line := range railWrap(said, inner) { + out = append(out, row{text: pal.dim(line), entry: -1}) + } + } + } + if a.room.done && !a.roomLandingAsking() { + if len(out) > 0 { + out = append(out, row{entry: -1}) + } + out = append(out, row{text: pal.dim(a.roomDoneRefusal().fit(inner)), entry: -1}) + } + gutterPass(out, width) + a.hoverPass(out, width) + return out +} + +// programSay puts one line on the program's page, below its conversation. It +// is the room's own note ([app.roomNote]) for a page whose body is not a +// transcript, and like it a line identical to the one before it is not said +// twice. +func (p *programRoom) programSay(text string) { + if n := len(p.said); n > 0 && p.said[n-1] == text { + return + } + p.said = append(p.said, text) +} + +// programRoomRefusal is [programRoomRefusal] with the program named, when the +// page knows what to call it. +func (a *app) programRoomRefusal() refusal { + out := programRoomRefusal + if p := a.programOf(); p != nil { + if name := convProgramName(p.page); name != "" && name != convProgramFallback { + out.what = name + programRoomNoMessages + } + } + return out +} + +// programFactsWord is the room's facts row on a program's page: the line the +// stored page pins under its title — the stage, the spend of the ceiling, the +// calls, the age — with the age read off the node the room stands on, the +// clock the rail and the landed card read. It answers how many of its leading +// cells are the lead word, which the row paints in the node's own ink. +func (a *app) programFactsWord(width int) (string, int) { + p := a.programOf() + if p == nil { + return "", 0 + } + line := strings.TrimSpace(a.programPinned(p.page, width, a.programRoomClock())) + lead, _, _ := strings.Cut(line, rowSep) + if a.roomNode() == nil { + return line, 0 + } + return line, ansi.StringWidth(lead) +} + +// programRoomClock is the age the program room's facts row draws: the node's +// clock when this conversation holds one for it, and the stored page's own +// stamps otherwise. +func (a *app) programRoomClock() string { + if word, ok := a.nodeClock(a.roomNode()); ok { + return word + } + if p := a.programOf(); p != nil { + return a.taskPlanAge(p.page.Row) + } + return "" +} + +// programStopTarget is what `x`, `/stop` and the room's Stop end on a +// program's room: the run's own task, through the store's own door +// ([session.Agent.PlanCancel]) — the target the stored page's `x` has always +// raised ([app.taskPlanStop]), which ends a live run and a run whose process +// is already gone alike. Only work that can still stop is offered. +func (a *app) programStopTarget() stopTarget { + p := a.programOf() + if p == nil || a.room.done { + return stopTarget{} + } + if _, ok := a.planReader(); !ok { + return stopTarget{} + } + row := p.page.Row + if strings.TrimSpace(row.ID) == "" { + // The page has not been read and the surface holds no row for it: the + // store's id is the task's own number, as it is for every program's run. + row.ID = strconv.FormatUint(a.room.id, 10) + } + if planEnded(row) { + return stopTarget{} + } + return stopTarget{plan: row.ID, noun: stopTaskNoun, detail: stopTaskDetail} +} + +// programRoomKey is what a program's room takes before the room's own keys: +// `ctrl+o` folds and unfolds the brief when it is long enough to fold, and the +// thinking chord is taken and does nothing, because a program's run has no +// thinking level this surface can move. Everything else is the room's. +func (a *app) programRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + p := a.programOf() + if p == nil { + return nil, false + } + switch msg.String() { + case "ctrl+o": + width := p.inner + if width <= 0 { + width = gutterInner(a.bodyWidth()) + } + if !convBriefFolds(p.page, width) { + return nil, false + } + p.briefFull = !p.briefFull + a.room.dirty = true + a.touch() + return nil, true + case effortKey: + return nil, true + } + return nil, false +} diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go new file mode 100644 index 000000000..5dcb9540e --- /dev/null +++ b/internal/tui3/programroom_test.go @@ -0,0 +1,281 @@ +package tui3 + +// A program's room (programroom.go), driven through the surface's own loop: the +// box that sends nothing and says so, the brief's fold, the page that follows +// the run on the paint clock and stops when it settles, the stop that goes +// through the store's own door, the room at a phone's width, and the one clock +// the room, the rail and the landed card all read for one run. + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// programRoomAgent is the plan fixture with the room doors on it and a count of +// every page read and every line sent to a node, so a test can say what a +// gesture asked of the engine. +type programRoomAgent struct { + *railPlanCounter + steered []string +} + +func (f *programRoomAgent) SteerTask(_ uint64, text string) (session.SteerReceipt, error) { + f.steered = append(f.steered, text) + return session.SteerReceipt{}, nil +} + +// programRoomApp is a window in the conversation "the run" whose task 7 was +// handed to senior-dev. The rows are spelled the way the store spells them +// (`t-7`, session's planStoreID); the page is keyed by the number the room +// reads it by. The rail's row for the node started at [programRunBegan]. +func programRoomApp(t *testing.T, width, height int) (*app, *programRoomAgent) { + t.Helper() + row := programRow() + page := programPage(row, programTurns()) + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{"7": page}) + agent := &programRoomAgent{railPlanCounter: &railPlanCounter{planFake: fake}} + a.agent = agent + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = width, height + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, session.TaskRunning, session.TaskNotice{StartedAt: programRunBegan})}) + readPlanRows(t, a) + return a, agent +} + +// openProgramRoomNow opens task 7's room by the card's door and lets its first +// read come back. +func openProgramRoomNow(t *testing.T, a *app) { + t.Helper() + a.openRoomFor(7, "rewrite the auth middleware") + drain(t, a, a.takeRoomPump()) + if a.programOf() == nil { + t.Fatalf("the card did not open the program's room: room=%v", a.room != nil) + } +} + +// THE BOX SENDS NOTHING AND SAYS SO. A program reads no message: the +// placeholder says it with the door the words can go through, and enter over a +// sentence sends it nowhere — not to the node, not to the store as a note — +// says the same line on the page, and leaves the sentence in the box. +func TestAProgramsRoomSendsNothingAndSaysSo(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + said := "senior-dev" + programRoomNoMessages + refusalGap + refusalMainDoor + if frame, _, _ := a.frame(); !strings.Contains(plain(frame), said) { + t.Fatalf("the empty box does not say %q:\n%s", said, plain(frame)) + } + for _, r := range "pause it" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(agent.steered) != 0 || len(agent.noted) != 0 { + t.Fatalf("enter on a program's room sent %v to the node and %v to the store", agent.steered, agent.noted) + } + if got := a.input.String(); got != "pause it" { + t.Fatalf("enter took the sentence out of the box: %q", got) + } + if !strings.Contains(roomText(a), said) { + t.Fatalf("enter did not say %q on the page:\n%s", said, roomText(a)) + } + // AND A SECOND ENTER DOES NOT SAY IT TWICE. + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if n := strings.Count(roomText(a), said); n != 1 { + t.Fatalf("the page says the refusal %d times", n) + } +} + +// A LONG BRIEF FOLDS, AND ctrl+o OPENS AND CLOSES IT, in the room exactly as on +// the stored page: the fold line names how many lines and the key. +func TestCtrlOFoldsAProgramRoomsBrief(t *testing.T) { + a, agent := programRoomApp(t, 120, 40) + page := agent.planFake.pages["7"] + page.Description = strings.Repeat("the store interface changes and every caller moves with it. ", 12) + agent.planFake.pages["7"] = page + openProgramRoomNow(t, a) + fold := briefFoldWhat + railSep + briefFoldKey + if !strings.Contains(roomText(a), fold) { + t.Fatalf("a long brief is not folded with its key:\n%s", roomText(a)) + } + drive(t, a, key("ctrl+o")) + if strings.Contains(roomText(a), fold) { + t.Fatalf("ctrl+o did not unfold the brief:\n%s", roomText(a)) + } + drive(t, a, key("ctrl+o")) + if !strings.Contains(roomText(a), fold) { + t.Fatalf("a second ctrl+o did not fold the brief again:\n%s", roomText(a)) + } +} + +// THE ROOM FOLLOWS THE RUN ON THE PAINT CLOCK AND STOPS WHEN IT SETTLES. While +// the run works, the page is read once a beat and no more; the landing is read +// once, so the room ends on the page the store ended on; after that no beat +// reads it again, and the clock that carried the reads stops turning for it. +func TestAProgramRoomFollowsWhileRunningAndStopsAfterItSettles(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + if !a.programRoomFollows() { + t.Fatal("a room on a running program is not on the paint clock") + } + reads := agent.railPlanCounter.pages + for range 5 { + drive(t, a, frameMsg{}) + } + if agent.railPlanCounter.pages != reads { + t.Fatalf("frames inside one beat read the page %d times", agent.railPlanCounter.pages-reads) + } + for beat := 1; beat <= 3; beat++ { + planBeat(t, a) + if got := agent.railPlanCounter.pages - reads; got != beat { + t.Fatalf("after %d beats the page was read %d times, want once a beat", beat, got) + } + } + // THE RUN LANDS: the store ends its root and the conversation's row settles. + page := agent.planFake.pages["7"] + page.Row.Status, page.Row.Ended = "done", a.now() + page.Notes = []session.PlanTaskNote{{Body: "the work landed on branch senior-dev/auth"}} + agent.planFake.pages["7"] = page + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, page.Row.Title, session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: a.now()})}) + before := agent.railPlanCounter.pages + drive(t, a, frameMsg{}) + if agent.railPlanCounter.pages != before+1 { + t.Fatalf("the landing was read %d times, want once", agent.railPlanCounter.pages-before) + } + if !strings.Contains(roomText(a), "the work landed on branch senior-dev/auth") { + t.Fatalf("the room did not end on the page the store ended on:\n%s", roomText(a)) + } + if !strings.Contains(roomText(a), roomFinishedRefusal.what) { + t.Fatalf("a landed program's room draws no foot:\n%s", roomText(a)) + } + if a.programRoomFollows() { + t.Fatal("a settled program's room keeps the paint clock turning") + } + settled := agent.railPlanCounter.pages + for range 3 { + planBeat(t, a) + } + if agent.railPlanCounter.pages != settled { + t.Fatalf("a settled program's room was read %d more times", agent.railPlanCounter.pages-settled) + } +} + +// STOP ON A PROGRAM'S ROOM IS THE RUN'S STOP, through the store's own door. `x` +// over an empty box and /stop both raise the card aimed at the run's own task +// by the store's id, the target the stored page's `x` has always raised, and +// saying yes cancels that task in the store — never a node cancel the run is +// not a node of. +func TestStopOnAProgramsRoomRaisesThePlanCard(t *testing.T) { + for _, way := range []struct { + name string + press func(t *testing.T, a *app) + }{ + {"x over an empty box", func(t *testing.T, a *app) { drive(t, a, key(stopRaiseKey)) }}, + {"/stop", func(t *testing.T, a *app) { + for _, r := range "/stop" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + }}, + } { + t.Run(way.name, func(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + if frame, _, _ := a.frame(); !strings.Contains(plain(frame), roomStopMark) && !strings.Contains(plain(frame), roomStopMarkASCII) { + t.Fatalf("the facts row offers no Stop:\n%s", plain(frame)) + } + way.press(t, a) + if a.stop == nil { + t.Fatal("no stop card was raised") + } + if got := a.stop.target; got.plan != "t-7" || got.id != "" || got.noun != stopTaskNoun { + t.Fatalf("the card is aimed at %+v, want the run's own task by the store's id", got) + } + drain(t, a, a.stopTake(0)) + if len(agent.cancelled) != 1 || agent.cancelled[0] != "t-7" { + t.Fatalf("saying yes cancelled %v in the store, want [t-7]", agent.cancelled) + } + }) + } + // A LANDED RUN OFFERS NO STOP. + a, _ := programRoomApp(t, 120, 28) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: a.now()})}) + openProgramRoomNow(t, a) + if target := a.stopHere(); !target.empty() { + t.Fatalf("a landed program's room offers a stop: %+v", target) + } +} + +// THE ROOM AT A PHONE'S WIDTH keeps the conversation's strip and the trail, its +// facts row keeps the stage and the spend, the conversation stands each name +// on a line of its own, and no row of the frame is wider than the frame. +func TestAProgramsRoomAtFortyFourColumns(t *testing.T) { + a, _ := programRoomApp(t, 44, 30) + openProgramRoomNow(t, a) + frame, _, _ := a.frame() + text := plain(frame) + for _, want := range []string{"Home", "the run", "implement · $1.24", "senior-dev", "I'll read the middleware"} { + if !strings.Contains(text, want) { + t.Fatalf("the room at 44 columns lost %q:\n%s", want, text) + } + } + for i, line := range strings.Split(frame, "\n") { + if cells := ansi.StringWidth(line); cells > 44 { + t.Fatalf("row %d is %d cells in a 44-cell frame: %q", i, cells, plain(line)) + } + } +} + +// ── ONE CLOCK FOR ONE RUN ─────────────────────────────────────────────────── +// +// runclock_test.go holds the rail's anchor and the landed card's span on their +// own; this is the three surfaces read side by side for a program's run. + +// AND THE ROOM, THE RAIL AND THE CARD READ ONE FIGURE. Opened while the run +// works, the room's facts row reads the rail's clock, anchored at the record's +// start, and not the store's stamps, which bracket other events (the store is +// seeded before the run's copy is made); once the run has landed the facts row, +// the stored page's pinned line and the landed card read one span. +func TestAProgramRunReadsOneFigureOnTheRoomTheRailAndTheCard(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + // The store was seeded sixteen seconds before the run's hand-off. + page := agent.planFake.pages["7"] + page.Row.Started = programRunBegan.Add(-16 * time.Second) + agent.planFake.pages["7"] = page + rail := strings.Split(plain(a.railTelemetry(a.tasks[7], 40)), railSep)[0] + openProgramRoomNow(t, a) + facts, _ := a.programFactsWord(120) + if !strings.HasSuffix(facts, rowSep+rail) || rail != "14m 3s" { + t.Fatalf("the room's facts read %q and the rail %q, want both 14m 3s", facts, rail) + } + // THE RUN LANDS, its process gone twenty-nine minutes and eight seconds after + // the hand-off. + ended := programRunBegan.Add(29*time.Minute + 8*time.Second) + now := ended.Add(3 * time.Second) + a.clock = func() time.Time { return now } + page.Row.Status, page.Row.Ended = "done", ended.Add(2*time.Second) + agent.planFake.pages["7"] = page + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, page.Row.Title, session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: ended, Elapsed: ended.Sub(programRunBegan)})}) + drive(t, a, frameMsg{}) + facts, _ = a.programFactsWord(120) + if !strings.HasSuffix(facts, rowSep+"29m 8s") { + t.Fatalf("the landed room's facts read %q, want the span 29m 8s", facts) + } + if pinned := a.taskPlanPinned(agent.planFake.pages["7"], 120); !strings.HasSuffix(pinned, rowSep+"29m 8s") { + t.Fatalf("the stored page pins %q, want the span 29m 8s", pinned) + } + var card *taskDone + for i := len(a.entries) - 1; i >= 0 && card == nil; i-- { + card = a.doneCardAt(i) + } + if card == nil || !strings.Contains(plain(a.doneTail(card)), "29m08s") { + t.Fatalf("the landed card does not read the span 29m08s: %+v", card) + } +} diff --git a/internal/tui3/programtab_test.go b/internal/tui3/programtab_test.go new file mode 100644 index 000000000..49a9e4709 --- /dev/null +++ b/internal/tui3/programtab_test.go @@ -0,0 +1,144 @@ +package tui3 + +// A PROGRAM'S TASK OPENS INSIDE THE CONVERSATION'S TAB, and the tab bar keeps +// working while it is open. These tests drive the owner's report of 2026-09-24 +// through the surface's own Update loop: open senior-dev's task by every door a +// person has onto it, then leave it by `esc`, by a press on the conversation's +// tab, and by a press on Home, and read what the FRAME shows after each — how +// many tabs are drawn selected, whether Home is on the bar, and whether the +// program's page is still covering whatever the press chose (tabbar_test.go +// holds the readings and the ordinary room they are held against). + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// programTabSaid is a line only the program's conversation draws: the model's +// first answer in [programTurns]. Its presence on the frame is the program's +// page being on screen. +const programTabSaid = "I'll read the middleware and the store first." + +// newProgramTabLab is a window in one conversation, "the run", that handed a +// task to senior-dev: the task is node 7 of its graph and the run's root in +// its store, and the store answers the program's page for it. Home is +// reachable, so the strip carries its Home door. +func newProgramTabLab(t *testing.T, status string) *tabLab { + t.Helper() + row := programRow() + row.ID, row.Status = "7", status + pages := map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, pages) + // The room doors (a lane, a journal, a steer), so an ordinary room and a + // room reopened on the program's task can both open. + a.agent = &railPlanCounter{planFake: fake} + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + state := session.TaskRunning + if status == "done" { + state = session.TaskDone + } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, state, session.TaskNotice{})}) + readPlanRows(t, a) + lab := &tabLab{a: a} + // Two frames: the strip settles on the second. + lab.shoot() + if shot := lab.shoot(); !shot.drawn || len(shot.selected) != 1 || !shot.home { + t.Fatalf("the conversation's own frame is not the baseline: drawn=%v selected=%q home=%v\n%s", + shot.drawn, shot.selected, shot.home, shot.text) + } + return lab +} + +// programTabDoors is every door onto the program's task a test can drive +// through the loop, each named for what a person does. +var programTabDoors = []struct { + name string + open func(t *testing.T, l *tabLab) +}{ + {"a press on its rail row", func(t *testing.T, l *tabLab) { clickRail(t, l.a, 0) }}, + {"enter on its rail row", func(t *testing.T, l *tabLab) { + l.a.railWhere, l.a.railHold = railSpot{id: 7}, true + drive(t, l.a, tea.KeyPressMsg{Code: tea.KeyEnter}) + }}, + // The card in the transcript, a task link, the task strip, the home panel + // and the sessions place's own row all come through this one door. + {"its card (openRoomFor)", func(t *testing.T, l *tabLab) { + l.a.openRoomFor(7, "rewrite the auth middleware") + drain(t, l.a, l.a.takeRoomPump()) + }}, + // A switch back to a held conversation, the new-chat page's way back, the → + // key and the landing's `tell` reopen a room by id. + {"a room reopened on it (openRoom)", func(t *testing.T, l *tabLab) { + l.a.openRoom(7, "rewrite the auth middleware") + drain(t, l.a, l.a.takeRoomPump()) + }}, +} + +// THE PAGE IS INSIDE THE CONVERSATION'S TAB. Whatever door opened it, the +// frame still draws the conversation's strip, with exactly one tab selected — +// the conversation's — and Home on it; and the program's conversation is what +// the body shows. +func TestAProgramsTaskOpensInsideTheConversationsTab(t *testing.T) { + for _, status := range []string{"running", "done"} { + for _, door := range programTabDoors { + t.Run(status+"/"+door.name, func(t *testing.T) { + l := newProgramTabLab(t, status) + door.open(t, l) + shot := l.shoot() + if !strings.Contains(shot.text, programTabSaid) { + t.Fatalf("the door did not put the program's conversation on screen:\n%s", shot.text) + } + if !shot.drawn { + t.Errorf("SYMPTOM: the page took the whole frame and no tab strip is drawn (railTaskPlanOn=%v workTabOn=%v)", + l.a.railTaskPlanOn, l.a.workTabOn) + return + } + if len(shot.selected) != 1 { + t.Errorf("SYMPTOM: %d tabs are drawn selected, %q, want only the conversation's", len(shot.selected), shot.selected) + } + if !shot.home { + t.Errorf("SYMPTOM: Home is not on the strip") + } + }) + } + } +} + +// AND THE THREE WAYS OUT ALL LEAVE IT: `esc`, a press on the conversation's +// tab, and a press on Home. After the first two the conversation is on screen +// under its own strip; after the third, Home is — and in no case is the +// program's page still drawn over what the gesture chose. +func TestEveryWayOutOfAProgramsTaskLeavesIt(t *testing.T) { + for _, status := range []string{"running", "done"} { + for _, door := range programTabDoors { + for _, way := range tabWaysOut { + t.Run(status+"/"+door.name+"/"+way.name, func(t *testing.T) { + l := newProgramTabLab(t, status) + door.open(t, l) + l.shoot() + way.leave(t, l) + checkLeft(t, l, way.name, way.home, programTabSaid) + }) + } + } + } +} + +// A PROGRAM'S RUN IS NOT A TAB. The strip names conversations; a run that the +// belt switch drives had a tab of its own beside its conversation, and once a +// program's store became readable with the switch off (028247170) every +// senior-dev run grew one too, named after its task — the "new task tab" of +// the report. A program's task opens inside its conversation's tab instead. +func TestAProgramsRunOffersNoTabOfItsOwn(t *testing.T) { + l := newProgramTabLab(t, "running") + for _, hit := range l.last { + if hit.tab.work { + t.Fatalf("SYMPTOM: the strip offers the program's run a tab of its own, %q", hit.tab.word) + } + } +} diff --git a/internal/tui3/room.go b/internal/tui3/room.go index d512a8180..227dbb158 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -208,7 +208,9 @@ func (a *app) taskModelDoors() (taskModelDoor, bool) { // Ordinary settled tasks save continuation settings through the same picker. // Adaptive runs and guest pages remain outside this task model door. func (a *app) roomModelMovable() bool { - if a.room == nil || a.room.orch != nil { + // A PROGRAM'S RUN IS NOT A NODE OF THE GRAPH, so the task model door has + // nothing to move: the program chooses its own models (programroom.go). + if a.room == nil || a.room.orch != nil || a.room.program != nil { return false } // AND A PAGE READ THROUGH SOMEBODY ELSE'S CONVERSATION MOVES NOTHING. The door @@ -384,6 +386,12 @@ type taskRoom struct { // for both to BE a room. orch *orchRun + // program is set when this page is a PROGRAM'S TASK (programroom.go): the + // same room again, for the same reason as [taskRoom.orch], whose body is the + // program's conversation with codeaf read from the task's stored page + // instead of a worker's transcript. + program *programRoom + // harnessProgress is the design lane's one evolving thought inside the // design node's room. It is display-only: no journal line is minted for live // telemetry, and the next event replaces this string in place. @@ -678,6 +686,16 @@ func (a *app) openRoom(id uint64, title string) { if a.startingChat() { a.parkChatStart() } + // A TASK THE SURFACE ALREADY HOLDS AS A PROGRAM'S OPENS THE PROGRAM'S ROOM AT + // ONCE (programroom.go), from every door that opens a room by its id — the + // card, a task link, the task strip, the home panel, a held conversation + // brought forward, the new-chat page's way back, the → key, the landing's + // `tell`. An ordinary room on it would be a blank page for the one read + // [app.roomProgramCheck] takes to learn what the held row already says. + if a.programTask(id) { + a.openProgramRoom(id, title, a.heldProgramPage(id)) + return + } doors, ok := a.roomDoors() if !ok { // Local engine windows use this same door without a remote host label. @@ -790,17 +808,18 @@ func (a *app) openRoom(id uint64, title string) { } // roomProgramCheck asks the store, off the loop, whether the task a room was -// just opened on is a program's, and if it is, trades the room for the task's -// page. -// -// EVERY DOOR ENDS HERE, SO THE QUESTION IS ASKED HERE. [app.openRoomFor] asks -// the rows this conversation holds, which answers without a frame of room, but -// a door that brings a conversation forward and reopens the room it was on -// (the sessions place, a switch back to a held conversation) opens it before -// that conversation's rows have been read, and a program's room is a blank -// page that says it will fill in: the program has no worker transcript, and -// its conversation with codeaf is on the stored page. Nothing is traded when -// the person has already left the room, or when it is another conversation's. +// just opened on is a program's, and if it is, turns the room into the +// program's room (programroom.go). +// +// EVERY DOOR ENDS HERE, SO THE QUESTION IS ASKED HERE. [app.openRoom] asks the +// rows this conversation holds, which answers without a frame of room, but a +// door that brings a conversation forward and reopens the room it was on (the +// sessions place, a switch back to a held conversation) opens it before that +// conversation's rows have been read, and an ordinary room on a program's task +// is a blank page that says it will fill in: the program has no worker +// transcript, and its conversation with codeaf is on the stored page. Nothing +// is changed when the person has already left the room, when it is another +// conversation's, or when it is already the program's. func (a *app) roomProgramCheck(id uint64) tea.Cmd { // A DOOR THAT ALREADY ASKED THE STORE, and was told there is no page, is // not asked again: the rail reads the store once, at the gesture. @@ -821,11 +840,11 @@ func (a *app) roomProgramCheck(id uint64) tea.Cmd { if !here || !found || (page.Program == nil && strings.TrimSpace(page.Row.Program) == "") { return nil } - if a.room == nil || a.roomIsGuest() || a.room.id != id { + if a.room == nil || a.roomIsGuest() || a.room.id != id || a.room.program != nil { return nil } - a.closeRoom() - return a.openRailPlan(key, nil) + a.openProgramRoom(id, a.room.title, page) + return a.takeRoomPump() } }) } @@ -1143,51 +1162,23 @@ func (a *app) roomStandingOn(node *taskNode) bool { // conversation. Sidebar rows use openRailRoom so a repeated click stays inside. // A guest with the same task number belongs to a different conversation. // -// A PROGRAM'S TASK OPENS ITS CONVERSATION AND NEVER A ROOM. A task handed to a -// program codeaf carries (senior-dev) has no worker transcript: the program +// A PROGRAM'S TASK OPENS THE PROGRAM'S ROOM (programroom.go). A task handed to +// a program codeaf carries (senior-dev) has no worker transcript: the program // talks to codeaf through the run's model API, and what it said is on the -// task's stored page ([app.taskConversation]). A room on it is a blank page -// saying it fills in as the task works while the program makes call after -// call, which is how a person watching senior-dev saw nothing for five -// minutes. The rail's own door already asked the store first -// ([app.openRailRoom]); every other door — the card in the conversation, a -// transcript link, the task strip, the home panel, the sessions place — came -// through here and went straight to the room. So a row the surface already -// holds as a program's opens its page the rail's way, with the room as the -// answer only when the store has no page for it. +// task's stored page. The card in the conversation, a transcript link, the +// task strip, the home panel and the sessions place all come through here to +// [app.openRoom], which opens a row the surface already holds as a program's +// as the program's room at once, and turns a room on a row it does not hold +// yet into the program's when the store's answer says so +// ([app.roomProgramCheck]). func (a *app) openRoomFor(id uint64, title string) { if a.room != nil && !a.roomIsGuest() && a.room.id == id { a.closeRoom() return } - if a.programTask(id) { - a.roomPump = tea.Batch(a.roomPump, a.openRailPlan(strconv.FormatUint(id, 10), func() tea.Cmd { - a.roomPageAsked = id - a.openRoom(id, title) - return a.takeRoomPump() - })) - return - } a.openRoom(id, title) } -// programTask reports whether the surface holds this conversation's task as a -// program's run: its held row names the program. It reads only what is held, -// never the store, because it is asked on the loop at a key or a click. -func (a *app) programTask(id uint64) bool { - rows, ok := a.heldPlanRows() - if !ok { - return false - } - want := strconv.FormatUint(id, 10) - for _, row := range rows { - if row.ID == want { - return strings.TrimSpace(row.Program) != "" - } - } - return false -} - // openRailRoom makes list selection idempotent. Repeated clicks must not close // the page or replace its draft, scroll position and live subscription. // @@ -1201,6 +1192,13 @@ func (a *app) openRailRoom(node *taskNode) tea.Cmd { return nil } id, title, run, part := node.id, node.title, node.run, node.node + // A PROGRAM'S TASK OPENS ITS ROOM AT ONCE, on the row the surface holds, and + // reads its page from there (programroom.go). No key is held for a page on + // its way, because the room is up from this press on. + if run == "" && a.programTask(id) { + a.openProgramRoom(id, title, a.heldProgramPage(id)) + return a.takeRoomPump() + } _, hasPlan := a.planReader() room := func() tea.Cmd { if run != "" && !hasPlan { @@ -1413,6 +1411,15 @@ func (a *app) roomNote(text string) { return } if text = strings.TrimSpace(text); text != "" { + // A PROGRAM'S PAGE IS NOT A TRANSCRIPT (programroom.go), so a line the + // room says is kept on the page's own list and drawn under its + // conversation, where a note in the transcript would never be drawn. + if p := a.room.program; p != nil { + p.programSay(text) + a.room.dirty = true + a.touch() + return + } a.room.note(text) } } @@ -1507,6 +1514,13 @@ func (a *app) steer() tea.Cmd { if room.orch != nil { return a.orchSteer() } + // A PROGRAM READS NO MESSAGE (programroom.go). Nothing is sent and nothing is + // taken out of the box: the page says so, names where the words can go, and + // leaves the sentence where the person can carry it there. + if room.program != nil { + a.roomNote(a.programRoomRefusal().line()) + return nil + } if a.roomIsGuest() { a.roomNote(roomGuestReadingWord) return nil @@ -1991,6 +2005,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return cmd, true } } + if cmd, taken := a.programRoomKey(msg); taken { + return cmd, true + } switch msg.String() { case "esc": // ESC IN HERE IS THE DOOR AND IT IS NEVER A STOP — stop.go's standing law, @@ -2020,7 +2037,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // caret over a sentence. case "enter": - if !a.roomIsGuest() && strings.TrimSpace(a.input.String()) == "" { + // A PROGRAM'S RUN IS NOT A NODE THE RETRY DOOR CAN REOPEN, so enter over an + // empty box on its room offers nothing (programroom.go). + if !a.roomIsGuest() && a.room.program == nil && strings.TrimSpace(a.input.String()) == "" { entry := a.roomRetryEntry() if a.taskCanRetry(entry) { return a.retryTask(entry), true @@ -2106,7 +2125,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // would be the surface repeating itself in the one place a person reads for the // next keystroke. func (a *app) roomHint() string { - if a.room != nil && !a.roomIsGuest() && !a.guarding() && !a.asking() && !a.stopping() { + if a.room != nil && !a.roomIsGuest() && a.room.program == nil && !a.guarding() && !a.asking() && !a.stopping() { entry := a.roomRetryEntry() if hint := a.taskRetryHint(entry); hint != "" { return hint @@ -2124,7 +2143,7 @@ func (a *app) roomHint() string { case a.recalling(): return roomRecallHint case a.stopOffered(): - if a.roomOrganized() { + if a.roomOrganized() && a.programOf() == nil { return "/model · /stop · esc main" } // THE ROOM'S ANSWER TO "HOW DO I STOP THIS". It is the honest counterpart @@ -2710,7 +2729,7 @@ func (a *app) roomFactsLine(width int) string { if mark != "" && a.hoveringRoomStop() { shown = a.pal.ink(mark) } - if node != nil && !a.orchOpen() { + if node != nil && !a.orchOpen() && a.programOf() == nil { if line, ok := a.roomGroupedFacts(node, width, shown); ok { if mark != "" { cols := ansi.StringWidth(mark) @@ -2761,6 +2780,11 @@ func (a *app) roomFactsWord(node *taskNode, width int) (string, int) { if a.orchOpen() { return a.orchHeadWord(room), 0 } + // A PROGRAM'S PAGE ANSWERS WITH THE LINE ITS STORED PAGE PINS: the stage, the + // spend of the ceiling, the calls and the age (programroom.go). + if a.programOf() != nil { + return a.programFactsWord(room) + } if node == nil { return "", 0 } @@ -3558,6 +3582,13 @@ func (a *app) roomRows(width int) []row { room.rows, room.width, room.height, room.dirty = out, width, height, false return out } + // AND A PROGRAM'S PAGE IS ITS CONVERSATION WITH CODEAF (programroom.go), + // branched here for the run's reason: only what fills the room differs. + if room.program != nil { + out := a.programRoomRows(width) + room.rows, room.width, room.height, room.dirty = out, width, height, false + return out + } // THE READING GUTTER IS TAKEN OUT FIRST AND GIVEN BACK LAST, exactly as in // the conversation (gutter.go, and render.go's [app.layout] states the law). // A task's page is a transcript and is read as one; it stood flush against @@ -4088,6 +4119,12 @@ func (a *app) roomSteerLaneRows(rows []string, width int) []string { // listening" is the question it exists to answer. lane = orchSteerLane + roomSteerBack } + if a.room.program != nil { + // A PROGRAM READS NO MESSAGE, so the box does not offer to steer it: it + // says the fact and the place the words can go, the same line enter over a + // sentence says (programroom.go). + lane = a.programRoomRefusal().fit(room) + } if a.roomIsGuest() { // A BORROWED PAGE DOES NOT OFFER A KEYBOARD IT DOES NOT HAVE. This window is // a second view onto a conversation another window is driving, and the diff --git a/internal/tui3/roompanel.go b/internal/tui3/roompanel.go index 84ff90ad6..f97d0146e 100644 --- a/internal/tui3/roompanel.go +++ b/internal/tui3/roompanel.go @@ -153,7 +153,9 @@ func (a *app) roomControlRows(width int) []railLine { if host, ok := a.agent.(interface{ TaskSetupSupported() bool }); ok && !host.TaskSetupSupported() && !a.roomIsGuest() { out = append(out, railLine{text: a.pal.dim(fit("Engine update needed", width)), entry: -1}) } - if node := a.roomNode(); node != nil && !a.roomIsGuest() { + // A PROGRAM'S RUN HAS NO THINKING LEVEL THIS SURFACE CAN MOVE, so its room + // draws no row for one (programroom.go). + if node := a.roomNode(); node != nil && !a.roomIsGuest() && a.programOf() == nil { movable := a.taskRungMovable(node) rung := a.taskRung(node.id).String() if movable || rung != "" { @@ -180,7 +182,10 @@ func (a *app) roomControlRows(width int) []railLine { } } if target := a.stopHere(); !target.empty() { - if _, ok := a.stopDoors(); ok { + // A run's own task stops through the store's door and not the stop door + // ([stopTarget.plan]), and its target is only offered when that door is + // there (programroom.go's [app.programStopTarget]). + if _, ok := a.stopDoors(); ok || target.plan != "" { out = append(out, railLine{entry: -1}, row("Stop "+target.noun+"…", "stop")) } } @@ -288,11 +293,21 @@ func (a *app) roomModelCommand(rest string) { func (a *app) roomTitleRow(width int) string { left := a.roomHereWord() right, painted := "", "" - if node := a.roomNode(); node != nil { + if node := a.roomNode(); node != nil && a.programOf() == nil { f := a.roomFactsOf(node) right = rowAll([]rowField{f.state, f.live, f.clock, f.spend}) state := rowAll([]rowField{f.state}) painted = a.taskStateInk(node)(state) + a.pal.muted(strings.TrimPrefix(right, state)) + } else if a.programOf() != nil { + // A PROGRAM'S ROOM PINS ITS STORED PAGE'S LINE BESIDE THE TITLE: the stage, + // the spend of the ceiling, the calls and the age (programroom.go). It is + // given at most half the row, so the title keeps its half. + line, lead := a.programFactsWord(max((width-headLabelAt-2)/2, 1)) + right = line + painted = a.pal.muted(line) + if node != nil && lead > 0 { + painted = a.taskStateInk(node)(ansi.Cut(line, 0, lead)) + a.pal.muted(ansi.Cut(line, lead, ansi.StringWidth(line))) + } } room := max(width-headLabelAt-2-ansi.StringWidth(right)-3, 1) left = fit(left, room) @@ -316,7 +331,9 @@ func (a *app) roomRecipientWord() string { } return "Conversation model" } - if a.roomIsGuest() { + // A PROGRAM'S ROOM IS READ AND NEVER WRITTEN TO (programroom.go), so the box's + // label says what a borrowed page's does rather than naming a recipient. + if a.roomIsGuest() || a.programOf() != nil { return "Reading: " + a.roomHereWord() } return "To: " + a.roomHereWord() diff --git a/internal/tui3/stop.go b/internal/tui3/stop.go index b5077be5e..46dca18ac 100644 --- a/internal/tui3/stop.go +++ b/internal/tui3/stop.go @@ -466,6 +466,13 @@ func (a *app) stopHere() stopTarget { if a.roomIsGuest() { return stopTarget{} } + // A PROGRAM'S ROOM STOPS THE RUN THROUGH THE STORE'S OWN DOOR, the target + // its stored page's `x` has always raised (programroom.go's + // [app.programStopTarget]): the run is not a node of the graph, so the + // node's cancel has nothing to end. + if a.room.program != nil { + return a.programStopTarget() + } return a.stopTaskTarget(a.tasks[a.room.id]) } // A HELD ROSTER IS STILL THE FIRST ANSWER OFF IT, because its cursor is where diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go index a1fe919c8..d19b9f9f1 100644 --- a/internal/tui3/taskconversation.go +++ b/internal/tui3/taskconversation.go @@ -100,10 +100,10 @@ type convLine struct { ink func(string) string } -// taskPlanIsProgram reports whether the open page is a program's: its read -// carries the program's conversation, or its row names the program — which is -// all a page opened on a held row knows until its own read comes back -// ([app.openWorkTab]), and that page must not flash a box it will take away. +// taskPlanIsProgram reports whether the open stored page is a program's: its +// read carries the program's conversation, or its row names the program, which +// is how a read that came back for a side-list press is known to belong in the +// program's room instead ([app.finishRailPlan]). func (a *app) taskPlanIsProgram() bool { page := a.taskSheet.plan return page.Program != nil || strings.TrimSpace(page.Row.Program) != "" @@ -136,6 +136,14 @@ func convProgramOf(page session.PlanTaskPage) *session.PlanProgram { // narrow frame gives up the clock before the calls and the calls before the // money. func (a *app) taskPlanPinned(page session.PlanTaskPage, width int) string { + return a.programPinned(page, width, a.taskPlanAge(page.Row)) +} + +// programPinned is [app.taskPlanPinned] with the clock handed in: the program's +// room ([app.programFactsWord]) draws the same line with the clock of the +// node it is standing on, which is the clock the rail and the landed card +// read, so the one run reads one figure wherever it is drawn. +func (a *app) programPinned(page session.PlanTaskPage, width int, clock string) string { if (page.Program == nil && strings.TrimSpace(page.Row.Program) == "") || width < 1 { return "" } @@ -155,14 +163,27 @@ func (a *app) taskPlanPinned(page session.PlanTaskPage, width int) string { if n := program.Calls; n > 0 { fields = append(fields, rowSay(itoa(n)+" "+plural("call", n))) } - fields = append(fields, rowSay(a.taskPlanAge(page.Row))) + fields = append(fields, rowSay(clock)) return rowTail(fields, width) } // taskPlanAge is how long a task has been going: from when it was made to when // it ended, or to now while it runs. A task that has ended without a moment // recorded for its ending draws no age rather than one that keeps climbing. +// +// A PROGRAM'S RUN READS THE CLOCK THE RAIL READS whenever this conversation +// holds one for it ([app.nodeClock]). The store's two stamps bracket other +// events than the run's notices do — the store is seeded before the run's copy +// is made, and it is ended by the supervisor rather than when the program's +// process is gone — so a page reading them and a rail and a landed card reading +// the notices drew three different figures for one run. The store's stamps are +// what is left for a run this conversation has no row for. func (a *app) taskPlanAge(row session.PlanTaskRow) string { + if node := a.programRowNode(row); node != nil { + if word, ok := a.nodeClock(node); ok { + return word + } + } if row.Started.IsZero() { return "" } @@ -196,7 +217,17 @@ func (a *app) taskProgramBody(width int) []string { if n := len(a.taskSheet.planBack); n > 0 { out = append(out, pal.dim("esc/← "+a.taskSheet.planBack[n-1].Row.Title)) } - out = append(out, a.taskConversation(page, width)...) + return append(out, a.programBody(page, width, a.taskSheet.planBriefFull)...) +} + +// programBody is what both of a program's pages draw under their head — the +// tasks place's stored page ([app.taskProgramBody]) and the program's room in +// the conversation's own tab (programroom.go): the conversation, the steps a +// run with no conversation reported, and the notes. briefFull is the page's +// own fold, because each page folds its brief with its own key. +func (a *app) programBody(page session.PlanTaskPage, width int, briefFull bool) []string { + pal := a.pal + out := a.taskConversation(page, width, briefFull) if len(convProgramOf(page).Turns) == 0 && len(page.Steps) > 0 { out = append(out, "", pal.dim("steps")) for _, step := range page.Steps { @@ -227,7 +258,7 @@ func (a *app) taskProgramBody(width int) []string { // [convTextLeast] cells of words each name stands on its own line instead. The // column is as wide as the widest name on the page, so it does not move as the // conversation grows by a call from the same model. -func (a *app) taskConversation(page session.PlanTaskPage, width int) []string { +func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull bool) []string { if width < 1 { return nil } @@ -244,7 +275,7 @@ func (a *app) taskConversation(page session.PlanTaskPage, width int) []string { // Folded to the brief's own three lines, with the key that unfolds it, the // way every other page folds a brief. opening := convSide{name: speaker} - for _, line := range a.taskConversationBrief(page, text) { + for _, line := range taskConversationBrief(page, text, briefFull) { opening.lines = append(opening.lines, convLine{text: line, ink: pal.ink}) } if len(opening.lines) > 0 { @@ -295,9 +326,9 @@ func (a *app) taskConversation(page session.PlanTaskPage, width int) []string { // ([planBriefRows]), at the width the words get beside the names, folded to // [briefFoldLines] with the line that says how many more and which key opens // them. -func (a *app) taskConversationBrief(page session.PlanTaskPage, text int) []string { +func taskConversationBrief(page session.PlanTaskPage, text int, briefFull bool) []string { lines := planBriefRows(page.Description, text) - if a.taskSheet.planBriefFull || len(lines) <= briefFoldLines { + if briefFull || len(lines) <= briefFoldLines { return lines } return append(append([]string(nil), lines[:briefFoldLines]...), @@ -309,9 +340,15 @@ func (a *app) taskConversationBrief(page session.PlanTaskPage, text int) []strin // or closes it ([app.taskPlanKey]). It measures the brief at the width the // conversation draws it at, so the key and the fold line cannot disagree. func (a *app) taskConversationFolds() bool { - page := a.taskSheet.plan width, _ := a.size() - _, text := convColumns(convNames(convProgramOf(page), convProgramName(page)), width-2) + return convBriefFolds(a.taskSheet.plan, width-2) +} + +// convBriefFolds is whether a program's brief folds when its conversation is +// drawn at this width — the one measure both of a program's pages ask before +// their `ctrl+o` opens or closes it. +func convBriefFolds(page session.PlanTaskPage, width int) bool { + _, text := convColumns(convNames(convProgramOf(page), convProgramName(page)), width) return len(planBriefRows(page.Description, text)) > briefFoldLines } diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index 898eb0b17..03ed79e9a 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -416,32 +416,22 @@ func TestAProgramsPlanRowDrawsItsStageAndNotACommand(t *testing.T) { } } -// A PROGRAM'S RUN'S TAB OFFERS NO BOX EITHER. The run's tab routes its keys to -// the page's own keyboard, so a box drawn there for a program would be a promise -// that every key typed into it breaks — before the page's read comes back as -// much as after, because the row it opens on already names the program. -func TestAProgramsWorkTabOffersNoNoteBox(t *testing.T) { +// A PROGRAM'S RUN IS OFFERED NO TAB, SO NO TAB OFFERS IT A BOX. The run's tab +// used to open the stored page with the tab's own keyboard, which had to be +// kept from drawing a box for a program; a program's run has no tab now, and +// its task opens in the conversation's own tab, whose box sends a program +// nothing ([TestAProgramsRoomSendsNothingAndSaysSo]). Its held rows are no work +// tab's rows either, so the tab cannot be opened on them by any door. +func TestAProgramsRunOpensNoWorkTab(t *testing.T) { row := programRow() a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) a.width, a.height = 120, 28 a.taskSheet.mine.plan = fake.plan - if cmd := a.openWorkTab(); cmd == nil { - t.Fatal("the run's tab did not open") - } else { - if text := plain(strings.Join(a.workTabFrame(a.width, a.height), "\n")); strings.Contains(text, taskPlanNoteWord) { - t.Fatalf("the program's tab offers a box before its page is read:\n%s", text) - } - drive(t, a, cmd()) - } - if text := plain(strings.Join(a.workTabFrame(a.width, a.height), "\n")); strings.Contains(text, taskPlanNoteWord) { - t.Fatalf("the program's tab offers a box:\n%s", text) - } - for _, r := range "a note" { - drive(t, a, key(string(r))) + if tab, ok := a.workTab(); ok { + t.Fatalf("the program's run is offered a tab of its own, %q", tab.word) } - drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) - if len(fake.noted) != 0 || !a.taskSheet.planNote.empty() { - t.Fatalf("typing on a program's tab wrote notes %v, box %q", fake.noted, a.taskSheet.planNote.String()) + if cmd := a.openWorkTab(); cmd != nil || a.workTabOn { + t.Fatal("the work tab opened on a program's run") } } @@ -482,30 +472,38 @@ func TestACallsArgumentsAreReadForWhatTheCallWasAbout(t *testing.T) { } } -// EVERY DOOR INTO A PROGRAM'S TASK OPENS ITS CONVERSATION. The card in the -// conversation, a transcript link, the task strip and the home panel all come -// through [app.openRoomFor], which used to open a room: a blank page, because a -// program has no worker transcript, while senior-dev made call after call. A -// held row that names its program now opens the stored page the rail's way. +// EVERY DOOR INTO A PROGRAM'S TASK OPENS ITS CONVERSATION, AS A ROOM. The card +// in the conversation, a transcript link, the task strip and the home panel all +// come through [app.openRoomFor], which used to open an ordinary room — a blank +// page, because a program has no worker transcript — and then a full-frame page +// over the conversation with no tab strip. A held row that names its program +// opens the program's room at once, inside the conversation's tab, with the +// program's conversation as its body — whichever way the row's id is spelled: +// the store answers `t-7`, and a comparison against the bare number missed +// every real row. func TestEveryDoorIntoAProgramsTaskOpensItsConversation(t *testing.T) { - row := programRow() - row.ID = "7" - a, _ := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) - a.width, a.height = 120, 28 - a.openRoomFor(7, row.Title) - if a.room != nil { - t.Fatal("a program's task opened a room") - } - cmd := a.takeRoomPump() - if cmd == nil { - t.Fatal("nothing asked the store for the program's page") - } - drive(t, a, cmd()) - if !a.taskSheet.planOn || !a.taskPlanIsProgram() { - t.Fatalf("the door did not open the program's page: plan %v, program %+v", a.taskSheet.planOn, a.taskSheet.plan.Program) - } - if lines := programPageLines(a); !saidBy(lines, "senior-dev", "rewrite the auth middleware") { - t.Fatalf("the page does not show the program's conversation:\n%s", strings.Join(lines, "\n")) + for _, id := range []string{"7", "t-7"} { + t.Run(id, func(t *testing.T) { + row := programRow() + row.ID = id + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{"7": programPage(row, programTurns())}) + a.width, a.height = 120, 28 + a.openRoomFor(7, row.Title) + if a.programOf() == nil || a.railTaskPlanOn || a.taskSheet.planOn { + t.Fatalf("the door did not open the program's room: room=%v railPage=%v page=%v", a.room != nil, a.railTaskPlanOn, a.taskSheet.planOn) + } + cmd := a.takeRoomPump() + if cmd == nil { + t.Fatal("nothing asked the store for the program's page") + } + drain(t, a, cmd) + if len(fake.noted) != 0 { + t.Fatalf("opening the room wrote notes %v", fake.noted) + } + if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") { + t.Fatalf("the room does not show the program's conversation:\n%s", text) + } + }) } } @@ -524,52 +522,35 @@ func TestADoorIntoAnOrdinaryTaskStillOpensItsRoom(t *testing.T) { } } -// A PROGRAM'S TAB IS ITS CONVERSATION, AND IT IS THE RUN STILL WORKING. With two -// of senior-dev's runs in one conversation, the tab was named after the first -// row, which had landed, and it drew the whole tasks place — every -// conversation on the machine — with that run's notes under it. -func TestAProgramsTabShowsTheWorkingRunsConversation(t *testing.T) { +// A PROGRAM'S RUNS ARE NO TAB, AND A BELT RUN BESIDE THEM KEEPS ITS OWN. With +// two of senior-dev's runs in one conversation the strip used to offer a tab +// named after one of them; a program's task opens in the conversation's own +// tab now, so neither is a tab — and a run the belt switch drives beside them +// is still the tab, named after itself and never after a program's run. +func TestAProgramsRunsAreNoTabAndABeltRunKeepsItsOwn(t *testing.T) { landed := programRow() - landed.ID, landed.Title, landed.Status, landed.Stage = "1", "Implement true-myth", "done", "" + landed.ID, landed.Title, landed.Status, landed.Stage = "t-1", "Implement true-myth", "done", "" working := programRow() - working.ID, working.Title = "2", "Implement happy-dom" - rows := []session.PlanTaskRow{landed, working} - pages := map[string]session.PlanTaskPage{ - landed.ID: programPage(landed, nil), - working.ID: programPage(working, programTurns()), - } - a, fake := planAppWith(t, rows, pages) + working.ID, working.Title = "t-2", "Implement happy-dom" + a, fake := planAppWith(t, []session.PlanTaskRow{landed, working}, nil) a.width, a.height = 120, 30 a.taskSheet.mine.plan = fake.plan - if tab, ok := a.workTab(); !ok || tab.word != "Implement happy-dom" { - t.Fatalf("the tab is %q, want the run still working", tab.word) + if tab, ok := a.workTab(); ok { + t.Fatalf("a program's run is offered a tab, %q", tab.word) } - cmd := a.openWorkTab() - if cmd == nil { - t.Fatal("the run's tab did not open") - } - drive(t, a, cmd()) - if a.taskSheet.plan.Row.ID != working.ID { - t.Fatalf("the tab opened row %q, want the working run %q", a.taskSheet.plan.Row.ID, working.ID) - } - lines := make([]string, 0) - for _, line := range a.workTabFrame(a.width, a.height) { - lines = append(lines, plain(line)) - } - if !saidBy(lines, "senior-dev", "rewrite the auth middleware") { - t.Fatalf("the tab does not draw the program's conversation:\n%s", strings.Join(lines, "\n")) - } - if strings.Contains(strings.Join(lines, "\n"), " chats · ") { - t.Fatalf("the tab still draws the tasks place:\n%s", strings.Join(lines, "\n")) + belt := session.PlanTaskRow{ID: "t-3", Title: "Fix the flake", Status: "running"} + a.taskSheet.mine.plan = append(append([]session.PlanTaskRow(nil), fake.plan...), belt) + if tab, ok := a.workTab(); !ok || tab.word != belt.Title { + t.Fatalf("the belt run's tab is %q, want %q", tab.word, belt.Title) } } -// A ROOM OPENED ON A PROGRAM'S TASK TRADES ITSELF FOR THE PAGE. The sessions +// A ROOM OPENED ON A PROGRAM'S TASK BECOMES THE PROGRAM'S ROOM. The sessions // place brings a conversation forward and reopens the room it was aimed at -// before that conversation's rows are read, so the row check at the door -// cannot see the program; the room asks the store itself, and a program's -// page replaces the blank room. -func TestARoomOpenedOnAProgramsTaskBecomesItsPage(t *testing.T) { +// before that conversation's rows are read, so the row check at the door cannot +// see the program; the room asks the store itself, and becomes the program's +// room — still a room in the conversation's tab, never a page drawn over it. +func TestARoomOpenedOnAProgramsTaskBecomesItsRoom(t *testing.T) { row := programRow() row.ID = "7" a, _ := planAppWith(t, nil, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) @@ -580,11 +561,14 @@ func TestARoomOpenedOnAProgramsTaskBecomesItsPage(t *testing.T) { t.Fatal("the room did not ask whether its task is a program's") } drive(t, a, cmd()) - if a.room != nil { - t.Fatal("the program's room stayed open") + if a.programOf() == nil || a.room.id != 7 { + t.Fatalf("the room did not become the program's room: room=%v", a.room != nil) + } + if a.railTaskPlanOn || a.taskSheet.planOn { + t.Fatal("the program's task was drawn as a page over the conversation") } - if !a.taskSheet.planOn || !a.taskPlanIsProgram() { - t.Fatal("the program's page did not replace the room") + if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") { + t.Fatalf("the room does not show the program's conversation:\n%s", text) } // AND AN ORDINARY TASK KEEPS ITS ROOM. plain := row @@ -592,7 +576,7 @@ func TestARoomOpenedOnAProgramsTaskBecomesItsPage(t *testing.T) { b, _ := planAppWith(t, nil, map[string]session.PlanTaskPage{"8": {Row: plain}}) b.room = b.newRoom(8, "ordinary") drive(t, b, b.roomProgramCheck(8)()) - if b.room == nil { - t.Fatal("an ordinary task's room was traded away") + if b.room == nil || b.programOf() != nil { + t.Fatal("an ordinary task's room was changed") } } diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index bb7f37307..0822c9d03 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -18,6 +18,7 @@ package tui3 // slice of [session.Agent] this file needs. import ( + "strconv" "strings" "time" @@ -1042,7 +1043,13 @@ func (a *app) taskPlanStopTaken(id string) tea.Cmd { return nil } if err != nil { - a.note(err.Error()) + // A stop that could not be given is said where the person is: on + // the program's room when that is what they stopped it from. + if a.programOf() != nil { + a.roomNote(err.Error()) + } else { + a.note(err.Error()) + } } else { a.railStamp++ } @@ -2089,6 +2096,19 @@ func (a *app) finishRailPlan(id string) tea.Cmd { } keys := a.railPlanPending.keys a.railPlanPending = railPlanPending{} + // A PROGRAM'S PAGE IS A ROOM IN THE CONVERSATION'S TAB (programroom.go), opened + // on the page this read just brought back — which is how a row the surface + // did not yet hold as a program's, and a run's own line under its row, reach + // it. The keys held for a page are dropped, as they are when the answer is a + // room: a room's box is a different receiver. + if a.taskPlanIsProgram() { + if n, err := strconv.ParseUint(planTaskIDWord(id), 10, 64); err == nil && n != 0 { + page := a.taskSheet.plan + a.closeTaskPlan() + a.openProgramRoom(n, page.Row.Title, page) + return a.takeRoomPump() + } + } a.railTaskPlanOn = true // THE SIDE LIST GIVES THE KEYBOARD BACK, because the page covers it: a list // holding keys nobody can see would spend the page's first `esc` on itself. diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 909ca2ecc..f025d51ee 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -13,7 +13,13 @@ func (a *app) workTab() (chatTab, bool) { // rows are the ones the task sheet already carries ([tasksMine.plan], read // off the loop); asking the agent here opened the plan store twice on // every frame of a conversation with a run in it. - rows := a.taskSheet.mine.plan + // + // A PROGRAM'S RUN HAS NO TAB. Its task opens inside this conversation's own + // tab, as a room, from its row, its card and every other door + // (programroom.go); a tab of its own drew itself selected beside the + // conversation's, and a press on the conversation's tab never left it. Only + // the rows of a run the belt switch drives make the tab. + rows := beltRows(a.taskSheet.mine.plan) if len(rows) == 0 { return chatTab{}, false } @@ -34,9 +40,21 @@ func (a *app) workTab() (chatTab, bool) { return chatTab{key: a.frontTabKey() + "#work", file: a.file, word: word, full: word, here: a.workTabOn, held: true, work: true}, true } +// beltRows is the rows of runs the belt switch drives, which are the only runs +// with a tab of their own: every row that names no program. +func beltRows(rows []session.PlanTaskRow) []session.PlanTaskRow { + var out []session.PlanTaskRow + for _, row := range rows { + if strings.TrimSpace(row.Program) == "" { + out = append(out, row) + } + } + return out +} + func (a *app) workTabStable() bool { var sig strings.Builder - for _, row := range a.taskSheet.mine.plan { + for _, row := range beltRows(a.taskSheet.mine.plan) { if planRunning(row.Status) { a.workTabSettled = "" return false @@ -59,6 +77,7 @@ func (a *app) workTabStable() bool { // ([app.taskSheetPlanAsk]), and until it does the pane draws the run's own row. func (a *app) openWorkTab() tea.Cmd { rows, ok := a.heldPlanRows() + rows = beltRows(rows) if !ok || len(rows) == 0 { return nil } @@ -112,16 +131,6 @@ func (a *app) workTabKey(msg tea.KeyPressMsg) tea.Cmd { func (a *app) workTabFrame(width, height int) []string { a.workTabStable() out := a.headRows(width, a.tabsRow(width), a.pal) - // A PROGRAM'S RUN SHOWS ITS PAGE. The rows below are the whole tasks place - // — every conversation this machine has held — and for a run the plan - // switch drives that was the run's own list; a program's run has one row - // and its page is its conversation with codeaf, so the tab drew a hundred - // conversations and the run's notes under them and never the program. - // Its tab draws the page the rail opens, under the tab strip. - if a.taskSheet.planOn && a.taskPlanIsProgram() { - page, _, _ := a.taskPlanFrame(width, max(height-len(out), 1)) - return append(out, page...) - } reading := a.tasksFiltered() reading.unfolded = true rows := reading.rows(width, a.pal) @@ -143,12 +152,6 @@ func (a *app) workTabFrame(width, height int) []string { } out = append(out, a.pal.dim(who+railSep)+a.pal.ink(note.Body)) } - // A PROGRAM'S RUN TAKES NO NOTE, so its tab offers no box: nothing typed - // there would reach the program, and the keys it would have typed are the - // page's reading keys and nothing else ([app.taskPlanKey]). - if a.taskPlanIsProgram() { - return out - } text := a.taskSheet.planNote.String() if strings.TrimSpace(text) == "" { text = taskPlanNoteWord From af77735e6451910214f6f7eb2f45305be7a2f67a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:29:56 -0400 Subject: [PATCH 088/195] session: a run's reading and a question asked of it are in the books, and a program's run buys none The card's four lines and a question asked of a run were worker-tier calls that reached the conversation's journal and nothing else: the status line, /cost and the spending ledger all left them out. A stub service that billed every call found three unbanked readings on every senior-dev run. Each paid answer is now banked, detached from any turn, whether or not it could be used. A run with no rows yet no longer pays a model to summarise an empty ask, two surfaces asking at once buy one reading, and a program's run buys none: its store holds one row, and its page is its conversation with codeaf. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/tasks.md | 7 ++ internal/session/plandb_program.go | 9 +++ internal/session/runask.go | 6 +- internal/session/runask_test.go | 21 ++++++ internal/session/runsummary.go | 30 +++++++- internal/session/runsummary_test.go | 108 +++++++++++++++++++++++++++- internal/session/session.go | 4 ++ 7 files changed, 182 insertions(+), 3 deletions(-) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 0ed36f6b8..c9def9e9f 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5580,3 +5580,10 @@ 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 its conversation with +codeaf, and its row already says its stage, so it has no four lines. diff --git a/internal/session/plandb_program.go b/internal/session/plandb_program.go index ffc58e6bb..1c1bf3e41 100644 --- a/internal/session/plandb_program.go +++ b/internal/session/plandb_program.go @@ -146,6 +146,15 @@ func (a *Agent) planCarriedPrograms() map[string]string { return map[string]string{a.beltRun.root: a.beltRun.delegate.Name} } +// planRootIsProgram answers whether a run's root task was handed to a program: +// its record folder holds the program's record, or the conversation's live run +// carries a program for it before that record has reached the disk. +func (a *Agent) planRootIsProgram(store *plandb.Store, rootID string) bool { + id := planTaskID(rootID) + _, ok := planProgramRecord(filepath.Dir(store.Path()), id, a.planCarriedPrograms()[id]) + return ok +} + // planProgramPage reads one task's program and conversation for its page, or // nil for a task that is not a program's: no record in its folder, no name the // live run carries for it, and no conversation log. diff --git a/internal/session/runask.go b/internal/session/runask.go index 9c8797309..6622e108a 100644 --- a/internal/session/runask.go +++ b/internal/session/runask.go @@ -81,10 +81,14 @@ func (a *Agent) AskRun(ctx context.Context, rootID, question string, earlier []R func (a *Agent) runAskCalls(ctx context.Context, rootID string, messages []ai.Message) (RunAskAnswer, error) { tool := ai.ToolDefinition{Type: "function", Function: ai.ToolFunction{Name: "read_task", Description: "Read one task from this run", Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"id": map[string]interface{}{"type": "string"}}, "required": []string{"id"}, "additionalProperties": false}}} for round := 0; round < 3; round++ { - response, _, err := a.callRole(ctx, roles.RoleWorker, a.Model(), messages, ai.WithTools([]ai.ToolDefinition{tool})) + response, called, err := a.callRole(ctx, roles.RoleWorker, a.Model(), messages, ai.WithTools([]ai.ToolDefinition{tool})) if err != nil { return RunAskAnswer{}, err } + // EVERY ROUND IS PAID FOR, the read_task round as much as the answer, and + // is banked detached because a person asked a page, not a turn + // (runsummary.go says the same of the card's lines). + a.addDetachedUsageAs(response, called, 1, string(roles.RoleWorker)) if len(response.Choices) == 0 { return RunAskAnswer{}, errors.New("run ask returned no answer") } diff --git a/internal/session/runask_test.go b/internal/session/runask_test.go index 3caf27017..9588784bd 100644 --- a/internal/session/runask_test.go +++ b/internal/session/runask_test.go @@ -2,6 +2,7 @@ package session import ( "context" + "math" "path/filepath" "strings" "sync" @@ -188,3 +189,23 @@ func TestRunAskAnswerIsReadThroughAFenceAndANoteIsRecognised(t *testing.T) { t.Fatalf("a recognised steer = %#v, want a note and no answer", note) } } + +// A QUESTION ASKED OF A RUN IS PAID FOR, SO IT IS IN THE BOOKS, every round of +// it: the read_task round and the answer alike, which reached the journal and +// nothing else until this test. +func TestAQuestionAskedOfARunIsInTheConversationsBooks(t *testing.T) { + agent, _, _ := runAskFixture(t, + func(context.Context, []ai.Message) (*ai.Response, error) { + response := toolResponse("c1", "read_task", `{"id":"t-child"}`) + cost := 0.01 + response.Usage = &ai.Usage{PromptTokens: 10, CompletionTokens: 5, Cost: &cost} + return response, nil + }, + pricedText(`{"text":"It changed the lookup.","from":[{"id":"t-child","title":"Write handler","step_start":3,"step_end":14}]}`, 0.02)) + if _, err := agent.AskRun(context.Background(), "t-root", "what changed?", nil); err != nil { + t.Fatal(err) + } + if got := agent.Usage().CostUSD; math.Abs(got-0.03) > 1e-9 { + t.Fatalf("the conversation's books hold $%.4f, want both rounds' $0.03", got) + } +} diff --git a/internal/session/runsummary.go b/internal/session/runsummary.go index ae9ec1401..b5fc05ebc 100644 --- a/internal/session/runsummary.go +++ b/internal/session/runsummary.go @@ -66,6 +66,16 @@ func (a *Agent) RefreshRunSummary(ctx context.Context, rootID string, lastLook t } stored, had := readRunSummary(store, rootID) family := runSummaryFamily(store, rootID) + // A RUN WITH NOTHING IN IT BUYS NO READING, and neither does a program's. + // The first refresh of a run could arrive before its store held the task, + // and a model was paid to summarise an empty ask and no rows. A program's + // run holds one task and a live stage, and its page is its conversation with + // codeaf, so four model-written lines about that one row would say again, + // for money, what the row already says (plandb_program.go). + if len(family) == 0 || a.planRootIsProgram(store, rootID) { + closeStore() + return stored.Summary, had + } questions := a.runSummaryQuestions(family) stamp := runSummaryStamp(family, questions) if had && stored.Stamp == stamp { @@ -83,11 +93,29 @@ func (a *Agent) RefreshRunSummary(ctx context.Context, rootID string, lastLook t closeStore() return stored.Summary, had } + // ONE READING AT A TIME FOR ONE RUN. Two surfaces that ask in the same + // moment (a window's own refresh and a page it just opened) both found the + // reading stale and both paid for one; the second keeps the last reading. + if _, busy := a.runSummaryBusy.LoadOrStore(rootID, struct{}{}); busy { + closeStore() + return stored.Summary, had + } + defer a.runSummaryBusy.Delete(rootID) input := runSummaryInput(family, questions, rootID, lastLook, a.summaryNow(), stored.Summary) closeStore() - response, _, err := a.callRole(ctx, roles.RoleWorker, a.model, []ai.Message{ + response, called, err := a.callRole(ctx, roles.RoleWorker, a.model, []ai.Message{ textMessage("system", runSummaryPrompt), textMessage("user", input), }, ai.WithMaxTokens(320)) + // THE READING IS PAID FOR WHETHER OR NOT IT CAN BE USED, so it is banked + // before it is read, the way every other errand's answer is (caption.go, + // title.go). It is DETACHED: a surface asked for it, not a turn, so it must + // not move whichever turn happens to be running ([Agent.addDetachedUsageAs]). + // Until this line the card's lines reached the journal and nothing else — a + // stub service that billed every call found three unbanked calls on every + // senior-dev run. + if err == nil && response != nil { + a.addDetachedUsageAs(response, called, 1, string(roles.RoleWorker)) + } if err != nil || response == nil || len(response.Choices) == 0 { return stored.Summary, had } diff --git a/internal/session/runsummary_test.go b/internal/session/runsummary_test.go index 10483abad..9ac5d8fb8 100644 --- a/internal/session/runsummary_test.go +++ b/internal/session/runsummary_test.go @@ -3,16 +3,19 @@ package session import ( "context" "errors" + "math" "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" ) -func runSummaryFixture(t *testing.T, completer *scriptedCompleter) (*Agent, *plandb.Store, time.Time) { +func runSummaryFixture(t *testing.T, completer *scriptedCompleter, more ...func(*Config)) (*Agent, *plandb.Store, time.Time) { t.Helper() t.Setenv("CODEAF_TASK_BELT", "bash") dir := t.TempDir() @@ -28,6 +31,9 @@ func runSummaryFixture(t *testing.T, completer *scriptedCompleter) (*Agent, *pla agent, _ := newTestAgent(t, completer, func(c *Config) { c.Place = Place{Dir: dir} c.clock = func() time.Time { return now } + for _, apply := range more { + apply(c) + } }) return agent, store, now } @@ -161,3 +167,103 @@ func TestRunSummaryStampAndInputCarryTheHeldQuestions(t *testing.T) { t.Fatalf("a run never looked at must say so:\n%s", input) } } + +// A RUN'S READING IS PAID FOR, SO IT IS IN THE BOOKS. The card's lines are a +// worker-tier call, and until this test they reached the conversation's journal +// and nowhere else: the status line, `/cost` and the machine's spending ledger +// all left them out, which a stub service that billed every call caught at +// three unbanked calls on every senior-dev run. +func TestARunSummaryIsInTheConversationsBooksAndTheLedger(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + client := &scriptedCompleter{steps: []step{pricedText("what: w\nsince: s\nnow: n\nnext: Nothing needs you.", 0.0123)}} + agent, _, _ := runSummaryFixture(t, client, func(c *Config) { c.usageLedger = ledger }) + if _, ok := agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}); !ok { + t.Fatal("the refresh stored no summary") + } + if got := agent.Usage().CostUSD; math.Abs(got-0.0123) > 1e-9 { + t.Fatalf("the conversation's books hold $%.4f, want the reading's $0.0123", got) + } + FlushUsage() + lines, err := ReadUsage(ledger, time.Time{}) + if err != nil { + t.Fatalf("read the ledger: %v", err) + } + if len(lines) != 1 || math.Abs(lines[0].USD-0.0123) > 1e-9 { + t.Fatalf("ledger = %+v, want one row of $0.0123", lines) + } +} + +// AN ANSWER THAT CANNOT BE READ WAS STILL PAID FOR. The last good reading +// stands on the card, and the money for the one that could not be used is in +// the books all the same. +func TestARunSummaryAnswerThatCannotBeReadIsStillInTheBooks(t *testing.T) { + client := &scriptedCompleter{steps: []step{pricedText("not the four lines", 0.004)}} + agent, _, _ := runSummaryFixture(t, client) + if _, ok := agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}); ok { + t.Fatal("an unreadable answer was stored as a summary") + } + if got := agent.Usage().CostUSD; math.Abs(got-0.004) > 1e-9 { + t.Fatalf("the conversation's books hold $%.4f, want the unreadable answer's $0.004", got) + } +} + +// A PROGRAM'S RUN BUYS NO READING. Its store holds one task and a live stage, +// its page is its conversation with codeaf, and four model-written lines about +// one row would say again, for money, what the row already says. +func TestAProgramsRunBuysNoRunSummary(t *testing.T) { + client := &scriptedCompleter{steps: []step{pricedText("what: w\nsince: s\nnow: n\nnext: n", 0.0123)}} + agent, store, _ := runSummaryFixture(t, client) + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(store.Path()), planRootID), delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + if _, ok := agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}); ok { + t.Fatal("a program's run was given a summary") + } + if client.requests() != 0 { + t.Fatalf("a program's run asked a model %d times for a summary, want none", client.requests()) + } +} + +// A RUN WITH NOTHING IN IT BUYS NO READING. The first refresh of a run used to +// be sent before its store held the task, with an empty ask and no rows, and a +// model was paid to summarise nothing. +func TestARunSummaryOfNoRowsMakesNoCall(t *testing.T) { + client := &scriptedCompleter{steps: []step{pricedText("what: w\nsince: s\nnow: n\nnext: n", 0.0123)}} + agent, _, _ := runSummaryFixture(t, client) + if _, ok := agent.RefreshRunSummary(context.Background(), "no-such-root", time.Time{}); ok { + t.Fatal("a run with no rows was given a summary") + } + if client.requests() != 0 { + t.Fatalf("a run with no rows asked a model %d times, want none", client.requests()) + } +} + +// ONE READING AT A TIME FOR ONE RUN. Two surfaces asking in the same moment +// (a window's own refresh and the page it just opened) both found the reading +// stale and both paid for one; the second now keeps the last reading. +func TestTwoRefreshesOfOneRunAtOnceBuyOneReading(t *testing.T) { + release := make(chan struct{}) + entered := make(chan struct{}, 2) + client := &scriptedCompleter{steps: []step{func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + entered <- struct{}{} + <-release + cost := 0.0123 + response := textResponse("what: w\nsince: s\nnow: n\nnext: n") + response.Usage.Cost = &cost + return response, nil + }}} + agent, _, _ := runSummaryFixture(t, client) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}) + }() + <-entered + agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}) + close(release) + wg.Wait() + if client.requests() != 1 { + t.Fatalf("two refreshes at once asked a model %d times, want one", client.requests()) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 50005d0c1..168e1d044 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3196,6 +3196,10 @@ type Agent struct { // held. beltMu sync.Mutex beltRun *beltRun + // runSummaryBusy holds the roots whose card reading is being bought right + // now ([Agent.RefreshRunSummary]), so a second surface that asks in the same + // moment keeps the last reading rather than paying for a second one. + runSummaryBusy sync.Map // taskAnswers is the proposals a person owes an answer to, keyed by the id // the EventTaskProposal carried. It is consent's pending-id machinery for a // question whose CLOCK can be held: the wait ends on an answer, on an active From 3164fee73a059e3be9d055817d5ddc69cf5176a6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:33:30 -0400 Subject: [PATCH 089/195] delegate: a program's stage is shown in the word it gives a person senior-dev's page and row read agent-runtime for the whole of its work, and run-contract, router-cancellation and patch-summary around it: its machinery's names for its phases, on a surface that draws no machinery vocabulary. A program now says what a person reads for each stage (Delegate.StageWords); senior-dev's are starting, reading the brief, working, handing in its work, checking its work and finishing, a test holds every stage it can report to a word, and a stage with no word keeps the one already shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delegate/delegate.go | 9 +++++ internal/manual/chat/senior-dev.md | 5 +++ internal/manual/chat/worker-harness.md | 5 +-- internal/run/delegateworker.go | 19 ++++++++-- internal/run/stage_words_internal_test.go | 40 +++++++++++++++++++++ internal/seniordev/seniordev.go | 28 +++++++++++++-- internal/seniordev/stagewords_test.go | 42 +++++++++++++++++++++++ 7 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 internal/run/stage_words_internal_test.go create mode 100644 internal/seniordev/stagewords_test.go diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index e58a63faf..f9042c065 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -113,6 +113,15 @@ type Delegate struct { // 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 // Default is the command a bare brief runs: `/<name> <brief>` in the chat // and `codeaf <name> <brief>` in a shell. It names one of Commands. Default string diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index af36c3347..4d4ef4d5b 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -32,6 +32,11 @@ it pins the stage, the spend of the run's ceiling, the number of calls and how l 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 stage is said in plain words.** On the row and on that line senior-dev's stage +reads `starting`, `reading the brief`, `working`, `handing in its work`, `checking its +work` or `finishing` — never senior-dev's own names for its inner phases. The whole of its +work on the change, every model call and tool included, reads `working`. + `esc`, a press on the conversation's tab, or a press on `Home` 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 diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 53d1d1793..1d288c6e0 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -208,7 +208,7 @@ 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 ─ +─ working · $1.24 of $5.00 · 3 calls · 14m 3s ────────────────────── Stop ─ <program> rewrite the auth middleware to use the new session store deepseek-v4-flash I'll read the middleware and the store first. ▤ read internal/auth/middleware.go @@ -236,7 +236,8 @@ conversation: the program on one side, like a very particular person asking code things, and the model that answered on the other. The line over the conversation stays put while you scroll: the stage the program says it -is in (the task's own word, such as `running` or `done`, when there is none), what the +is in, in the word the program gives a person for it rather than its own name for the +stage (the task's own word, such as `running` or `done`, when there is none), 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 is the one the side list and the landed card diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 952686c1c..bf4ba5689 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -197,10 +197,23 @@ func (s *delegateSink) Hello(h delegate.Hello) { func (s *delegateSink) Stage(stage, status string) { // THE LIVE STEP IS THE PROGRAM'S PHASE, numbered after the last step - // recorded, so the row reads "senior-dev: implement · running" while the - // program is inside that phase and the count on the row stays the steps'. + // recorded, so the row reads "senior-dev: working" while the program is + // inside that phase and the count on the row stays the steps'. + // + // 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. label := s.name + ": " + stage - if status != "" { + if words := s.worker.program.StageWords; words != nil { + word := strings.TrimSpace(words[stage]) + if word == "" { + return + } + label = s.name + ": " + word + } else if status != "" { label += " · " + status } _ = s.worker.store.SetLive(s.taskID, s.steps+1, label) diff --git a/internal/run/stage_words_internal_test.go b/internal/run/stage_words_internal_test.go new file mode 100644 index 000000000..5da3d1a94 --- /dev/null +++ b/internal/run/stage_words_internal_test.go @@ -0,0 +1,40 @@ +package run + +import ( + "path/filepath" + "testing" + + "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(stage[0], 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) + } +} diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 1430ad987..27bfdda07 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -34,6 +34,23 @@ import ( ) // Program is senior-dev as codeaf carries it. +// stageWords is senior-dev's stage names (app.Stages) in a person's words. +var stageWords = map[string]string{ + "bootstrap": "starting", + "run-contract": "starting", + "intake": "reading the brief", + "landing": "starting", + "implement": "working", + "agent-runtime": "working", + "compaction-capacity": "working", + "router-cancellation": "working", + "submit": "handing in its work", + "verification": "checking its work", + "ship": "finishing", + "patch-summary": "finishing", + "agent-summary": "finishing", +} + var Program = delegate.Delegate{ Name: "senior-dev", Summary: "an autonomous agent for one large, well-specified code change", @@ -55,9 +72,14 @@ var Program = delegate.Delegate{ // conversation (app's seniorDevDataDirectory, which git never sees). Notes: ".senior-dev", CrewFlags: crewFlags, - Default: "run", - Page: "senior-dev", - Commands: []delegate.Command{runCommand}, + // What a person reads while it works, one plain word per phase: getting + // ready, doing the work (every inner stage of a model turn included), + // handing it in, checking it, wrapping up. A test holds every stage in + // app.Stages to a word. + StageWords: stageWords, + Default: "run", + Page: "senior-dev", + Commands: []delegate.Command{runCommand}, } // crewFlags is the conversation's crew as senior-dev's own flags: the working diff --git a/internal/seniordev/stagewords_test.go b/internal/seniordev/stagewords_test.go new file mode 100644 index 000000000..636445c33 --- /dev/null +++ b/internal/seniordev/stagewords_test.go @@ -0,0 +1,42 @@ +package seniordev + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// EVERY STAGE senior-dev CAN REPORT HAS A WORD A PERSON READS, and no word is +// machinery. app.Stages is the closed list of what the run can emit (its own +// test holds it to the source), so a stage added there without a word here +// fails now rather than showing its name on somebody's task page. +func TestEveryStageSeniorDevReportsHasAPersonsWord(t *testing.T) { + banned := []string{"auditor", "verdict", "verified", "refuted", "runtime", "contract", "router"} + for _, stage := range app.Stages { + word := strings.TrimSpace(Program.StageWords[stage]) + if word == "" { + t.Errorf("stage %q has no word a person reads", stage) + continue + } + for _, bad := range banned { + if strings.Contains(word, bad) { + t.Errorf("stage %q reads %q, which says %q", stage, word, bad) + } + } + } + for stage := range Program.StageWords { + if !contains(app.Stages, stage) { + t.Errorf("a word is kept for %q, which senior-dev never reports", stage) + } + } +} + +func contains(list []string, want string) bool { + for _, item := range list { + if item == want { + return true + } + } + return false +} From 4d9f6b18a67567261e4b40ba44bc246e0d6ade85 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:41:47 -0400 Subject: [PATCH 090/195] session: a run codeaf closed under reads ended on its page and its row, with its time stopped Killing the engine under a real senior-dev run left its page reading 'working' with no time, and its side-list row waiting on a person: closing writes the run's ending before it cuts the program, and a program cut that way never clears the stage it was in, while the reopen restored the row as interrupted and nothing settled it. A task the store has ended now has no live stage, its clock stops where the store ended it, and the reopen settles a program's interrupted row as ended in codeaf's sentence, not a fault, at that same instant. A run whose task the store calls done is left alone: its work was never landed, and that is a person's call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 6 ++- internal/session/task_run_belt.go | 36 ++++++++++++++- internal/session/task_run_clock.go | 16 ++++++- internal/session/task_run_orphan_test.go | 58 ++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 9 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 4d4ef4d5b..a028e407c 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -375,8 +375,10 @@ tests could run, or to where it began. senior-dev runs inside the codeaf that started it and ends with it. When you quit codeaf or close the conversation, when the engine is stopped (`codeaf engine --stop`, a signal), -or when codeaf crashes while senior-dev is working, the run is over: its page reads -`incomplete` with `codeaf closed while senior-dev was running` beside it. +or when codeaf crashes while senior-dev is working, the run is over: its page and its row +on the side list read `incomplete` with `codeaf closed while senior-dev was running` beside +it, no stage, and nothing waiting on you — it is not a fault, and there is nothing to carry +on. **The run ends where it was last seen working**: the end of its last model call, its last charge, or its store's last change, whichever is latest. So the time and the spend on its diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 3e43c06a7..0462c2cfd 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -807,10 +807,44 @@ func (a *Agent) endInterruptedProgramRun() { if err != nil { return } - if kept, found := runRowOf(g, row); !found || kept.State != TaskInterrupted { + kept, found := runRowOf(g, row) + if !found || kept.State != TaskInterrupted { return } endOrphanedProgramRun(store) + a.settleInterruptedProgramRow(g, store, kept) +} + +// settleInterruptedProgramRow settles the row a reopen restored as interrupted +// once its program's run has ended in its store, whether this reopen ended it +// or the closing did first ([Agent.cutBeltRun]). +// +// A PROGRAM'S RUN IS ONE NOTHING CAN CARRY ON, so a row left interrupted — which +// the side list draws as waiting on a person — says something the page does +// not: the page reads it ended, in codeaf's sentence, with its time stopped. +// The row now says the same, not as a fault, ending where the store ended it. +// A run whose task the store calls done is left as it came back: its program +// finished, but the work was never landed, and that is a person's call. +func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, kept TaskNotice) { + root := store.Task(store.RootID()) + if root == nil || (root.Status != plandb.StatusFailed && root.Status != plandb.StatusCancelled) { + return + } + record, ok := delegate.ReadProgram(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())) + if !ok { + return + } + settled := kept + settled.State = TaskFailed + settled.Report = strings.TrimSpace(root.Error) + settled.Ending = TaskEndingProgram + if settled.Report == "" || settled.Report == programClosedSentence(record.Name) { + settled.Report = programClosedSentence(record.Name) + settled.Ending = TaskEndingInterrupted + } + settled.EndedAt = root.CompletedAt + settled.Elapsed = 0 + a.publishRunRow(g, settled) } // holdsInterruptedRun says whether any run row this graph holds came back diff --git a/internal/session/task_run_clock.go b/internal/session/task_run_clock.go index 0d2762ab2..e8e6a528c 100644 --- a/internal/session/task_run_clock.go +++ b/internal/session/task_run_clock.go @@ -196,7 +196,21 @@ func (c planRunClocks) apply(row *PlanTaskRow, dir string, task *plandb.Task, ro row.Ended = runClockEnd(started, record, time.Time{}) } } - if task.ID != root || terminalStoreStatus(task.Status) || c.live == task.ID { + // A TASK THE STORE HAS ENDED IS IN NO STAGE, AND ITS CLOCK HAS STOPPED. + // Closing a conversation writes its program's ending before it cuts the + // program, and a program cut that way never lives to clear the stage it was + // in, so the page read `working` with no time over a run nothing was + // driving (found by killing the engine under a real senior-dev run). A live + // step on an ended task is left over, and when neither the row nor the + // program's record says when the run ended, the store's own ending does. + if terminalStoreStatus(task.Status) { + row.Live, row.Stage = plandb.LiveStep{}, "" + if !row.Started.IsZero() && row.Ended.IsZero() && !task.CompletedAt.Before(row.Started) { + row.Ended = task.CompletedAt + } + return + } + if task.ID != root || c.live == task.ID { return } row.Status = string(plandb.StatusFailed) diff --git a/internal/session/task_run_orphan_test.go b/internal/session/task_run_orphan_test.go index f91ef0b31..f0bdc50e3 100644 --- a/internal/session/task_run_orphan_test.go +++ b/internal/session/task_run_orphan_test.go @@ -28,9 +28,9 @@ import ( // program's run leaves when its process goes away mid-run: its run task still // open, the program's record beside it, a charge on the ledger, and a model // call that came back at lastSeen. It answers lastSeen. -func orphanProgramStore(t *testing.T, path, rootID, brief string) time.Time { +func orphanProgramStore(t *testing.T, path, rootID, brief string, chat ...string) time.Time { t.Helper() - store, err := plandb.Open(path, "the dead run", rootID, "the dead run", brief) + store, err := plandb.Open(path, "the dead run", rootID, "the dead run", brief, chat...) if err != nil { t.Fatalf("seed the dead run's store: %v", err) } @@ -231,14 +231,26 @@ func TestAReopenedConversationEndsTheProgramRunItsLastProcessLeftOpen(t *testing g := life.graph() id := g.reserve() rootID := strconv.FormatUint(id, 10) - lastSeen := orphanProgramStore(t, g.planPath(), rootID, "the brief") + lastSeen := orphanProgramStore(t, g.planPath(), rootID, "the brief", g.planChat()) // The row as the process that died last wrote it down: running. life.publishRunRow(g, TaskNotice{ID: id, Title: "the dead run", State: TaskRunning, StartedAt: time.Now()}) _ = life.Close() reopened := open() - if rows := reopened.graph().runRows(id); len(rows) != 1 || rows[0].State != TaskInterrupted { - t.Fatalf("the row came back as %+v, want interrupted", rows) + // THE ROW SETTLES WITH ITS STORE. A program's run is one nothing can carry + // on, so the row the reopen restored as interrupted — which the side list + // drew as `?`, waiting on a person — is settled where the page already + // stood: ended, in codeaf's sentence, not a fault, at the instant it was + // last seen, with its span. + rows := reopened.graph().runRows(id) + if len(rows) != 1 || rows[0].State != TaskFailed || rows[0].Ending != TaskEndingInterrupted || + rows[0].Report != "codeaf closed while fake was running" || !rows[0].EndedAt.Equal(lastSeen) { + t.Fatalf("the row came back as %+v, want it ended as interrupted, in codeaf's sentence, at %v", rows, lastSeen) + } + page, ok := reopened.PlanTaskPage(rootID) + if !ok || page.Row.Status != string(plandb.StatusFailed) || page.Row.Stage != "" || !page.Row.Live.Empty() || + page.Row.Ended.IsZero() || !page.Row.Ended.Equal(lastSeen) { + t.Fatalf("the dead run's page row = %+v (%v), want it ended at %v with no live stage", page.Row, ok, lastSeen) } store := beltRunStoreAt(t, place) defer store.Close() @@ -253,3 +265,39 @@ func TestAReopenedConversationEndsTheProgramRunItsLastProcessLeftOpen(t *testing t.Fatalf("opening a conversation archived its store: %v", err) } } + +// A RUN CODEAF CLOSED UNDER READS ENDED ON ITS PAGE, NOT WORKING. Closing the +// conversation writes the run's ending before it cuts the program, and the +// program never lives to clear the stage it was in, so the page's line read +// `working` with no time over a run nothing was driving (found by killing the +// engine under a real senior-dev run). A task the store has ended has no live +// stage, and its time ends where the store says it ended. +func TestAProgramsTaskTheStoreHasEndedHasNoLiveStageAndStopsItsClock(t *testing.T) { + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: place} + config.Delegates = testPrograms("fake") + }) + g := agent.graph() + id := g.reserve() + rootID := strconv.FormatUint(id, 10) + orphanProgramStore(t, g.planPath(), rootID, "the brief", g.planChat()) + store := beltRunStoreAt(t, place) + if err := store.SetLive(rootID, 3, "fake: working"); err != nil { + t.Fatal(err) + } + endedAt := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond) + if err := store.FailRootAt("codeaf closed while fake was running", endedAt); err != nil { + t.Fatal(err) + } + _ = store.Close() + agent.publishRunRow(g, TaskNotice{ID: id, Title: "the dead run", State: TaskRunning, StartedAt: endedAt.Add(-5 * time.Minute)}) + page, ok := agent.PlanTaskPage(rootID) + if !ok || page.Row.Stage != "" || !page.Row.Live.Empty() { + t.Fatalf("an ended program task's page row = %+v (%v), want no live stage", page.Row, ok) + } + if page.Row.Ended.IsZero() { + t.Fatalf("an ended program task's page row has no end, want its clock stopped where the store ended it") + } +} From 4e5c3724e8e6514c18f5a097b6c1f5610c0d473f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:45:02 -0400 Subject: [PATCH 091/195] session: a run row codeaf closed under is kept, not the reason the whole task list is thrown away A row that was moving when its process went away comes back interrupted, and the next checkpoint wrote it down as interrupted, a state the reader refuses: the reopen after that set the whole file aside and the conversation lost every task it had, finished ones included (found by killing the engine under a real senior-dev run and opening the conversation twice; dev writes run rows the same way). An interrupted row is now written as the moving row it was, and a file an earlier build wrote with one is read rather than refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/task_run_clock_test.go | 73 +++++++++++++++++++++++++ internal/session/task_store.go | 18 +++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go index 7c2afd85e..20470b0df 100644 --- a/internal/session/task_run_clock_test.go +++ b/internal/session/task_run_clock_test.go @@ -391,3 +391,76 @@ func TestTheTasksToolSeesAProgramsRunAndSaysHowLongItTook(t *testing.T) { t.Fatal("the note the conversation was handed at the landing does not say how long the run took") } } + +// AN INTERRUPTED ROW SURVIVES THE NEXT CHECKPOINT. A row that was moving when +// its process went away comes back interrupted, and the next checkpoint wrote +// it down as `interrupted` — a state the reader refuses — so the reopen after +// that set the whole file aside and the conversation lost every task it had, +// the finished ones with it (found by killing the engine under a real +// senior-dev run, then opening the conversation twice). +func TestAnInterruptedRunRowSurvivesTheNextCheckpoint(t *testing.T) { + dir, workspace := t.TempDir(), t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + }) + g := agent.graph() + began := time.Date(2026, time.September, 24, 10, 35, 54, 0, time.UTC) + done := TaskNotice{ID: g.reserve(), Title: "the finished run", State: TaskDone, StartedAt: began, EndedAt: began.Add(time.Minute)} + moving := TaskNotice{ID: g.reserve(), Title: "the run codeaf closed under", State: TaskRunning, StartedAt: began.Add(2 * time.Minute)} + agent.publishRunRow(g, done) + agent.publishRunRow(g, moving) + journal := agent.file.journalPath() + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + reopen := func() *Agent { + t.Helper() + back, err := newAgent(Config{Workspace: workspace, Model: "test/model", System: "SYSTEM", SessionFile: journal}, &scriptedCompleter{}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + return back + } + first := reopen() + if rows := first.graph().runRows(moving.ID); len(rows) != 1 || rows[0].State != TaskInterrupted { + t.Fatalf("the moving row came back as %+v, want interrupted", rows) + } + // Anything that writes the checkpoint again: here, one more row. + later := TaskNotice{ID: first.graph().reserve(), Title: "a later run", State: TaskDone, StartedAt: began.Add(time.Hour), EndedAt: began.Add(2 * time.Hour)} + first.publishRunRow(first.graph(), later) + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + second := reopen() + defer second.Close() + for _, want := range []TaskNotice{done, moving, later} { + if rows := second.graph().runRows(want.ID); len(rows) != 1 || rows[0].Title != want.Title { + t.Fatalf("row %d (%s) came back as %+v after the second reopen", want.ID, want.Title, rows) + } + } + if rows := second.graph().runRows(moving.ID); rows[0].State != TaskInterrupted { + t.Fatalf("the interrupted row came back as %s, want interrupted still", rows[0].State) + } +} + +// A FILE AN EARLIER BUILD WROTE WITH AN INTERRUPTED ROW STILL LOADS, rather than +// being set aside whole: the row is read as the moving row it was. +func TestACheckpointHoldingAnInterruptedRunRowIsNotRefused(t *testing.T) { + document := taskDocument{Type: "tasks", Version: 1, Seq: 2, Runs: []runRecord{ + {ID: 1, Title: "done", State: TaskDone}, + {ID: 2, Title: "cut", State: TaskInterrupted}, + }} + content, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + back, err := decodeTasks(content) + if err != nil { + t.Fatalf("a checkpoint with an interrupted run row was refused: %v", err) + } + if len(back.Runs) != 2 { + t.Fatalf("the checkpoint came back with %d rows, want both", len(back.Runs)) + } +} diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 039fa88ef..1c4b39f07 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -1066,6 +1066,16 @@ func (g *TaskGraph) documentLocked() taskDocument { // They are a pair and they are next to each other so that a field added to one // is missing from the other in the same eyeful. func runRowRecord(notice TaskNotice) runRecord { + // AN INTERRUPTED ROW IS WRITTEN DOWN AS THE MOVING ROW IT WAS. Interrupted + // is what a reader makes of a row that was moving when its process went + // away ([runRowNotice]); it is not a state this file holds, and the row was + // written back verbatim, so the next reopen refused the whole checkpoint and + // the conversation lost every task it had, finished ones included. Written + // as running, it comes back interrupted again, and an older build reads it. + state := notice.State + if state == TaskInterrupted { + state = TaskRunning + } return runRecord{ ID: notice.ID, Run: notice.Run, @@ -1073,7 +1083,7 @@ func runRowRecord(notice TaskNotice) runRecord { Parent: notice.Parent, Title: notice.Title, Kind: notice.Kind, - State: notice.State, + State: state, Stopped: notice.Stopped, Report: notice.Report, Model: notice.Model, @@ -1409,7 +1419,11 @@ func decodeTasks(content []byte) (taskDocument, error) { return taskDocument{}, fmt.Errorf("run row %d is also a node", record.ID) case drawn[record.ID]: return taskDocument{}, fmt.Errorf("run row %d appears twice", record.ID) - case !validTaskState(record.State): + // A FILE AN EARLIER BUILD WROTE WITH AN INTERRUPTED ROW is read, not set + // aside whole: that build wrote the row back as the reader had drawn it + // ([runRowRecord] says why that no longer happens), and refusing the + // file for it cost the conversation every task it had. + case !validTaskState(record.State) && record.State != TaskInterrupted: return taskDocument{}, fmt.Errorf("run row %d is in state %q", record.ID, record.State) case record.ElapsedMS < 0: return taskDocument{}, fmt.Errorf("run row %d has a negative elapsed", record.ID) From 21af51b55ae10641a02979b20b764be75b2e21d7 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:54:01 -0400 Subject: [PATCH 092/195] session: a program row's clock is read in three steps, and a run row's state has a rule of its own The ended-task step made the clock's one function too long for the task engine's complexity gate, and the interrupted-row rule lengthened the checkpoint reader; each now lives in a function of its own. The stage-word test carries the build tag every senior-dev file does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/seniordev/stagewords_test.go | 2 + internal/session/task_run_clock.go | 61 ++++++++++++++++++--------- internal/session/task_store.go | 15 ++++--- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/internal/seniordev/stagewords_test.go b/internal/seniordev/stagewords_test.go index 636445c33..31c8ebae8 100644 --- a/internal/seniordev/stagewords_test.go +++ b/internal/seniordev/stagewords_test.go @@ -1,3 +1,5 @@ +//go:build !windows + package seniordev import ( diff --git a/internal/session/task_run_clock.go b/internal/session/task_run_clock.go index e8e6a528c..25bd40d10 100644 --- a/internal/session/task_run_clock.go +++ b/internal/session/task_run_clock.go @@ -183,36 +183,55 @@ func (c planRunClocks) apply(row *PlanTaskRow, dir string, task *plandb.Task, ro if row == nil || task == nil || row.Program == "" { return } + c.pair(row, dir, task) + if terminalStoreStatus(task.Status) { + planEndedRow(row, task) + return + } + if task.ID != root || c.live == task.ID { + return + } + planUndrivenRow(row, dir, task) +} + +// pair puts the run's one pair on the row: the hand-off, and the run row's +// settled end or, before it settles, the program's recorded exit. +func (c planRunClocks) pair(row *PlanTaskRow, dir string, task *plandb.Task) { record, _ := delegate.ReadProgram(plandb.TaskDir(dir, task.ID)) kept := c.rows[task.ID] started := kept.StartedAt if started.IsZero() { started = record.StartedAt } - if !started.IsZero() { - row.Started = started - row.Ended = kept.EndedAt - if row.Ended.IsZero() { - row.Ended = runClockEnd(started, record, time.Time{}) - } - } - // A TASK THE STORE HAS ENDED IS IN NO STAGE, AND ITS CLOCK HAS STOPPED. - // Closing a conversation writes its program's ending before it cuts the - // program, and a program cut that way never lives to clear the stage it was - // in, so the page read `working` with no time over a run nothing was - // driving (found by killing the engine under a real senior-dev run). A live - // step on an ended task is left over, and when neither the row nor the - // program's record says when the run ended, the store's own ending does. - if terminalStoreStatus(task.Status) { - row.Live, row.Stage = plandb.LiveStep{}, "" - if !row.Started.IsZero() && row.Ended.IsZero() && !task.CompletedAt.Before(row.Started) { - row.Ended = task.CompletedAt - } + if started.IsZero() { return } - if task.ID != root || c.live == task.ID { - return + row.Started = started + row.Ended = kept.EndedAt + if row.Ended.IsZero() { + row.Ended = runClockEnd(started, record, time.Time{}) } +} + +// planEndedRow is a program's row the store has ended. +// +// A TASK THE STORE HAS ENDED IS IN NO STAGE, AND ITS CLOCK HAS STOPPED. +// Closing a conversation writes its program's ending before it cuts the +// program, and a program cut that way never lives to clear the stage it was +// in, so the page read `working` with no time over a run nothing was driving +// (found by killing the engine under a real senior-dev run). A live step on an +// ended task is left over, and when neither the row nor the program's record +// says when the run ended, the store's own ending does. +func planEndedRow(row *PlanTaskRow, task *plandb.Task) { + row.Live, row.Stage = plandb.LiveStep{}, "" + if !row.Started.IsZero() && row.Ended.IsZero() && !task.CompletedAt.Before(row.Started) { + row.Ended = task.CompletedAt + } +} + +// planUndrivenRow is a program's root the store still calls open while no run +// in this process holds it: ended at its last sign of life, with no stage. +func planUndrivenRow(row *PlanTaskRow, dir string, task *plandb.Task) { row.Status = string(plandb.StatusFailed) row.Live, row.Stage = plandb.LiveStep{}, "" if row.Ended.IsZero() { diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 1c4b39f07..6f774f218 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -1419,11 +1419,7 @@ func decodeTasks(content []byte) (taskDocument, error) { return taskDocument{}, fmt.Errorf("run row %d is also a node", record.ID) case drawn[record.ID]: return taskDocument{}, fmt.Errorf("run row %d appears twice", record.ID) - // A FILE AN EARLIER BUILD WROTE WITH AN INTERRUPTED ROW is read, not set - // aside whole: that build wrote the row back as the reader had drawn it - // ([runRowRecord] says why that no longer happens), and refusing the - // file for it cost the conversation every task it had. - case !validTaskState(record.State) && record.State != TaskInterrupted: + case !validRunRowState(record.State): return taskDocument{}, fmt.Errorf("run row %d is in state %q", record.ID, record.State) case record.ElapsedMS < 0: return taskDocument{}, fmt.Errorf("run row %d has a negative elapsed", record.ID) @@ -1439,6 +1435,15 @@ func decodeTasks(content []byte) (taskDocument, error) { return document, nil } +// validRunRowState is a run row's state as a checkpoint may hold it: a node's +// states, and interrupted too. A FILE AN EARLIER BUILD WROTE WITH AN +// INTERRUPTED ROW is read, not set aside whole: that build wrote the row back +// as the reader had drawn it ([runRowRecord] says why that no longer happens), +// and refusing the file for it cost the conversation every task it had. +func validRunRowState(state TaskState) bool { + return validTaskState(state) || state == TaskInterrupted +} + func validTaskState(state TaskState) bool { switch state { case TaskQueued, TaskRunning, TaskDone, TaskFailed, TaskUnverified: From 06564ad2fe2e05f55bba99059624c2545955c938 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:56:06 -0400 Subject: [PATCH 093/195] session, tui3: a finished run's time is rounded to the second wherever it is read The same 61.5-second senior-dev run read 'ran 1m 1s' in the note the chat was handed and on a page read off the store, and '1m 2s' on its card and room: the card rounded and the others cut. Every finished span is now rounded to the second; a running clock still counts whole seconds up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/task_run_clock.go | 4 ++++ internal/session/task_run_clock_test.go | 2 ++ internal/tui3/runclock_test.go | 14 ++++++++++++++ internal/tui3/taskconversation.go | 11 ++++++++++- 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/session/task_run_clock.go b/internal/session/task_run_clock.go index 25bd40d10..d1111fe6e 100644 --- a/internal/session/task_run_clock.go +++ b/internal/session/task_run_clock.go @@ -104,6 +104,10 @@ func runSpanWord(d time.Duration) string { if d < time.Second { return "" } + // ROUNDED TO THE SECOND, as the page and the card round it (tui3's + // taskNode.ranFor): the same 61.5-second run read `ran 1m 1s` in the note + // the chat was handed and `1m 2s` on the page it was reading about. + d = d.Round(time.Second) rungs := func(big int, bigUnit string, small int, smallUnit string) string { out := strconv.Itoa(big) + bigUnit if small == 0 { diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go index 20470b0df..3d3a985c6 100644 --- a/internal/session/task_run_clock_test.go +++ b/internal/session/task_run_clock_test.go @@ -36,6 +36,8 @@ func TestRunSpanWordSpellsASpanTheWayThePageDoes(t *testing.T) { {5 * time.Minute, "5m"}, {67*time.Minute + 34*time.Second, "1h 7m"}, {2 * time.Hour, "2h"}, + {61*time.Second + 500*time.Millisecond, "1m 2s"}, + {59*time.Second + 600*time.Millisecond, "1m"}, } { if got := runSpanWord(tc.d); got != tc.want { t.Errorf("runSpanWord(%v) = %q, want %q", tc.d, got, tc.want) diff --git a/internal/tui3/runclock_test.go b/internal/tui3/runclock_test.go index 4badea97a..7d91dd176 100644 --- a/internal/tui3/runclock_test.go +++ b/internal/tui3/runclock_test.go @@ -77,3 +77,17 @@ func TestALandedCardMeasuresTheRecordsOwnSpan(t *testing.T) { }) } } + +// A FINISHED SPAN READ OFF THE STORE IS ROUNDED LIKE EVERY OTHER. A page whose +// run this window holds no node for reads the row's own pair, and it cut a +// 61.5-second run to `1m 1s` while the card and the note the chat was handed +// said `1m 2s`. +func TestAStoredRowsFinishedSpanIsRoundedLikeTheCards(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + row := session.PlanTaskRow{ID: "t-9", Program: "senior-dev", Status: "done", + Started: started, Ended: started.Add(61*time.Second + 500*time.Millisecond)} + if got := a.taskPlanAge(row); got != "1m 2s" { + t.Fatalf("a finished 61.5-second run reads %q, want 1m 2s", got) + } +} diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go index d19b9f9f1..e90102b19 100644 --- a/internal/tui3/taskconversation.go +++ b/internal/tui3/taskconversation.go @@ -34,6 +34,7 @@ package tui3 import ( "strconv" "strings" + "time" "unicode/utf8" "github.com/charmbracelet/x/ansi" @@ -197,7 +198,15 @@ func (a *app) taskPlanAge(row session.PlanTaskRow) string { if end.Before(row.Started) { return "" } - return countUpWord(end.Sub(row.Started)) + span := end.Sub(row.Started) + // A FINISHED SPAN IS ROUNDED TO THE SECOND, as the card and the room round + // it ([taskNode.ranFor]) and as the note the chat is handed does, so a + // 61.5-second run reads `1m 2s` wherever it is read; a running clock counts + // whole seconds up. + if !row.Ended.IsZero() { + span = span.Round(time.Second) + } + return countUpWord(span) } // taskProgramBody is what a person reads on a program's page, under the pinned From 2438f4ed151344590af165204fda60c54227ce9c Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:37:01 -0400 Subject: [PATCH 094/195] session: a program's commits on its task branch survive the landing, and a stopped program's work goes to that branch A program that committed on the task's branch and then left HEAD elsewhere (senior-dev commits every write; a model that ran `git checkout --detach` to look at the baseline and was ended there) had that branch reset back to the start under a tree holding none of its work, and the branch, then empty, deleted with the only reference to its commits. The branch is now read before HEAD moves: when it holds commits the tree the program left was not built on, the tree is committed on top of them, the page and the conversation say its diff may undo them, and an empty branch is judged by the branch as the program left it, never by the tip the landing's own squash made. The stop committed on whatever branch HEAD was on, a person's own included, while its report named the task's branch, which held nothing; it counted only what was left uncommitted, so a run that committed every write read "it had changed nothing"; and it left empty branches. A stop now puts the copy back on the task's branch with the same guard, counts files from the copy's start, and deletes a branch that holds nothing. An empty run over a checkout with uncommitted edits left its branch at the person's own commit, which is not the copy's start, so it was kept: empty is now measured from the ground's own commit after the edits' commit is taken back out. The conversation's merge line carries the page's warning for work not built on everything its branch held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 14 ++- internal/manual/chat/senior-dev.md | 30 ++++-- internal/session/delegate_door.go | 102 +++++++++++++++--- internal/session/delegate_landing_test.go | 88 +++++++++++++++ internal/session/delegate_stop_test.go | 126 ++++++++++++++++++++++ internal/session/stoprun.go | 56 +++++++++- internal/session/task_run_belt.go | 78 ++++++++++++-- 7 files changed, 459 insertions(+), 35 deletions(-) create mode 100644 internal/session/delegate_stop_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 33dc785d5..2d152672d 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -118,12 +118,20 @@ Ask the chat to merge it, or run that yourself, when you are ready. Nothing can when the run ends, because the landing writes nothing of yours; a conflict only appears when you merge. +A run you stop keeps its work the same way: what it had made by then is squashed into one +commit on the task's own branch, and the task says `its work so far is kept on <branch> +and did not go into <folder>`. + If the program switched branches in its copy, its work still lands on the task's own -branch, and the branch it had moved to (even one of yours) is never reset by codeaf; the -task's page names that branch. +branch, whether it ended or you stopped it, and the branch it had moved to (even one of +yours) is never reset or committed on by codeaf; the task's page names that branch. +Commits it had made on the task's own branch before it moved stay there, under its +finished work. When there is nothing to land, it says `nothing to land: the run's working copy holds no -change`, and the task's branch, which would hold nothing, is deleted. A folder with no git history is the exception: the program works in it directly. +change` (`it had changed nothing` for a run you stopped), and the task's branch, which +would hold nothing, is deleted. A folder with no git history is the exception: the +program works in it directly. 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. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index a028e407c..06e69010f 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -163,17 +163,24 @@ project's build and tests (`senior-dev observed: …`). Read the second for "did Its own notes live in `.senior-dev/` in the copy: the brief, its checklist, the command it pinned and its session database. That folder is kept out of git, so it never lands. -When a run changed nothing, there is nothing to land and the task says so; its branch, -which would hold nothing, is deleted rather than left in your repository. On a folder -with no git history nothing is committed at all: the work is already in the folder. +**A run you stop keeps its work the same way.** What it had made by then, committed or +not, is squashed into one `task:` commit on the task's own branch, and the task says `its +work so far is kept on <branch> and did not go into <folder> · merge that branch to bring +it in, or delete it to drop it`, with the files it had changed. + +When a run changed nothing, there is nothing to land and the task says so (`it had changed +nothing` for a run you stopped); its branch, which would hold nothing, is deleted rather +than left in your repository. On a folder with no git history nothing is committed at +all: the work is already in the folder. ## When it moved to another branch in its copy — "work on a new branch", my own branch, a detached HEAD Its shell can run `git checkout` in its copy, and a brief that says "work on a new branch" makes that likely. It changes nothing about where the work lands: when the run -ends, codeaf puts the copy back on the task's own branch without touching its files, and -squashes the finished tree onto it. The branch it had moved to is never reset by codeaf, -even when that is one of your own branches, so what it left there stays. +ends, or you stop it, codeaf puts the copy back on the task's own branch without touching +its files, and squashes the finished tree onto it. The branch it had moved to is never +reset or committed on by codeaf, even when that is one of your own branches, so what it +left there stays. The task's page says so beside the landing, in these words after the program's name: `had moved its copy to the branch <branch>; its work was committed on <task branch>, and @@ -186,6 +193,17 @@ work did not, and the page adds `its work was not built on the commit its copy s from, so the commit on <task branch> may also undo changes that commit had; read its diff before you merge it`. +When it had committed on the task's own branch before it moved, and the copy it left was +not built on those commits (it went back to the start to look at it, say), they are not +squashed away: the finished tree is committed on top of them, so every one stays on the +task's branch, and the page adds `the commits it had made on <task branch> are kept there, +under its finished work; that work was not built on them, so it may also undo their +changes; read its diff before you merge it`. + +In either case the conversation is told too, after the merge command: `its work was not +built on everything that branch held, so the merge may also undo changes; read its diff +before you merge it`. + So a brief need not ask for a branch: codeaf already gives the work one. ## What a senior-dev run costs — model calls, the dollar ceiling, which models diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 06b40d9e4..18ea4a256 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -283,7 +283,9 @@ func delegateStand(workspace string, program delegate.Delegate) taskStand { // — with the tree and index kept, and committed once through the same road every // task commits through. The subject is the task's title; the body is the // terminal record's two sentences. Then the copy comes home the way every run's -// copy does. +// copy does. The one exception is a task's branch holding commits of the +// program's that the tree it left was not built on: the tree is committed on +// top of those, never over them ([headMove.squashOnto]). // // A TREE PROGRAM ON A PLAIN FOLDER LANDS NOTHING EITHER: there was no history // to copy from, so it worked in the folder itself and its changes are already @@ -314,17 +316,11 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { } dir := run.workspace // THE SQUASH LANDS ON CODEAF'S BRANCH, WHEREVER THE PROGRAM LEFT HEAD. - moved := delegateHeadHome(dir, run.tree.branch, run.startSha) - if run.startSha != "" { - head, err := git(dir, "rev-parse", "--verify", "-q", "HEAD") - if err != nil || strings.TrimSpace(head) != run.startSha { - if out, err := git(dir, "reset", "--soft", run.startSha); err != nil { - if g := a.graph(); g != nil { - g.planNote(m.Name + "'s commits could not be squashed: " + firstLine(out)) - } - } - } - } + moved := a.homeDelegateCopy(run) + // THE BRANCH AS THE PROGRAM LEFT IT is what says whether it held any work, + // and the landing below moves it, so it is kept for the branch-only + // landing's emptiness question ([dropEmptyTaskBranch]). + run.taskTip = moved.tip message := "task: " + clip(firstLine(run.title), 72) if result := strings.TrimSpace(summary.Result); result != "" { message += "\n\n" + result @@ -338,6 +334,7 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { landing.Refused = runNothingToLand default: landing.Branch, landing.Changed = currentBranch(dir), saved + landing.Unrelated = moved.warns() } note := landing.Refused if note == "" { @@ -359,11 +356,69 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { // with detached set for no branch at all), and whether its work stood on the // commit the copy started from. The zero value is a HEAD that never left the // task's branch. +// +// tip is the task's branch as the program left it, read before codeaf moved +// anything; kept says that branch held commits of the program's that the HEAD +// it left was not built on, so its work is committed on top of them rather +// than squashed over them ([headMove.squashOnto]). type headMove struct { moved bool from string detached bool unrelated bool + tip string + kept bool +} + +// squashOnto is the commit a tree program's finished tree is committed on: +// the commit its copy started from, so the program's own bookkeeping commits +// fold into one, or the task's branch as the program left it when that branch +// holds commits the finished tree was not built on. +// +// THE PROGRAM'S COMMITS ARE NEVER SQUASHED OVER FROM ELSEWHERE. senior-dev +// commits every write on the task's branch; a model that then ran `git +// checkout --detach` to look at the baseline, and was ended there by a limit, +// had that branch reset back to the start under a tree that held none of its +// work, and the branch, then empty, deleted with the only reference to an +// hour of paid commits. Committed on top, every one of them stays on the +// task's branch, and the note says the finished tree may undo them. +func (move headMove) squashOnto(startSha string) string { + if move.kept { + return move.tip + } + return startSha +} + +// warns says the landing's commit may also undo changes the branch held +// before it: work built on another commit than the copy's start, or on +// something other than the program's own commits on the task's branch. +func (move headMove) warns() bool { + return move.unrelated || move.kept +} + +// homeDelegateCopy puts a tree program's copy back on the task's own branch +// and takes that branch back to the commit its work is committed on +// ([headMove.squashOnto]), keeping the index and the files exactly as the +// program left them, so the one commit that follows holds the program's whole +// work. THE LANDING AND THE STOP BOTH TAKE IT: a stop that committed on +// whatever branch HEAD was on put codeaf's commit on the person's own branch +// while its report named the task's branch, which held nothing. +func (a *Agent) homeDelegateCopy(run *beltRun) headMove { + dir := run.workspace + move := delegateHeadHome(dir, run.tree.branch, run.startSha) + onto := move.squashOnto(run.startSha) + if onto == "" { + return move + } + if head, err := git(dir, "rev-parse", "--verify", "-q", "HEAD"); err == nil && strings.TrimSpace(head) == onto { + return move + } + if out, err := git(dir, "reset", "--soft", onto); err != nil { + if g := a.graph(); g != nil { + g.planNote(run.delegate.Name + "'s commits could not be squashed: " + firstLine(out)) + } + } + return move } // delegateHeadHome puts a tree program's copy back on the task's own branch @@ -389,25 +444,36 @@ type headMove struct { // so work the program built on some other commit (a branch cut from `main`, // say) also undoes whatever the copy's first commit had and that one did not, // and the diff is the only place that would show. +// +// THE TASK'S BRANCH IS READ BEFORE HEAD MOVES ONTO IT. Where it holds commits +// past the copy's start that the HEAD the program left was not built on, those +// are the program's own work, and the squash must not reset over them +// ([headMove.squashOnto]). func delegateHeadHome(dir, branch, startSha string) headMove { branch = strings.TrimSpace(branch) if branch == "" { return headMove{} } + tip, _ := git(dir, "rev-parse", "--verify", "-q", "refs/heads/"+branch) + stay := headMove{tip: strings.TrimSpace(tip)} current := currentBranch(dir) if current == branch { - return headMove{} + return stay } head, _ := git(dir, "rev-parse", "--verify", "-q", "HEAD") head = strings.TrimSpace(head) if _, err := git(dir, "symbolic-ref", "HEAD", "refs/heads/"+branch); err != nil { - return headMove{} + return stay } - move := headMove{moved: true, from: current, detached: current == ""} + move := headMove{moved: true, from: current, detached: current == "", tip: stay.tip} if startSha != "" && head != "" { _, err := git(dir, "merge-base", "--is-ancestor", startSha, head) move.unrelated = err != nil } + if move.tip != "" && move.tip != startSha { + _, err := git(dir, "merge-base", "--is-ancestor", move.tip, head) + move.kept = head == "" || err != nil + } return move } @@ -431,7 +497,11 @@ func (move headMove) sentence(name, branch string, landed bool) string { if !move.detached { said += ", and any commit it made on " + move.from + " is still on that branch" } - if landed && move.unrelated { + switch { + case landed && move.kept: + said += " · the commits it had made on " + branch + " are kept there, under its finished work; that work was not built on them, " + + "so it may also undo their changes; read its diff before you merge it" + case landed && move.unrelated: said += " · its work was not built on the commit its copy started from, so the commit on " + branch + " may also undo changes that commit had; read its diff before you merge it" } diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index 4cf81adf1..ae63a456e 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -158,6 +158,10 @@ func TestADelegatedRunBuiltOnAnotherCommitIsWarnedAbout(t *testing.T) { if !strings.Contains(strings.Join(notes, "\n"), "its work was not built on the commit its copy started from") { t.Fatalf("work built on another commit landed without a word: %q", notes) } + // The outcome line, the one the conversation is handed, says it too. + if !strings.Contains(strings.Join(notes, "\n"), "brings it in; "+landingUnrelatedWarning) { + t.Fatalf("the conversation's merge line does not carry the warning: %q", notes) + } } // A PROGRAM RUN THAT CHANGED NOTHING LEAVES NO BRANCH. There is nothing on an @@ -385,3 +389,87 @@ func TestAMovedHeadsNoteClaimsACommitOnlyWhenOneWasMade(t *testing.T) { t.Fatalf("a HEAD that never moved says %q", got) } } + +// taskBranchLog answers the task's one branch and the subjects of its history, +// newest first. +func taskBranchLog(t *testing.T, repo string) (string, string) { + t.Helper() + branches := strings.Fields(gitOut(t, repo, "branch", "--format=%(refname:short)", "--list", "task/*")) + if len(branches) != 1 { + t.Fatalf("want the task's one branch in the repository, got %q", branches) + } + return branches[0], gitOut(t, repo, "log", "--format=%s", branches[0]) +} + +// A PROGRAM THAT COMMITTED ON THE TASK'S BRANCH AND THEN LEFT HEAD AT THE BASE +// keeps those commits. senior-dev commits every write on the task's branch; a +// model that then ran `git checkout --detach` to look at the baseline, and was +// ended there, had its branch reset back to the base over its commits, and the +// branch, then empty, deleted: the paid work was unreachable. +func TestADelegatedRunThatLeftHeadAtTheBaseKeepsItsCommitsOnTheTaskBranch(t *testing.T) { + repo, _, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + commitIn(t, workspace, "one.txt") + mustGit(t, workspace, "checkout", "-q", "--detach", "HEAD~1") + }) + branch, log := taskBranchLog(t, repo) + if !strings.Contains(log, "wip(edit): one.txt") { + t.Fatalf("the program's own commit is gone from the task's branch:\n%s", log) + } + if row.Branch != branch { + t.Fatalf("the row names %q, want the task's branch %q", row.Branch, branch) + } + if !strings.Contains(strings.Join(notes, "\n"), "the commits it had made on "+branch+" are kept there, under its finished work") { + t.Fatalf("the page does not say the program's commits are kept under its finished work: %q", notes) + } +} + +// A PROGRAM THAT COMMITTED ON THE TASK'S BRANCH AND THEN CUT A BRANCH OFF THE +// BASE keeps those commits too: its finished tree is committed on top of them, +// never over them. +func TestADelegatedRunThatBranchedOffTheBaseKeepsItsCommitsOnTheTaskBranch(t *testing.T) { + repo, _, _, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + commitIn(t, workspace, "one.txt") + mustGit(t, workspace, "checkout", "-q", "-b", "experiment", "HEAD~1") + commitIn(t, workspace, "exp.txt") + }) + branch, log := taskBranchLog(t, repo) + if !strings.Contains(log, "wip(edit): one.txt") { + t.Fatalf("the program's own commit is gone from the task's branch:\n%s", log) + } + if files := gitOut(t, repo, "ls-tree", "--name-only", branch); !strings.Contains(files, "exp.txt") { + t.Fatalf("the task's branch does not end with the program's finished tree:\n%s", files) + } + if !strings.Contains(strings.Join(notes, "\n"), "read its diff before you merge it") { + t.Fatalf("work not built on the program's own commits landed without a word: %q", notes) + } +} + +// A PROGRAM RUN THAT CHANGED NOTHING OVER A CHECKOUT WITH UNCOMMITTED EDITS +// LEAVES NO BRANCH EITHER. The copy started from a commit holding the person's +// edits, the landing takes that commit back out, and the branch then stood at +// the person's own commit: not the start, so it was kept, empty. +func TestADelegatedRunThatChangedNothingOverADirtyCheckoutLeavesNoBranch(t *testing.T) { + repo, _, row, _ := delegatedRunThatDid(t, func(repo string) { + writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own unfinished line\n") + }, func(*testing.T, string) {}) + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a run that changed nothing over a dirty checkout left a branch behind: %q", branches) + } + if row.Branch != "" { + t.Fatalf("the row names a branch %q over no work", row.Branch) + } +} + +// THE CONVERSATION'S MERGE LINE CARRIES THE PAGE'S WARNING. The line is the one +// account of a landing the chat's model reads, and asked to merge it had nothing +// telling it to look first. +func TestTheConversationsMergeLineWarnsAboutWorkBuiltElsewhere(t *testing.T) { + landing := RunLanding{Branch: "task/x", Changed: []string{"a.go"}, Home: mergeKept, Root: "/r", Unrelated: true} + if got := beltLandingLine(landing); !strings.HasSuffix(got, "`git -C '/r' merge task/x` brings it in; "+landingUnrelatedWarning) { + t.Fatalf("the merge line does not carry the warning: %q", got) + } + landing.Unrelated = false + if got := beltLandingLine(landing); strings.Contains(got, landingUnrelatedWarning) { + t.Fatalf("work built on its start was warned about: %q", got) + } +} diff --git a/internal/session/delegate_stop_test.go b/internal/session/delegate_stop_test.go new file mode 100644 index 000000000..7333340ca --- /dev/null +++ b/internal/session/delegate_stop_test.go @@ -0,0 +1,126 @@ +package session + +// WHERE A STOPPED PROGRAM'S WORK GOES, WHATEVER IT DID WITH HEAD. +// +// The landing puts a program's copy back on the task's own branch before its +// work is committed; the stop did not. A stopped program's work was committed +// on whatever branch HEAD was on, one of the person's own included, while the +// report named the task's branch, which held nothing; a stop that changed +// nothing left an empty branch; and a run that had committed every write, the +// way senior-dev does, was told as having changed nothing at all. + +import ( + "context" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// stoppedDelegatedRunThatDid runs one program whose work before the stop is +// play, in a repository newTestRepo makes (prepare may give it branches first), +// stops it, and answers the repository, the run's row and the notes on its page. +func stoppedDelegatedRunThatDid(t *testing.T, prepare func(repo string), play func(t *testing.T, workspace string)) (string, TaskNotice, []string) { + t.Helper() + double := newBeltRunDouble("unused") + double.honoursStop = true + double.early = func(workspace string) { play(t, workspace) } + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + if prepare != nil { + prepare(conversation) + } + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if _, err := agent.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)); err != nil { + t.Fatal(err) + } + beltRunWaitFor(t, "the run to end", func() bool { + agent.beltMu.Lock() + defer agent.beltMu.Unlock() + return agent.beltRun == nil + }) + var row TaskNotice + for _, kept := range agent.graph().runRows(id) { + if kept.ID == id { + row = kept + } + } + return conversation, row, beltRunNotes(t, filepath.Dir(spec.Store.Path()), spec.Store.RootID()) +} + +// A STOPPED PROGRAM THAT HAD CHECKED OUT THE PERSON'S OWN BRANCH never has +// codeaf commit on it: the person's branch is exactly as the program left it, +// and the work, committed and not, is on the task's branch the report names. +func TestAStoppedProgramOnThePersonsBranchLeavesItAndKeepsItsWorkOnTheTaskBranch(t *testing.T) { + var left string + repo, row, notes := stoppedDelegatedRunThatDid(t, func(repo string) { + mustGit(t, repo, "checkout", "-q", "-b", "persons-feature") + commitIn(t, repo, "mine.txt") + mustGit(t, repo, "checkout", "-q", "-") + }, func(t *testing.T, workspace string) { + mustGit(t, workspace, "checkout", "-q", "persons-feature") + commitIn(t, workspace, "one.txt") + writeFile(t, filepath.Join(workspace, "two.txt"), "two\n") + left = strings.TrimSpace(gitOut(t, workspace, "rev-parse", "HEAD")) + }) + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "persons-feature")); tip != left { + t.Fatalf("codeaf's stop moved the person's branch from %s to %s:\n%s", left, tip, gitOut(t, repo, "log", "--format=%s", "persons-feature")) + } + branch, _ := taskBranchLog(t, repo) + files := gitOut(t, repo, "ls-tree", "--name-only", branch) + for _, name := range []string{"one.txt", "two.txt"} { + if !strings.Contains(files, name) { + t.Fatalf("the task's branch does not hold %s:\n%s", name, files) + } + } + if row.Branch != branch { + t.Fatalf("the row names %q, want the task's branch %q", row.Branch, branch) + } + joined := strings.Join(notes, "\n") + if !strings.Contains(joined, "its work so far is kept on "+branch) || !strings.Contains(joined, "fake had moved its copy to the branch persons-feature") { + t.Fatalf("the stop does not say where the work is and where the program had moved: %q", notes) + } +} + +// A STOPPED PROGRAM THAT COMMITTED EVERY WRITE is told as having changed what +// it changed. senior-dev commits each write on the task's branch, so nothing +// is left uncommitted at a stop, and the stop read that as "it had changed +// nothing" and named no branch. +func TestAStoppedProgramThatCommittedEveryWriteReportsItsFiles(t *testing.T) { + repo, row, notes := stoppedDelegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + commitIn(t, workspace, "one.txt", "two.txt") + }) + branch, _ := taskBranchLog(t, repo) + if row.Branch != branch || len(row.Changed) != 2 { + t.Fatalf("the row names %q with %q, want the task's branch %q with both files", row.Branch, row.Changed, branch) + } + joined := strings.Join(notes, "\n") + if strings.Contains(joined, "it had changed nothing") || !strings.Contains(joined, "its work so far is kept on "+branch) { + t.Fatalf("a stopped run that committed its writes says: %q", notes) + } +} + +// A STOPPED PROGRAM THAT CHANGED NOTHING LEAVES NO BRANCH, as one that ended +// does. +func TestAStoppedProgramThatChangedNothingLeavesNoBranch(t *testing.T) { + repo, row, notes := stoppedDelegatedRunThatDid(t, nil, func(*testing.T, string) {}) + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a stopped run that changed nothing left a branch behind: %q", branches) + } + if row.Branch != "" || !strings.Contains(strings.Join(notes, "\n"), "stopped · it had changed nothing") { + t.Fatalf("a stopped run that changed nothing draws %q and says %q", row.Branch, notes) + } +} diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index 43b19d645..dc4144986 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -223,11 +223,14 @@ func (a *Agent) beltRunRootRow(id string) (uint64, bool) { // stopped by a person. NOTHING IS LANDED AND NO TURN IS BOUGHT: the person // ended the spend, and a model call to narrate the ending would be more of it. func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { - merge, changed := keptWork(run.tree, run.title, nil, a.signsGitWork()) + merge, changed, moved := a.keepStoppedWork(run) report := stopBecause(taskStoppedWord, why) if merge != mergeInPlace { report += " · " + beltStoppedWhere(run.tree.branch, run.ground, changed) } + if moved != "" { + report += " · " + moved + } // A PROGRAM STOPPED IN A PLAIN FOLDER leaves the person's folder its work // and nothing of its own, exactly as one that ended does. if run.plain { @@ -277,3 +280,54 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { // the stop ending ([Agent.settleJoinedRows], [TaskReasonOf]). a.settleJoinedRows(g, run, notice.EndedAt, TaskEndingStopped, cut) } + +// keepStoppedWork commits what a stopped run had made on the run's own branch +// ([keptWork]) and answers the merge, the files, and what a tree program had +// done with its copy's HEAD when it had moved it ([headMove.sentence]). +// +// A STOPPED PROGRAM'S WORK GOES WHERE A LANDED ONE'S DOES. The stop committed on +// whatever branch HEAD was on, so a program that had checked out one of the +// person's own branches had codeaf's commit put on it, while the report sent +// the person to the task's branch, which held nothing. The copy is put back on +// the task's branch first, with the landing's own guard over the program's +// commits there ([Agent.homeDelegateCopy]). +// +// AND ITS FILES ARE COUNTED FROM THE COPY'S START. senior-dev commits every +// write, so at a stop nothing is left uncommitted, and a count of what the stop +// itself committed told a run that had written a dozen files as one that "had +// changed nothing", naming no branch. A stop that changed nothing leaves no +// branch, as a landing that changed nothing does ([dropEmptyTaskBranch]). +func (a *Agent) keepStoppedWork(run *beltRun) (string, []string, string) { + sign := a.signsGitWork() + if run.delegate == nil || !run.delegate.LandsTree() || run.plain || run.tree.dir == "" { + merge, changed := keptWork(run.tree, run.title, nil, sign) + return merge, changed, "" + } + move := a.homeDelegateCopy(run) + committed := changedSince(run.workspace, run.startSha) + merge, changed := keptWork(run.tree, run.title, nil, sign) + changed = alsoChanged(changed, committed) + if len(changed) == 0 { + dropEmptyTaskBranch(run.tree, run.startSha, move.tip) + } + return merge, changed, move.sentence(run.delegate.Name, run.tree.branch, len(changed) > 0) +} + +// changedSince is every path HEAD's tree differs from a commit in, empty when +// either cannot be read: the work a copy's branch already holds past its start. +func changedSince(dir, sha string) []string { + if strings.TrimSpace(sha) == "" { + return nil + } + out, err := git(dir, "diff", "--name-only", sha, "HEAD") + if err != nil { + return nil + } + var paths []string + for _, line := range strings.Split(out, "\n") { + if line = strings.TrimSpace(line); line != "" { + paths = append(paths, line) + } + } + return paths +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 0462c2cfd..b19fdc6ac 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -220,8 +220,18 @@ type RunLanding struct { // ([delegateKeepsBranch]), set only for that landing: the folder the merge // that brings the work in runs in ([beltLandingLine]). Root string + // Unrelated says the branch's work was not built on everything that branch + // held before it ([headMove.warns]), so a merge of it may also undo changes; + // the line the conversation is handed says so ([beltLandingLine]). + Unrelated bool } +// landingUnrelatedWarning is what the conversation's landing line adds for work +// that was not built on everything its branch held. THE PAGE SAID IT AND THE +// CHAT DID NOT: the line the chat's model reads offered the merge command +// alone, and a model asked to merge had nothing telling it to look first. +const landingUnrelatedWarning = "its work was not built on everything that branch held, so the merge may also undo changes; read its diff before you merge it" + // RunEngine is the run engine as this door reaches it. Start drives one store // to an outcome and answers what came of it; Land commits the run's working // copy onto its branch and answers where the work went. @@ -303,6 +313,12 @@ type beltRun struct { // back to at landing (delegate_door.go). delegate *delegate.Delegate startSha string + // taskTip is the task's branch as a tree program left it, read by its + // landing before anything moved it ([Agent.homeDelegateCopy]). It is what + // says whether that branch ever held the program's work, which the tip + // after the landing's own squash cannot ([dropEmptyTaskBranch]). It is + // written and read on the run's own goroutine alone. + taskTip string // plain is a tree program working in a folder with no git history // ([delegateOnPlainFolder]): it is told so on its line, and its landing // commits nothing, because the work is already where it belongs. @@ -1143,7 +1159,7 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { // the branch named, with the repository it is in, and the homecoming written // on the run's page; or, for a branch holding nothing, the branch deleted. func (a *Agent) branchOnlyLanding(run *beltRun, landing RunLanding, said string) RunLanding { - if dropEmptyTaskBranch(run.tree, run.startSha) { + if dropEmptyTaskBranch(run.tree, run.startSha, run.taskTip) { // AN EMPTY BRANCH IS NOT A LANDING. The branch was kept for the person // to merge, and there is nothing on it to merge: every look-only, // failed or crashed program run left one more `task/*` branch at the @@ -1173,23 +1189,59 @@ func (a *Agent) branchOnlyLanding(run *beltRun, landing RunLanding, said string) } // dropEmptyTaskBranch deletes a kept task branch that holds nothing past the -// commit its copy started from, and reports whether it did. Only a branch -// whose tip IS that commit goes, so a branch holding even one commit of the -// program's is never touched; the repository's lock is taken the way every -// landing's branch work takes it. -func dropEmptyTaskBranch(tree taskTree, startSha string) bool { +// commit its copy started from, and reports whether it did; the repository's +// lock is taken the way every landing's branch work takes it. +// +// THE BRANCH AS THE PROGRAM LEFT IT DECIDES, NOT THE BRANCH AFTER THE LANDING. +// before is the task's branch read before codeaf moved it ([beltRun.taskTip]), +// and only a branch that stood at the copy's start then can go: the landing's +// own squash resets the branch to that start, and a test on the tip after it +// once deleted a branch whose only reference to the program's commits was +// that branch. +// +// AND EMPTY IS MEASURED FROM THE GROUND'S OWN COMMIT ([taskGroundCommit]). A +// copy cut from a checkout with uncommitted edits starts from the commit that +// holds them, which the landing takes back out ([taskTree.replayOwnWork]), so +// an empty branch ends at the person's own commit, not at the start: it is +// empty when it holds no commit past that commit and no change from it. +func dropEmptyTaskBranch(tree taskTree, startSha, before string) bool { if strings.TrimSpace(tree.root) == "" || strings.TrimSpace(tree.branch) == "" || startSha == "" { return false } + if strings.TrimSpace(before) != startSha { + return false + } defer lockGitRoot(tree.place, tree.root)() tip, err := git(tree.root, "rev-parse", "--verify", "-q", "refs/heads/"+tree.branch) - if err != nil || strings.TrimSpace(tip) != startSha { + if err != nil { + return false + } + tip, from := strings.TrimSpace(tip), taskGroundCommit(tree, startSha) + if ahead, err := git(tree.root, "rev-list", from+".."+tip); err != nil || strings.TrimSpace(ahead) != "" { + return false + } + if _, err := git(tree.root, "diff", "--quiet", from, tip); err != nil { return false } _, err = git(tree.root, "branch", "-D", tree.branch) return err == nil } +// taskGroundCommit is the commit of the person's own a task's copy counts its +// work from: the parent of the commit the ground ladder sealed the person's +// uncommitted edits into, when it made one ([taskTree.replayOwnWork] takes that +// commit back out), and the copy's start otherwise. +func taskGroundCommit(tree taskTree, startSha string) string { + if strings.TrimSpace(tree.base) == "" { + return startSha + } + parent, err := git(tree.root, "rev-parse", "--verify", "-q", tree.base+"^") + if err != nil || strings.TrimSpace(parent) == "" { + return startSha + } + return strings.TrimSpace(parent) +} + // deliverBeltRunLanding writes the run's digest into the conversation record. // A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED. func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { @@ -1447,6 +1499,10 @@ func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, // merged, which repository held the branch, or what brings it in, and would tell // the person their folder held the work. The folder is quoted for a shell the // way every path this package hands one is ([shellQuoted]). +// +// WORK NOT BUILT ON EVERYTHING ITS BRANCH HELD IS SAID HERE AS ON THE PAGE +// ([landingUnrelatedWarning]), because this line is what the chat's model reads +// before it runs the merge it offers. func beltLandingLine(landing RunLanding) string { if landing.Refused != "" { return landing.Refused @@ -1454,11 +1510,15 @@ func beltLandingLine(landing RunLanding) string { if landing.Branch == "" { return "" } + line := fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) if landing.Home == mergeKept && landing.Root != "" { - return fmt.Sprintf("its work is on the branch %s in %s, %s; nothing was merged into your checkout, and `git -C %s merge %s` brings it in", + line = fmt.Sprintf("its work is on the branch %s in %s, %s; nothing was merged into your checkout, and `git -C %s merge %s` brings it in", landing.Branch, landing.Root, fileCount(len(landing.Changed)), shellQuoted(landing.Root), landing.Branch) } - return fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) + if landing.Unrelated { + line += "; " + landingUnrelatedWarning + } + return line } // fileCount is a count of files in words, `1 file` and `2 files`, so every From a2c450ea482bd48eacac60fcfb1669ab193b02b6 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:38:29 -0400 Subject: [PATCH 095/195] tui3: a run's clocks stop where the run did, and a program room reads its landing Six things drew a run's time or its room wrong: - A program room took the store's ending for the end of the work. The engine ends the store's root at the program's exit and writes the landing note after it, so a read in that gap took the room off the clock and the landed room never showed where the work went. The row's landing now ends a room whose row this window holds, and a read never ends it; the room reads the page once more after the row lands. - An ordinary task accepted, refuted or re-read later read the wait as work: the engine moves a settled node's end and keeps its age, and the card and room measured the two stamps. The reported age now comes first. - The room's facts row and the stored page kept counting after the program's process ended, through the receipt wait and the landing, then jumped back. They now stop at the exit the page's row already carries. - A side-list press whose answer came back after the person went Home opened the run's page over Home, or a room under it. Going to a place now withdraws the press, and its answer opens nothing unless the conversation is in front. - A hosted engine whose clock runs behind inflated every running task's age. The reported age is now the anchor; the record's start is used only when no age is reported, which is a run's row. - In a hosted window a stale far row put a landed run back to running with no age and its spend from before the landing, and its clock climbed with no end. A live far row no longer touches a settled node, never writes an age, and anchors a new node at its own start; a node with no anchor draws no age. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/reading-a-task-page.md | 9 +- internal/manual/chat/worker-harness.md | 9 +- internal/tui3/c295_rail_task_page_test.go | 38 +++++++++ internal/tui3/programroom.go | 61 +++++++++++--- internal/tui3/programroom_test.go | 93 +++++++++++++++++++++ internal/tui3/room.go | 3 + internal/tui3/runclock_test.go | 55 ++++++++++++ internal/tui3/task.go | 68 ++++++++------- internal/tui3/taskcardhost_test.go | 57 +++++++++++++ internal/tui3/taskconversation.go | 7 +- internal/tui3/taskmention.go | 42 +++++++++- internal/tui3/taskplan.go | 18 +++- internal/tui3/worktab.go | 7 ++ 13 files changed, 417 insertions(+), 50 deletions(-) diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index d0a58d913..d496fd0ea 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -40,9 +40,12 @@ 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.** When the record carries both instants, the -settled page's header and the completion card show the landing less the start, rounded to -the second — one figure, spelled `29m 8s` in the header and `29m08s` on the card. While +**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. diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 1d288c6e0..1df2f3961 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -113,7 +113,8 @@ nothing. The page can take a moment to arrive. From the press on, what you type belongs to the page and never to the conversation: the keys are kept in order and land in the page's -note box when it opens, `enter` included. `esc` in that moment withdraws the press. If +note box when it opens, `enter` included. `esc` in that moment withdraws the press, and so +does going to a place such as Home: the page does not open over it. If the row turns out to have no page and its room opens instead, those keys are dropped. A page opened on a task that is queued or running follows it: it reads the task again @@ -242,7 +243,8 @@ run has spent (`of` its ceiling when the page knows it), how many model calls it 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 is the one the side list and the landed card show for the run: it counts from the moment codeaf handed the work over, and once the run -has ended it is the whole span, up to the moment the program's own process ended. A run +has ended it is the whole span, up to the moment the program's own process ended. It stops +there as soon as that process ends, while codeaf is still landing the work. A run nothing is driving any more, because codeaf closed while the program worked, reads `incomplete` with its time stopped at the last thing it did. On a tall window with the side list open, the line sits beside the task's title instead. @@ -253,7 +255,8 @@ model's, named by its short name: the first line of its answer, and one dim row it asked for behind that tool's mark. A call codeaf refused is one line from `codeaf`, `refused · <why>`; a failed one is `the call failed · <why>`. The call in flight is the last line, `◐`, the model and its seconds, gone when the call returns. The page reads the -store again every three seconds while the run works, and once more when it ends. +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. 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. On diff --git a/internal/tui3/c295_rail_task_page_test.go b/internal/tui3/c295_rail_task_page_test.go index 76a546e3b..80bf7641b 100644 --- a/internal/tui3/c295_rail_task_page_test.go +++ b/internal/tui3/c295_rail_task_page_test.go @@ -268,3 +268,41 @@ func TestALetterTypedWhileARailPageOpensNeverRaisesTheStopCard(t *testing.T) { t.Fatalf("the page's box holds %q, want every key typed while it opened: %q", got, "an example") } } + +// A PLACE OPENED WHILE A ROW'S PAGE IS ON ITS WAY WITHDRAWS THE PRESS. The +// person pressed a run's row in the side list and then went Home before the +// store answered — milliseconds here, seconds over a connection — and the +// answer opened the run's page over Home, a room under it, or a program's room +// under it with the box pointed at the run while Home's box was on screen. +func TestAPlaceOpenedWhileARowsPageIsOnItsWayWithdrawsThePress(t *testing.T) { + for _, tc := range []struct { + name string + app func(t *testing.T) *app + id uint64 + }{ + {"a run's stored page", func(t *testing.T) *app { a, _ := railTaskPageApp(t, true); return a }, 2}, + {"a run with no stored page", func(t *testing.T) *app { a, _ := railTaskPageApp(t, false); return a }, 2}, + {"a program's row not yet held", func(t *testing.T) *app { + a, _ := programRoomApp(t, 120, 28) + a.planRows, a.planRowsRead = nil, false + return a + }, 7}, + } { + t.Run(tc.name, func(t *testing.T) { + a := tc.app(t) + cmd := a.openRailRoom(a.tasks[tc.id]) + if cmd == nil || a.railPlanPending.id == "" { + t.Fatalf("the press asked the store nothing: pending=%q", a.railPlanPending.id) + } + drain(t, a, a.openHome()) + drain(t, a, cmd) + if !a.at(pageHome) { + t.Fatal("the person is no longer at Home") + } + if a.railTaskPlanOn || a.taskSheet.planOn || a.room != nil || a.railPlanPending.id != "" { + t.Fatalf("the answer landed on Home: page=%v plan=%v room=%v pending=%q", + a.railTaskPlanOn, a.taskSheet.planOn, a.room != nil, a.railPlanPending.id) + } + }) + } +} diff --git a/internal/tui3/programroom.go b/internal/tui3/programroom.go index ca2dabb4c..a913291fc 100644 --- a/internal/tui3/programroom.go +++ b/internal/tui3/programroom.go @@ -154,16 +154,22 @@ func (a *app) openProgramRoom(id uint64, title string, page session.PlanTaskPage // programRoomDone is whether the open program room's work is over: by the // conversation's own row when it holds one, and by the stored page's state -// otherwise, and by either one when both are known. A room whose node this -// window never saw is not taken for finished on that absence alone -// ([roomRowDone] answers true for no node at all). +// only when it does not. A room whose node this window never saw is not taken +// for finished on that absence alone ([roomRowDone] answers true for no node +// at all). +// +// THE ROW OUTRANKS THE STORE WHENEVER THE WINDOW HOLDS ONE. The engine ends +// the store's root at the program's exit and writes the landing — where the +// work went, how to bring it in — only after it, and the row settles last. A +// room that took the store's ending for the end stopped reading in that gap, +// and its landing never reached the page. func (a *app) programRoomDone() bool { p := a.programOf() if p == nil { return false } - if node := a.roomNode(); node != nil && roomRowDone(node) { - return true + if node := a.roomNode(); node != nil { + return roomRowDone(node) } return planEnded(p.page.Row) } @@ -190,7 +196,15 @@ func (a *app) programRoomRead() tea.Cmd { if room.title == "" || room.title == taskIDWord(room.id) { room.title = firstNonEmpty(page.Row.Title, room.title) } - room.done = a.programRoomDone() + // A READ NEVER ENDS A ROOM WHOSE ROW THIS WINDOW HOLDS. The node's + // landing is what ends it, in [app.programRoomFollow], which reads the + // page once more from that moment; a read that was already out when + // the row landed answers with the page from before the landing, and + // had it ended the room here the last read would never be made. A room + // with no node has only the store to go by, and this read is it. + if a.roomNode() == nil { + room.done = a.programRoomDone() + } room.dirty = true a.touch() return nil @@ -317,19 +331,46 @@ func (a *app) programFactsWord(width int) (string, int) { return line, ansi.StringWidth(lead) } -// programRoomClock is the age the program room's facts row draws: the node's -// clock when this conversation holds one for it, and the stored page's own -// stamps otherwise. +// programRoomClock is the age the program room's facts row draws: the span +// the program ran once its process has ended, the node's clock when this +// conversation holds one for it, and the stored page's own stamps otherwise. func (a *app) programRoomClock() string { + p := a.programOf() + if p != nil { + if word, ok := programExitClock(p.page.Row, a.roomNode()); ok { + return word + } + } if word, ok := a.nodeClock(a.roomNode()); ok { return word } - if p := a.programOf(); p != nil { + if p != nil { return a.taskPlanAge(p.page.Row) } return "" } +// programExitClock is the span a program's run ran for when its process has +// ended and the conversation's row has not yet settled: the page's own pair, +// which session puts on the hand-off and the program's recorded exit, rounded +// as every finished span is ([taskNode.ranFor]). +// +// THE CLOCK STOPS AT THE PROGRAM'S EXIT, NOT AT THE LANDING. After the process +// ends the engine waits for the receipts of calls it still owes — up to +// seventy seconds on a cut call — and lands the work, and only then settles +// the row; a room and a page that went on reading the row's running clock +// counted through all of that and jumped back when it settled. A row that has +// settled has its own span, and a run still working has no exit to stop at. +func programExitClock(row session.PlanTaskRow, node *taskNode) (string, bool) { + if node == nil || node.state != session.TaskRunning || strings.TrimSpace(row.Program) == "" { + return "", false + } + if row.Started.IsZero() || row.Ended.IsZero() || row.Ended.Before(row.Started) { + return "", false + } + return countUpWord(row.Ended.Sub(row.Started).Round(time.Second)), true +} + // programStopTarget is what `x`, `/stop` and the room's Stop end on a // program's room: the run's own task, through the store's own door // ([session.Agent.PlanCancel]) — the target the stored page's `x` has always diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go index 5dcb9540e..d7759c493 100644 --- a/internal/tui3/programroom_test.go +++ b/internal/tui3/programroom_test.go @@ -165,6 +165,65 @@ func TestAProgramRoomFollowsWhileRunningAndStopsAfterItSettles(t *testing.T) { } } +// THE STORE ENDS BEFORE THE ROW LANDS, AND THE ROOM KEEPS READING. The engine +// ends the store's root at the program's exit and writes the landing — where +// the work went and how to bring it in — after it, and only then settles the +// conversation's row. A read in that gap came back ended, took the room off the +// clock, and the landing's own notice found nothing left to read: the landed +// room never showed the note. The node's landing is what ends the room, and a +// read that was still out when it landed is not the last one. +func TestAProgramRoomReadsTheLandingTheStoreEndedAhead(t *testing.T) { + for _, tc := range []struct { + name string + out bool // a beat's read is still out when the landing arrives + }{ + {"the store's ending read on a beat", false}, + {"a read still out when the row lands", true}, + } { + t.Run(tc.name, func(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + page := agent.planFake.pages["7"] + page.Row.Status, page.Row.Ended = "done", a.now() + agent.planFake.pages["7"] = page + planBeat(t, a) + if a.room.done || !a.programRoomFollows() { + t.Fatalf("a store that ended ahead of the row took the room off the clock: done=%v", a.room.done) + } + var out tea.Cmd + if tc.out { + at := a.now().Add(elsewhereEvery) + a.clock = func() time.Time { return at } + out = a.programRoomFollow() + if out == nil { + t.Fatal("the beat issued no read") + } + } + const landing = "the work landed on branch senior-dev/auth" + page.Notes = []session.PlanTaskNote{{Body: landing}} + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, page.Row.Title, session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: a.now()})}) + if tc.out { + // The read that was out answers with the page before the landing. + stale := page + stale.Notes = nil + agent.planFake.pages["7"] = stale + drain(t, a, out) + } + agent.planFake.pages["7"] = page + for range 2 { + drive(t, a, frameMsg{}) + } + if !strings.Contains(roomText(a), landing) { + t.Fatalf("the landed room never shows the landing note:\n%s", roomText(a)) + } + if a.programRoomFollows() { + t.Fatal("a landed program's room keeps the paint clock turning") + } + }) + } +} + // STOP ON A PROGRAM'S ROOM IS THE RUN'S STOP, through the store's own door. `x` // over an empty box and /stop both raise the card aimed at the run's own task // by the store's id, the target the stored page's `x` has always raised, and @@ -279,3 +338,37 @@ func TestAProgramRunReadsOneFigureOnTheRoomTheRailAndTheCard(t *testing.T) { t.Fatalf("the landed card does not read the span 29m08s: %+v", card) } } + +// THE CLOCK STOPS AT THE PROGRAM'S EXIT, NOT AT THE LANDING. After senior-dev's +// process ends the engine waits for the receipts of calls it still owes (up to +// seventy seconds on a cut call) and lands the work, and only then settles the +// conversation's row; the stored page's row already carries the exit. The room +// and the stored page counted on through that wait — `21m 5s` over a run of +// twenty minutes — and jumped back when the row settled. +func TestAProgramRoomsClockStopsAtTheProgramsExit(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + exit := programRunBegan.Add(20 * time.Minute) + page := agent.planFake.pages["7"] + page.Row.Ended = exit + agent.planFake.pages["7"] = page + now := exit.Add(65 * time.Second) + a.clock = func() time.Time { return now } + drain(t, a, a.programRoomRead()) + if a.tasks[7].state != session.TaskRunning { + t.Fatalf("the fixture's row has settled: %s", a.tasks[7].state) + } + facts, _ := a.programFactsWord(120) + if !strings.HasSuffix(facts, rowSep+"20m") { + t.Fatalf("the room's facts read %q sixty-five seconds after a twenty-minute run's exit, want 20m", facts) + } + if pinned := a.taskPlanPinned(page, 120); !strings.HasSuffix(pinned, rowSep+"20m") { + t.Fatalf("the stored page pins %q after the program exited, want 20m", pinned) + } + // A RUN STILL WORKING COUNTS ON, whatever the store's row says about the end + // of a run it has not been told of. + page.Row.Ended = time.Time{} + if got := a.taskPlanAge(page.Row); got != "21m 5s" { + t.Fatalf("a running program's page reads %q, want 21m 5s", got) + } +} diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 227dbb158..e24efa8ce 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -1225,6 +1225,9 @@ func (a *app) openRailPlan(id string, missing func() tea.Cmd) tea.Cmd { return nil } a.railPlanPending = railPlanPending{} + if !a.railPlanFront() { + return nil + } if missing != nil { return missing() } diff --git a/internal/tui3/runclock_test.go b/internal/tui3/runclock_test.go index 7d91dd176..e01e42fb0 100644 --- a/internal/tui3/runclock_test.go +++ b/internal/tui3/runclock_test.go @@ -91,3 +91,58 @@ func TestAStoredRowsFinishedSpanIsRoundedLikeTheCards(t *testing.T) { t.Fatalf("a finished 61.5-second run reads %q, want 1m 2s", got) } } + +// AN ORDINARY TASK SETTLED AGAIN KEEPS THE AGE ITS WORK TOOK. The engine moves +// a node's end to the moment a person accepts it (and to every later round that +// settles it again) and keeps the age it reported at the landing, so a task that +// worked five minutes and was accepted an hour later read `1h 5m` on its room +// and `1h05m` on its card when the two stamps outranked the age. The stamps are +// what is left for a row that reports no age — a run's row — and there they +// still measure the run. +func TestASettledTasksClockIsTheAgeItReported(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + now := started.Add(time.Minute) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "tidy the parser", session.TaskRunning, + session.TaskNotice{StartedAt: started, Elapsed: time.Minute})}) + now = started.Add(5 * time.Minute) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "tidy the parser", session.TaskUnverified, + session.TaskNotice{StartedAt: started, EndedAt: now, Elapsed: 5*time.Minute + 300*time.Millisecond})}) + if got := a.roomClock(a.tasks[4]); got != "5m" { + t.Fatalf("the landed task's clock reads %q, want 5m", got) + } + now = started.Add(time.Hour + 5*time.Minute) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "tidy the parser", session.TaskDone, + session.TaskNotice{StartedAt: started, EndedAt: now, Elapsed: 5*time.Minute + 300*time.Millisecond})}) + if got := a.roomClock(a.tasks[4]); got != "5m" { + t.Fatalf("a task that worked 5m and was accepted an hour later reads %q, want 5m", got) + } + card := a.doneCardAt(len(a.entries) - 1) + if card == nil { + t.Fatal("the accepted task drew no card") + } + if tail := plain(a.doneTail(card)); !strings.Contains(tail, "5m00s") { + t.Fatalf("the accepted task's card reads %q, want 5m00s", tail) + } +} + +// A REPORTED AGE OUTRANKS ANOTHER MACHINE'S START. An engine on another +// machine stamps a node's start on its own clock, and one running ninety +// seconds behind this window's made a node ten seconds into its work read +// `1m 40s` on the rail — and jump back when it landed. The age an update +// reports needs no agreement between two clocks; the start is the anchor only +// for a row that reports no age, which is a run's. +func TestARunningClockTrustsTheReportedAgeOverAnotherMachinesStart(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + now := taskFixtureNow + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(5, "behind", session.TaskRunning, + session.TaskNotice{StartedAt: now.Add(-10*time.Second - 90*time.Second), Elapsed: 10 * time.Second})}) + if got := a.tasks[5].began; !got.Equal(now.Add(-10 * time.Second)) { + t.Fatalf("a start from a clock behind anchored the row at %s, want the reported age", got) + } + if got := plain(a.railTelemetry(a.tasks[5], 40)); !strings.HasPrefix(got, "10s") { + t.Fatalf("the rail reads %q ten seconds into the work, want 10s", got) + } +} diff --git a/internal/tui3/task.go b/internal/tui3/task.go index a8088288e..b8f83b444 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -208,9 +208,10 @@ type taskNode struct { // (taskending.go). ending session.TaskEnding // started and ended are the record's own instants, carried by the engine on - // every node update when it has them. began is the older live fallback, - // derived once from the update's own Elapsed so the clock is the frame's and - // not the event's. met is when this surface first heard of the node at all, + // every node update when it has them. began is what the live clock counts + // from, anchored once ([app.noticeBegan]) — from the update's own Elapsed, + // or from the record's start for a row that reports no age — so the clock + // is the frame's and not the event's. met is when this surface first heard of the node at all, // which is the honest spawn time for a node that never reached running IN THIS // WINDOW — see [taskNode.restored] for the case where it is not. started, ended time.Time @@ -494,17 +495,20 @@ func (n *taskNode) spawnedAt() time.Time { return n.met } -// ranFor is how long this node's work took once it has landed: the record's -// own two stamps when it carries both, the age the landing reported when it -// does not, and zero — which every surface draws as nothing — when neither is +// ranFor is how long this node's work took once it has landed: the age the +// landing reported when it reported one, the record's own two stamps when it +// did not, and zero — which every surface draws as nothing — when neither is // known. // -// THE STAMPS OUTRANK THE REPORTED AGE, AND BOTH OUTRANK THIS WINDOW'S CLOCK. -// A run's rows used to publish no age at all, so the landed card measured from -// the moment this window first saw the run, and a window reopened twenty -// minutes into a senior-dev run read a twenty-nine-minute run as nine. The -// stamps are the record's facts about the work; the window's own moments are -// not. +// THE REPORTED AGE OUTRANKS THE STAMPS, AND BOTH OUTRANK THIS WINDOW'S CLOCK. +// The engine moves a settled node's end to the moment it is settled again — a +// person's accept, a second look, a merge round — and keeps the age its work +// took, so the stamps of a task that worked five minutes and was accepted an +// hour later span an hour and five. A run's row reports the span of its own +// stamps as its age (session's publishRunRow), so it reads the same figure +// either way; the stamps are for a row that reports no age at all, which a +// run's rows once did, when a window met twenty minutes into a senior-dev run +// read a twenty-nine-minute run as nine off its own moments. // // IT IS WHOLE SECONDS, ROUNDED, because the three surfaces that draw a landed // node's span spell it through two formatters — the landed card's @@ -512,30 +516,34 @@ func (n *taskNode) spawnedAt() time.Time { // which cuts — and a span handed to both unrounded read `22m52s` on the card // and `22m 51s` on the page for one run. func (n *taskNode) ranFor() time.Duration { - if !n.started.IsZero() && n.ended.After(n.started) { - return n.ended.Sub(n.started).Round(time.Second) - } if n.elapsed > 0 { return n.elapsed.Round(time.Second) } + if !n.started.IsZero() && n.ended.After(n.started) { + return n.ended.Sub(n.started).Round(time.Second) + } return 0 } // noticeBegan is the instant a node's running clock counts from, anchored // from the first running update this window receives about it. // -// THE RECORD'S START IS THE ANCHOR WHEN THE UPDATE CARRIES ONE. A run's rows -// report no age (session's task_run_belt.go publishes StartedAt and a zero -// Elapsed), and a run's row is replayed to a window that attaches mid-run as -// it was first published — so an anchor taken from the age alone started the -// rail's clock at the moment this window opened, and a window reopened while -// senior-dev worked read the run as however long the window had been open. A -// start stamped later than this window's own clock is another machine's clock -// running ahead, and it falls back to the reported age, which needs no -// agreement between two clocks. +// THE REPORTED AGE IS THE ANCHOR WHEN THE UPDATE CARRIES ONE, because it needs +// no agreement between two clocks: an engine on another machine stamps a +// node's start on its own clock, and one running ninety seconds behind this +// window's made a node ten seconds into its work read `1m 40s`. +// +// THE RECORD'S START IS THE ANCHOR WHEN THE UPDATE REPORTS NO AGE. A run's rows +// report none while the run works (session's task_run_belt.go publishes +// StartedAt and a zero Elapsed), and a run's row is replayed to a window that +// attaches mid-run as it was first published — so an anchor taken from the age +// alone started the rail's clock at the moment this window opened, and a +// window reopened while senior-dev worked read the run as however long the +// window had been open. A start stamped later than this window's own clock is +// another machine's clock running ahead, and the clock counts from now. func (a *app) noticeBegan(notice session.TaskNotice) time.Time { now := a.now() - if !notice.StartedAt.IsZero() && !notice.StartedAt.After(now) { + if notice.Elapsed <= 0 && !notice.StartedAt.IsZero() && !notice.StartedAt.After(now) { return notice.StartedAt } return now.Add(-notice.Elapsed) @@ -5562,7 +5570,9 @@ func (a *app) railWaiting(node *taskNode, width int) []string { // on it at all is no row. func (a *app) railTelemetry(node *taskNode, width int) string { segs := make([]string, 0, 5) - if clock := countUpWord(a.taskNow(node).Sub(node.began)); clock != "" { + // A NODE WITH NO ANCHOR HAS NO AGE TO DRAW. Counted from the zero instant it + // read `2562047h 47m`, which is not a measurement of anything. + if clock := countUpWord(a.taskNow(node).Sub(node.began)); clock != "" && !node.began.IsZero() { segs = append(segs, clock) } if node.tokens > 0 { @@ -6050,9 +6060,9 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { if !notice.EndedAt.IsZero() { node.ended = notice.EndedAt } - // The clock is anchored ONCE, from the record's start or the age the update - // reported ([app.noticeBegan]), so the row counts on the frame tick instead - // of standing still between events. + // The clock is anchored ONCE, from the age the update reported or, for a row + // that reports none, the record's start ([app.noticeBegan]), so the row + // counts on the frame tick instead of standing still between events. if notice.State == session.TaskRunning && node.began.IsZero() { node.began = a.noticeBegan(*notice) } diff --git a/internal/tui3/taskcardhost_test.go b/internal/tui3/taskcardhost_test.go index 2b3b81bed..6cc27de83 100644 --- a/internal/tui3/taskcardhost_test.go +++ b/internal/tui3/taskcardhost_test.go @@ -412,3 +412,60 @@ func TestAHostedTaskCardSaysItsPathsAreTheFarMachines(t *testing.T) { t.Fatalf("the card lost its branch row:\n%s", text) } } + +// A STALE FAR ROW NEVER UNDOES A LANDING THE STREAM DELIVERED. The far world a +// hosted window reads is the one it last fetched, and a run's row reaches its +// index as `running` at the hand-off: the roster read the landing's own notice +// asks for adopted that row, put the landed run back to running with no age, +// and its clock climbed with no end — `39m` ten minutes after a twenty-nine +// minute run, a spinner and `1 running` on the side list. +func TestAStaleFarRowNeverUndoesTheStreamsLanding(t *testing.T) { + a := hostedPlaceLab(t) + born := time.Now().Add(-30 * time.Minute) + ended := born.Add(29*time.Minute + 8*time.Second) + now := ended.Add(2 * time.Second) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(9, "widening the pipe", session.TaskRunning, + session.TaskNotice{StartedAt: born})}) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(9, "widening the pipe", session.TaskDone, + session.TaskNotice{StartedAt: born, EndedAt: ended, Elapsed: ended.Sub(born), CostUSD: 1.61})}) + held := farCardEntry(now) + held.Status, held.EndedAt, held.StartedAt, held.DurationMS, held.Cost = string(session.TaskRunning), time.Time{}, born, 0, 1.24 + a.adoptFarTaskRows([]session.TaskIndexEntry{held}) + node := a.tasks[9] + if node.state != session.TaskDone { + t.Fatalf("the held far row put the landed run back to %s", node.state) + } + now = now.Add(10 * time.Minute) + if got := a.roomClock(node); got != "29m 8s" { + t.Fatalf("ten minutes after the landing the run reads %q, want 29m 8s", got) + } + if node.cost != 1.61 { + t.Fatalf("the held far row put the landed run's spend back to %.2f", node.cost) + } +} + +// A LIVE FAR ROW THE STREAM HAS NOT NAMED COUNTS FROM THE ROW'S OWN START. A +// node adopted from a live row had no anchor at all, and the side list counted +// from the zero instant: `2562047h 47m`. +func TestALiveFarRowCountsFromItsOwnStart(t *testing.T) { + a := hostedPlaceLab(t) + now := time.Now() + a.clock = func() time.Time { return now } + live := farCardEntry(now) + live.Status, live.EndedAt, live.TranscriptURI = string(session.TaskRunning), time.Time{}, "" + live.StartedAt, live.DurationMS = now.Add(-3*time.Minute), int64(time.Minute/time.Millisecond) + a.adoptFarTaskRows([]session.TaskIndexEntry{live}) + if got := strings.Split(plain(a.railTelemetry(a.tasks[9], 40)), railSep)[0]; got != "3m" { + t.Fatalf("a live far row three minutes in reads %q on the side list, want 3m", got) + } + // AND ONE THAT NAMES NO START DRAWS NO AGE rather than one counted from the + // zero instant. + b := hostedPlaceLab(t) + b.clock = func() time.Time { return now } + live.StartedAt = time.Time{} + b.adoptFarTaskRows([]session.TaskIndexEntry{live}) + if got := plain(b.railTelemetry(b.tasks[9], 40)); strings.Contains(got, "h") { + t.Fatalf("a live far row with no start reads %q on the side list", got) + } +} diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go index e90102b19..928697655 100644 --- a/internal/tui3/taskconversation.go +++ b/internal/tui3/taskconversation.go @@ -178,9 +178,14 @@ func (a *app) programPinned(page session.PlanTaskPage, width int, clock string) // is made, and it is ended by the supervisor rather than when the program's // process is gone — so a page reading them and a rail and a landed card reading // the notices drew three different figures for one run. The store's stamps are -// what is left for a run this conversation has no row for. +// what is left for a run this conversation has no row for — and for the span +// between the program's exit and the row settling, when the page's pair has +// already stopped where the row will ([programExitClock]). func (a *app) taskPlanAge(row session.PlanTaskRow) string { if node := a.programRowNode(row); node != nil { + if word, ok := programExitClock(row, node); ok { + return word + } if word, ok := a.nodeClock(node); ok { return word } diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index 3ee60618e..791e2fc88 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -171,6 +171,14 @@ func (a *app) tasksLoaded(rows []session.TaskIndexEntry, known ...bool) tea.Cmd // the far world. It does not invent live controls: these nodes remain records, // and the room behind one is read-only because the remote agent deliberately // implements none of the local room-action interfaces. +// +// A LIVE ROW IS A STALE ROW ONCE THE STREAM HAS LANDED ITS NODE. The far world +// is the one this window last fetched, and a run's row reaches its index as +// `running` at the hand-off: the roster read a landing's own notice asks for +// adopted that row, put the landed run back to running with no age and its +// spend from before the landing, and its clock climbed with no end. So a live +// row never touches a node that has settled, and its age — the node's age at +// the instant the row was built — is never taken for how long the work ran. func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { if a.tasks == nil { a.tasks = map[uint64]*taskNode{} @@ -181,6 +189,9 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { continue } node := a.tasks[id] + if node != nil && row.Live() && farNodeSettled(node) { + continue + } if node == nil { node = &taskNode{id: id, ident: identFor(id), met: row.EndedAt} a.tasks[id] = node @@ -190,7 +201,11 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { node.label = firstNonEmpty(strings.TrimSpace(row.Title), strings.TrimSpace(row.Label)) node.title = taskTitleOf(node.label, "", id) node.state = session.TaskState(row.Status) - node.elapsed = time.Duration(row.DurationMS) * time.Millisecond + if row.Live() { + a.anchorFarLiveNode(node, row) + } else { + node.elapsed = time.Duration(row.DurationMS) * time.Millisecond + } node.cost, node.tokens, node.model = row.Cost, row.Tokens, strings.TrimSpace(row.Model) node.report, node.changed = strings.TrimSpace(row.Outcome), append([]string(nil), row.Files...) node.transcript = strings.TrimSpace(row.TranscriptURI) @@ -203,6 +218,31 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { } } +// farNodeSettled is whether a node this window holds has landed, whichever +// road told it so. A node with no state yet is not settled: nothing has said +// anything about its work. +func farNodeSettled(node *taskNode) bool { + return node.state != "" && node.state != session.TaskRunning && node.state != session.TaskQueued +} + +// anchorFarLiveNode gives a node adopted from a live far row the instant its +// clock counts from, when the stream has not already given it one: the row's +// own start, which does not go stale the way the age it carries does +// ([session.TaskIndexEntry.Duration]). A start later than this window's clock +// is another machine's clock running ahead, and the node counts from now. +// Without an anchor the side list counted from the zero instant. +func (a *app) anchorFarLiveNode(node *taskNode, row session.TaskIndexEntry) { + if !node.began.IsZero() || row.StartedAt.IsZero() { + return + } + now := a.now() + if row.StartedAt.After(now) { + node.began = now + return + } + node.began = row.StartedAt +} + // refreshTasks says the snapshot is stale and reads it again if anybody is // looking. It is called where a node changes state (task.go): a task that has // just started belongs under "running" the next time the list is opened, and a diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 0822c9d03..8a43da8e5 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -2078,11 +2078,12 @@ func planWithoutOwnFolder(command, folder string) string { // pressed, so from the press on, every key is held here, in order, and handed // to the page's own keyboard the moment the page is up ([app.finishRailPlan]). // -// THREE WAYS OUT, and none of them reaches the conversation: the answer opens +// FOUR WAYS OUT, and none of them reaches the conversation: the answer opens // the page and replays the keys; the answer says there is no page, the row's // room opens as it always did and the keys are dropped, because a room's box -// is a different receiver again; `esc` withdraws the press. A second press -// replaces the first and starts with no keys. +// is a different receiver again; `esc` withdraws the press; and so does going +// to a place, whose answer then opens nothing ([app.railPlanFront]). A second +// press replaces the first and starts with no keys. type railPlanPending struct { id string keys []tea.KeyPressMsg @@ -2090,12 +2091,23 @@ type railPlanPending struct { func (a *app) beginRailPlan(id string) { a.railPlanPending = railPlanPending{id: id} } +// railPlanFront is whether the answer to a row's press may still open +// anything: only while the conversation the row was pressed in is what is in +// front. A place opened since is a way out of the press ([app.leaveTaskOverlays] +// withdraws it), and this is the same rule read where the answer lands, so no +// door that forgot to withdraw it can open a page over a place or a room under +// one. +func (a *app) railPlanFront() bool { return a.showing() == nil } + func (a *app) finishRailPlan(id string) tea.Cmd { if a.railPlanPending.id != id { return nil } keys := a.railPlanPending.keys a.railPlanPending = railPlanPending{} + if !a.railPlanFront() { + return nil + } // A PROGRAM'S PAGE IS A ROOM IN THE CONVERSATION'S TAB (programroom.go), opened // on the page this read just brought back — which is how a row the surface // did not yet hold as a program's, and a run's own line under its row, reach diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index f025d51ee..a4a19a456 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -169,7 +169,14 @@ func (a *app) workTabFrame(width, height int) []string { // off the strip the work tab went on drawing. Every place opens through // [app.standDownRest], and the conversation's own tab calls it on the way back // ([app.tabGo]). +// +// AND A ROW'S PAGE STILL ON ITS WAY IS WITHDRAWN WITH THEM ([railPlanPending]). +// A person who pressed a run's row and then went Home has left the press +// behind, and its answer — milliseconds later here, seconds over a connection +// — opened the run's page over Home, or a room under it with the box pointed +// at the run while Home's box was the one on screen. func (a *app) leaveTaskOverlays() { + a.railPlanPending = railPlanPending{} if !a.workTabOn && !a.railTaskPlanOn { return } From ad4ca200dcedb1e11d376f4a505647ed0203b5d4 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:39:33 -0400 Subject: [PATCH 096/195] seniordev: the landing stage reads `checking its work` senior-dev reports `landing` once as it starts, and implement replaces it within milliseconds; every other time it is the end-of-run landing turn and the build and tests run on the tree it leaves. It was worded `starting`, so a run's row went back to `starting` for its last minutes of model calls and checks, against the manual's promise that its work reads `working`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 4 +++- internal/seniordev/seniordev.go | 8 +++++++- internal/seniordev/stagewords_test.go | 10 ++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 06e69010f..f7ecac5f3 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -35,7 +35,9 @@ the moment codeaf handed the work over. **The stage is said in plain words.** On the row and on that line senior-dev's stage reads `starting`, `reading the brief`, `working`, `handing in its work`, `checking its work` or `finishing` — never senior-dev's own names for its inner phases. The whole of its -work on the change, every model call and tool included, reads `working`. +work on the change, every model call and tool included, reads `working`; the build and +tests it runs at the end, and a last turn to leave its work in a state that stands, read +`checking its work`. `esc`, a press on the conversation's tab, or a press on `Home` leaves it, and the run goes on. `x` over an empty box, `/stop`, or `Stop` on that line asks `Stop this task?` first. diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 27bfdda07..09c12afa4 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -35,11 +35,17 @@ import ( // Program is senior-dev as codeaf carries it. // stageWords is senior-dev's stage names (app.Stages) in a person's words. +// +// `landing` IS ITS LAST CHECKS. senior-dev reports it once as it starts, and +// `implement` replaces that within milliseconds; every other time it is the +// end-of-run turn that brings its work to a state that stands and the build +// and tests it runs on the tree it leaves. Read as `starting`, the row went +// back to `starting` for the last minutes of a run. var stageWords = map[string]string{ "bootstrap": "starting", "run-contract": "starting", "intake": "reading the brief", - "landing": "starting", + "landing": "checking its work", "implement": "working", "agent-runtime": "working", "compaction-capacity": "working", diff --git a/internal/seniordev/stagewords_test.go b/internal/seniordev/stagewords_test.go index 31c8ebae8..062b5f644 100644 --- a/internal/seniordev/stagewords_test.go +++ b/internal/seniordev/stagewords_test.go @@ -34,6 +34,16 @@ func TestEveryStageSeniorDevReportsHasAPersonsWord(t *testing.T) { } } +// SENIOR-DEV'S LANDING STAGE IS ITS LAST CHECKS, NOT ITS START. It reports +// `landing` once as it starts (superseded by `implement` within milliseconds) +// and then for its end-of-run landing turn and the build and tests it runs on +// the tree it leaves, which read `starting` for those last minutes. +func TestSeniorDevsLandingStageReadsAsCheckingItsWork(t *testing.T) { + if word := Program.StageWords["landing"]; word != "checking its work" { + t.Fatalf("senior-dev's landing stage reads %q, want %q", word, "checking its work") + } +} + func contains(list []string, want string) bool { for _, item := range list { if item == want { From 623912f342ff6ccf6439f0452e4043314c4b59e3 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:39:33 -0400 Subject: [PATCH 097/195] session: a run settled after its process went away says what ended it and when MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reopen settle of a program's run read every cancelled or failed root as the program's own ending: a person's stop came back as `did not finish · stopped: enough`, a limit lost which limit it was, and codeaf's own sentence was drawn as `was cut short from outside the work` on the side list. It was dated by the store's ending, not the program's recorded exit, so a wait for owed receipts counted as run time. Now a cancelled root is the person's stop, the limit sentence is the limit its spend against the ceiling says, codeaf's sentence is the row's reason, and the row ends at runClockEnd. Closing codeaf after the program had exited (its worker settling receipts, or the run about to land) wrote `codeaf closed while <name> was running`. The close and the reopen now end such a run at the program's exit with `<name> had ended; codeaf closed before its work was brought in`. The project index closed every run row a dead process left running at the reopen instant with no duration: a run that ended at 09:55 read ended 10:53 with no time. A run row is now closed where its run ended (the program's exit, the store's ending, or its last evidence of life), with its span. The manual says what is true: leaving a hosted conversation's window only detaches a program's run, which ends with the engine holding the conversation; the index row quotes what the code writes; the landed card spells a run's time `22m51s`; and a program's run nothing had ended is ended where it was last seen before its store is archived. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 8 +- internal/manual/chat/senior-dev.md | 45 ++-- internal/manual/chat/worker-harness.md | 3 +- internal/session/task_index.go | 77 +++++++ internal/session/task_run_belt.go | 95 ++++++++- internal/session/task_run_orphan_test.go | 7 +- internal/session/task_run_settle_test.go | 249 +++++++++++++++++++++++ 7 files changed, 447 insertions(+), 37 deletions(-) create mode 100644 internal/session/task_run_settle_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 2d152672d..9135d366d 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -96,9 +96,11 @@ when that work has ended`. **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 does not outlive codeaf.** If codeaf quits, crashes or is stopped while a program -works, its run ends with `codeaf closed while <name> was running`, at the last moment it -was seen working. Nothing carries it on: the next hand-off starts a run of its own. +**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 <name> was running` where +it was last seen working, or `<name> had ended; codeaf closed before its work was brought +in` at the program's exit. Nothing carries it on; the next hand-off starts a run of its own. A name your build does not carry is refused with the ones it does: `this codeaf carries no program called <name>; it carries …`. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index f7ecac5f3..dc3cf2fd0 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -341,16 +341,16 @@ Everything that shows the run's time shows that one span: the line under its pag ended, even before the work has landed), its row and card once it has landed, the note the conversation is handed when it lands (`done · ran 22m 51s · …`), and the chat's `tasks` tool (`#3 · <title> · done · ran 22m 51s`, 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`. +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, and the branch its work is -on. +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`, @@ -366,7 +366,10 @@ see it, and a window that has the run's conversation open says it is being worke conversation that started the run lists it once, by the number its rail shows. If codeaf went away while the run was working, its row is closed the next time that -conversation is opened: `incomplete — codeaf closed while this was still running`. +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 whose work codeaf never brought in reads `incomplete — codeaf +closed while this was still running`. ## Why did senior-dev stop — how a run ends, its log, crashed or stopped @@ -384,8 +387,10 @@ A run ends in one of these ways, and the task's ending says which: refused `senior-dev.json`, no git repository at a shell without `--in-place`); - `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` — codeaf quit, crashed or was stopped - while it worked (see the next section). +- `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 its work was brought in` — senior-dev had + already exited, and codeaf stopped before its work was landed (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 @@ -393,18 +398,20 @@ tests could run, or to where it began. ## If codeaf quits while senior-dev works — closed, crashed, engine stopped, restarted mid-run -senior-dev runs inside the codeaf that started it and ends with it. When you quit codeaf -or close the conversation, when the engine is stopped (`codeaf engine --stop`, a signal), -or when codeaf crashes while senior-dev is working, the run is over: its page and its row -on the side list read `incomplete` with `codeaf closed while senior-dev was running` beside -it, no stage, and nothing waiting on you — it is not a fault, and there is nothing to carry -on. - -**The run ends where it was last seen working**: the end of its last model call, its last -charge, or its store's last change, whichever is latest. So the time and the spend on its -page stop there, and do not count the hours codeaf was closed. When codeaf closes in an -orderly way the ending is written before senior-dev is stopped; after a crash it is -written by the next codeaf that opens that conversation, or that hands work off in it. +senior-dev ends with the engine holding its conversation. Leaving a hosted conversation's +window (closing it, `ctrl+c`, a closed terminal) only detaches: senior-dev keeps working. +When that engine is stopped (`codeaf engine --stop`, a signal) or crashes, the conversation +itself 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` beside it, no +stage, nothing waiting on you, and no fault. If senior-dev had already exited, it reads +`senior-dev had ended; codeaf closed before its work was brought in`, and its work is +where senior-dev left it, not squashed. + +**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` in that conversation starts a run of its own, under its own task number, with its own brief and its own page. The old run's page diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 1df2f3961..d2c74af59 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -29,7 +29,8 @@ ANOTHER folder is refused while that run is underway, with both folders named an 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. 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, exactly as it was left, +the old run's store is kept beside the new one as its record — an ordinary run's exactly +as it was left, and a program's run nothing had ended first ended where it was last seen — and new work never runs inside it. **When the run ends its work comes home by itself.** The copy's work is committed and diff --git a/internal/session/task_index.go b/internal/session/task_index.go index 9cfcb1b88..d158be415 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -69,6 +69,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" ) @@ -704,6 +705,15 @@ const taskInterruptedOutcome = "incomplete — codeaf closed while this was stil // // The close is an APPEND, like every other write to this file, so nothing is // rewritten and a crash during it costs at most one row. +// +// A RUN'S ROW IS CLOSED WHERE THE RUN ENDED, WITH ITS SPAN, not at this +// instant. A hand-off's run rows reach this file too (task_run_index.go), and +// a run knows when it was last at work — its program's recorded exit, its +// store's ending, its last model call or charge ([Agent.interruptedRunEnds]). +// Closed at the reopen instant with no duration, a run that had ended at +// 09:55 read `ended 10:53` with no time at all, and the `@` list said it had +// ended moments ago. A graph node's row, and a run row nothing is known of, +// still close now with the duration they had. func (a *Agent) closeInflightTaskIndexRows() { // A node's own agent shares its parent's project directory and has no // business closing the conversation's rows (the argument [Agent.recoverTasks] @@ -722,6 +732,8 @@ func (a *Agent) closeInflightTaskIndexRows() { return } held := a.heldTaskIDs() + runEnd, done := a.interruptedRunEnds() + defer done() now := time.Now() for _, row := range ReadTaskIndex(path) { if row.SessionID != session || !row.Live() || held[strings.TrimSpace(row.ID)] { @@ -731,10 +743,75 @@ func (a *Agent) closeInflightTaskIndexRows() { closed.Status = string(TaskFailed) closed.Outcome = taskInterruptedOutcome closed.EndedAt = now + if ended := runEnd(row); !ended.IsZero() { + closed.EndedAt = ended + closed.DurationMS = runSpan(row.StartedAt, ended).Milliseconds() + } appendTaskIndex(path, closed) } } +// interruptedRunEnds answers, for a run row this conversation keeps, the +// instant its run was last known to be at work, read out of the conversation's +// run store; zero for every other row, and for a run the store does not hold. +// done closes the store once the rows are closed. +func (a *Agent) interruptedRunEnds() (func(TaskIndexEntry) time.Time, func()) { + none := func(TaskIndexEntry) time.Time { return time.Time{} } + runs := a.ownRunRowIDs() + g := a.tasker() + if len(runs) == 0 || g == nil || g.planPath() == "" { + return none, func() {} + } + if info, err := os.Stat(g.planPath()); err != nil || info.IsDir() { + return none, func() {} + } + store, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + return none, func() {} + } + return func(row TaskIndexEntry) time.Time { + if !runs[strings.TrimSpace(row.ID)] { + return time.Time{} + } + return interruptedRunEnd(store, row) + }, func() { _ = store.Close() } +} + +// interruptedRunEnd is where a run the store holds ended: for the run's own +// row, the end of its one pair ([runClockEnd]) — the program's recorded exit, +// else the store's ending, else the run's last evidence of life (its last +// model call, its last charge, the latest write to any of its tasks); for a +// hand-off that joined it, that task's own ending, else the run's. +func interruptedRunEnd(store *plandb.Store, row TaskIndexEntry) time.Time { + rootID := store.RootID() + root := store.Task(rootID) + if root == nil { + return time.Time{} + } + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), rootID) + record, _ := delegate.ReadProgram(taskDir) + ending := lastEvidenceOfLife(store, root, taskDir, record) + for _, task := range store.Tasks() { + if task.UpdatedAt.After(ending) { + ending = task.UpdatedAt + } + } + if terminalStoreStatus(root.Status) { + ending = root.CompletedAt + } + if id := strings.TrimSpace(row.ID); id != rootID { + task := store.Task(id) + if task == nil { + return time.Time{} + } + if terminalStoreStatus(task.Status) { + return task.CompletedAt + } + return ending + } + return runClockEnd(row.StartedAt, record, ending) +} + // heldTaskIDs is the set of node ids this session's graph is holding, or nil for // a session that never built one. It reads the graph WITHOUT constructing one, // on [Agent.liveTaskRows]'s own terms. diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index b19fdc6ac..c43abef0c 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -724,6 +724,23 @@ func programClosedSentence(name string) string { return "codeaf closed while " + name + " was running" } +// programEndedSentence is the ending written on a program's run that codeaf +// closed under AFTER the program had exited: its worker was still settling +// owed receipts, or the run was about to land. The program was not running, +// so the sentence does not say it was, and the work it left was never brought +// in — it is where the program left it (for a program that works in its own +// copy, in that copy on the task's branch, not squashed). +func programEndedSentence(name string) string { + return name + " had ended; codeaf closed before its work was brought in" +} + +// runLimitSentence is the run engine's outcome word for a run a limit its +// person set ended (internal/run's OutcomeLimit, spelled here because this +// package may not reach that one). A program's run that ended on a limit +// carries it as its store's ending when no sentence of the program's own came +// back ([runEndingWords]), and a reopen reads the limit back out of it. +const runLimitSentence = "a limit you set stopped it" + // endOrphanedProgramRun ends a program's run whose store was left open by a // process that went away, at the run's last evidence of life. It does nothing // to a store whose run has ended, or whose run no program worked (the task's @@ -743,7 +760,7 @@ func endOrphanedProgramRun(store *plandb.Store) { if !ok { return } - endProgramRunClosed(store, record.Name, lastEvidenceOfLife(store, root, taskDir, record)) + endProgramRunClosed(store, record, lastEvidenceOfLife(store, root, taskDir, record)) } // endProgramRunClosed writes a program's run's ending when codeaf closed under @@ -752,8 +769,18 @@ func endOrphanedProgramRun(store *plandb.Store) { // its page carries — the store's error is a field no page draws, and a page // that read `incomplete` with nothing beside it would send a person looking // for a fault in the work. -func endProgramRunClosed(store *plandb.Store, name string, at time.Time) { - sentence := programClosedSentence(name) +// +// A PROGRAM WHOSE RECORD CARRIES ITS EXIT WAS NOT RUNNING. Its worker writes +// the exit before it settles the program's owed receipts, and the run lands +// only after that, so codeaf can close over a program that has already gone: +// that run is ended at the program's exit ([runClockEnd]'s instant), in the +// sentence that says so ([programEndedSentence]), and never in the one that +// claims codeaf closed under a program at work. +func endProgramRunClosed(store *plandb.Store, record delegate.ProgramRecord, at time.Time) { + sentence := programClosedSentence(record.Name) + if !record.EndedAt.IsZero() { + sentence, at = programEndedSentence(record.Name), record.EndedAt + } if err := store.FailRootAt(sentence, at); err != nil { return } @@ -841,6 +868,11 @@ func (a *Agent) endInterruptedProgramRun() { // The row now says the same, not as a fault, ending where the store ended it. // A run whose task the store calls done is left as it came back: its program // finished, but the work was never landed, and that is a person's call. +// +// THE ROW ENDS AT THE PROGRAM'S RECORDED EXIT when the record carries one, and +// at the store's ending otherwise ([runClockEnd]) — the pair every live settle +// reads ([Agent.beltRunEndedAt]). The store's ending can come after the exit +// by the whole wait for owed receipts, and that wait is not the run's time. func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, kept TaskNotice) { root := store.Task(store.RootID()) if root == nil || (root.Status != plandb.StatusFailed && root.Status != plandb.StatusCancelled) { @@ -852,17 +884,53 @@ func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, k } settled := kept settled.State = TaskFailed - settled.Report = strings.TrimSpace(root.Error) - settled.Ending = TaskEndingProgram - if settled.Report == "" || settled.Report == programClosedSentence(record.Name) { - settled.Report = programClosedSentence(record.Name) - settled.Ending = TaskEndingInterrupted - } - settled.EndedAt = root.CompletedAt + settled.Report, settled.Ending, settled.Stopped = interruptedProgramEnding(store, root, record) + settled.EndedAt = runClockEnd(kept.StartedAt, record, root.CompletedAt) settled.Elapsed = 0 a.publishRunRow(g, settled) } +// interruptedProgramEnding is how a program's run that a reopen settles ended, +// read off what its store and its record kept: the sentence the row carries, +// its ending, and whether a person stopped it. +// +// EACH ENDING IS THE ONE THE LIVE SETTLE WOULD HAVE DRAWN, because the row is +// the same row whichever process settles it. A CANCELLED ROOT IS A PERSON'S +// STOP (the stop road writes it before the program is ended, and a person who +// quits during that wait has still stopped it). THE LIMIT SENTENCE IS THE +// LIMIT, and which one is a fact of the run: the dollar ceiling it handed its +// program was reached, or else its time ran out. codeaf's own sentences, and +// every sentence of the program's, are read as the program's ending, whose +// reason is the sentence itself — so the side list reads `codeaf closed while +// senior-dev was running`, not the fixed words of a cut it cannot explain. +func interruptedProgramEnding(store *plandb.Store, root *plandb.Task, record delegate.ProgramRecord) (string, TaskEnding, bool) { + report := strings.TrimSpace(root.Error) + switch { + case root.Status == plandb.StatusCancelled: + return report, TaskEndingStopped, true + case report == runLimitSentence: + return report, interruptedLimitEnding(store, record), false + case report == "": + return programClosedSentence(record.Name), TaskEndingProgram, false + } + return report, TaskEndingProgram, false +} + +// interruptedLimitEnding is which limit ended a program's run, off the run's +// own facts: its spend against the dollar ceiling the run handed its program +// (the run's own ceiling, [delegate.ProgramRecord.CeilingUSD]) says the +// dollars ran out, and any other limit ending is the run's time. +func interruptedLimitEnding(store *plandb.Store, record delegate.ProgramRecord) TaskEnding { + spent := 0.0 + for _, total := range store.SpendSummary().ByRole { + spent += total.USD + } + if record.CeilingUSD > 0 && spent >= record.CeilingUSD { + return TaskEndingCostLimit + } + return TaskEndingTimeLimit +} + // holdsInterruptedRun says whether any run row this graph holds came back // interrupted, so a conversation with none never opens its store to ask. func (g *TaskGraph) holdsInterruptedRun() bool { @@ -994,7 +1062,12 @@ func (a *Agent) cutBeltRun() { a.beltMu.Unlock() if run != nil && run.delegate != nil && !stopped { // A run a person already stopped keeps the stop's ending and its words. - endProgramRunClosed(run.store, run.delegate.Name, time.Time{}) + // A program that has not written its record yet is named by the run. + record := beltRunProgram(run) + if record.Name == "" { + record.Name = run.delegate.Name + } + endProgramRunClosed(run.store, record, time.Time{}) } if cut != nil { cut() diff --git a/internal/session/task_run_orphan_test.go b/internal/session/task_run_orphan_test.go index f0bdc50e3..1ccc68cf5 100644 --- a/internal/session/task_run_orphan_test.go +++ b/internal/session/task_run_orphan_test.go @@ -241,11 +241,12 @@ func TestAReopenedConversationEndsTheProgramRunItsLastProcessLeftOpen(t *testing // on, so the row the reopen restored as interrupted — which the side list // drew as `?`, waiting on a person — is settled where the page already // stood: ended, in codeaf's sentence, not a fault, at the instant it was - // last seen, with its span. + // last seen, with its span. The sentence is the row's reason, so its ending + // is the one whose reason is read from its report. rows := reopened.graph().runRows(id) - if len(rows) != 1 || rows[0].State != TaskFailed || rows[0].Ending != TaskEndingInterrupted || + if len(rows) != 1 || rows[0].State != TaskFailed || rows[0].Ending != TaskEndingProgram || rows[0].Report != "codeaf closed while fake was running" || !rows[0].EndedAt.Equal(lastSeen) { - t.Fatalf("the row came back as %+v, want it ended as interrupted, in codeaf's sentence, at %v", rows, lastSeen) + t.Fatalf("the row came back as %+v, want it ended in codeaf's sentence, at %v", rows, lastSeen) } page, ok := reopened.PlanTaskPage(rootID) if !ok || page.Row.Status != string(plandb.StatusFailed) || page.Row.Stage != "" || !page.Row.Live.Empty() || diff --git a/internal/session/task_run_settle_test.go b/internal/session/task_run_settle_test.go new file mode 100644 index 000000000..23a8c9a99 --- /dev/null +++ b/internal/session/task_run_settle_test.go @@ -0,0 +1,249 @@ +package session + +// A RUN SETTLED AFTER ITS PROCESS WENT AWAY SAYS WHAT ENDED IT AND WHEN. +// +// The reopen road ([Agent.settleInterruptedProgramRow]) and the close road +// ([Agent.cutBeltRun]) each write a program's run's ending without the run +// there to say how it ended, and the project's index closes every run row its +// last process left running ([Agent.closeInflightTaskIndexRows]). These tests +// pin that each of them tells the truth the run left behind: a person's stop +// is a stop, a limit is the limit, a program that had already exited is not +// said to have been running, and every ending is dated by the run's own facts +// rather than by the moment somebody noticed. + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// reopenedWith seeds a program's run the way a process that went away leaves +// it, lets change end it in its store (or not), closes the conversation that +// held its running row, and opens it again. It answers the reopened agent, the +// run's row id and the instant the run was last seen working. +func reopenedWith(t *testing.T, change func(store *plandb.Store, taskDir string, lastSeen time.Time)) (*Agent, uint64, time.Time) { + t.Helper() + place := t.TempDir() + workspace := newTestRepo(t) + registerBeltRunEngine(t, newBeltRunDouble("")) + open := func() *Agent { + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: place} + config.SessionFile = filepath.Join(place, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + life := open() + g := life.graph() + id := g.reserve() + rootID := strconv.FormatUint(id, 10) + started := time.Now().UTC() + lastSeen := orphanProgramStore(t, g.planPath(), rootID, "the brief", g.planChat()) + store, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + change(store, plandb.TaskDir(filepath.Dir(g.planPath()), rootID), lastSeen) + _ = store.Close() + life.publishRunRow(g, TaskNotice{ID: id, Title: "the dead run", State: TaskRunning, StartedAt: started}) + _ = life.Close() + return open(), id, lastSeen +} + +// reopenedRow is the one run row a reopened conversation holds under id. +func reopenedRow(t *testing.T, agent *Agent, id uint64) TaskNotice { + t.Helper() + rows := agent.graph().runRows(id) + if len(rows) != 1 { + t.Fatalf("the reopened conversation holds %d rows under %d, want one: %+v", len(rows), id, rows) + } + return rows[0] +} + +// A PERSON'S STOP IS A STOP AFTER A REOPEN TOO. The person stopped the run and +// quit codeaf while the stop was still settling; the reopened row used to read +// as the program ending itself (`did not finish · stopped: enough`). +func TestAReopenedRunAPersonStoppedSettlesAsTheirStop(t *testing.T) { + agent, id, _ := reopenedWith(t, func(store *plandb.Store, _ string, _ time.Time) { + if err := store.StopRoot("stopped: enough"); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.State != TaskFailed || !row.Stopped || row.Ending != TaskEndingStopped || row.Report != "stopped: enough" { + t.Fatalf("the stopped run came back as %+v, want it settled as the person's stop", row) + } +} + +// A LIMIT ITS PERSON SET IS THE ROW'S ENDING AFTER A REOPEN TOO, and which +// limit it was is read off the run's own facts: the spend against the ceiling +// the run handed its program. +func TestAReopenedRunALimitEndedKeepsWhichLimit(t *testing.T) { + for _, tc := range []struct { + name string + ceiling float64 + want TaskEnding + }{ + {"the dollars ran out", 0.25, TaskEndingCostLimit}, + {"the time ran out", 3.56, TaskEndingTimeLimit}, + } { + t.Run(tc.name, func(t *testing.T) { + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, _ time.Time) { + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", CeilingUSD: tc.ceiling}); err != nil { + t.Fatal(err) + } + if err := store.FailRoot(runLimitSentence); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.State != TaskFailed || row.Ending != tc.want || row.Stopped { + t.Fatalf("the limit-ended run came back as %+v, want the ending %q", row, tc.want) + } + }) + } +} + +// THE ROW CODEAF CLOSED UNDER READS CODEAF'S SENTENCE AS ITS REASON, which is +// what senior-dev.md promises beside `incomplete`; it used to read the fixed +// `was cut short from outside the work` of an ending machinery cut. +func TestAReopenedRunCodeafClosedUnderReadsItsSentence(t *testing.T) { + agent, id, _ := reopenedWith(t, func(*plandb.Store, string, time.Time) {}) + row := reopenedRow(t, agent, id) + if row.Ending != TaskEndingProgram || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { + t.Fatalf("the closed run came back as %+v with the reason %q, want codeaf's sentence", row, TaskReasonOf(row.Ending, row.Report)) + } +} + +// THE PROGRAM'S RECORDED EXIT WINS OVER THE STORE'S LATER ENDING. A store +// ending written after the program had gone (the worker was settling owed +// receipts) used to date the reopened row, which counted the wait as run time. +func TestAReopenedRunEndsAtItsProgramsRecordedExit(t *testing.T) { + var exited time.Time + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, lastSeen time.Time) { + exited = lastSeen.Add(time.Millisecond) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", StartedAt: lastSeen.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + if err := store.FailRoot("fake did not finish: the tests fail"); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if !row.EndedAt.Equal(exited) || row.Ending != TaskEndingProgram { + t.Fatalf("the run came back ended at %v (%+v), want the program's exit %v", row.EndedAt, row, exited) + } +} + +// A PROGRAM THAT HAD ALREADY EXITED IS NOT SAID TO HAVE BEEN RUNNING. codeaf +// closed while the worker was settling the program's receipts, after the +// program was gone: the run is ended where the program ended, in a sentence +// that says the program had ended and its work was never brought in. +func TestClosingAfterTheProgramExitedSaysItHadEnded(t *testing.T) { + place := t.TempDir() + double := newBeltRunDouble("") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "the brief"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + store := double.spec.Store + double.mu.Unlock() + rootID := store.RootID() + exited := time.Now().UTC() + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(store.Path()), rootID), delegate.ProgramRecord{Name: "fake", StartedAt: exited.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + _ = agent.Close() + kept := beltRunStoreAt(t, place) + root := kept.Task(rootID) + _ = kept.Close() + if root.Error != "fake had ended; codeaf closed before its work was brought in" || !root.CompletedAt.Equal(exited) { + t.Fatalf("after Close the run's task = %s (%q, ended %v), want it ended at the program's exit %v in a true sentence", root.Status, root.Error, root.CompletedAt, exited) + } + close(double.release) + <-double.finished +} + +// AND THE SAME ON THE REOPEN ROAD: a store left open over a program that had +// exited is ended at that exit, in the same sentence. +func TestAReopenedRunWhoseProgramHadExitedSaysItHadEnded(t *testing.T) { + var exited time.Time + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, lastSeen time.Time) { + exited = lastSeen.Add(time.Millisecond) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", StartedAt: lastSeen.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.Report != "fake had ended; codeaf closed before its work was brought in" || !row.EndedAt.Equal(exited) || row.Ending != TaskEndingProgram { + t.Fatalf("the run came back as %+v, want it ended at the program's exit %v in a true sentence", row, exited) + } +} + +// A RUN ROW THE INDEX CLOSES ON REOPEN ENDS WHERE THE RUN DID, WITH ITS SPAN. +// A program that had finished (its store says done) but whose work codeaf +// closed before landing was closed in the project's index at the reopen +// instant, hours after the run, with no duration. +func TestTheIndexClosesAnInterruptedRunRowFromTheRunsOwnFacts(t *testing.T) { + var exited time.Time + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, lastSeen time.Time) { + exited = lastSeen.Add(time.Millisecond) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", StartedAt: lastSeen.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + if err := store.CompleteRoot("finished: the change is in"); err != nil { + t.Fatal(err) + } + }) + var row TaskIndexEntry + for _, entry := range lastPerNode(ReadTaskIndex(agent.config.taskIndexFile())) { + if entry.ID == strconv.FormatUint(id, 10) { + row = entry + } + } + if row.Live() || !row.EndedAt.Equal(exited) || row.DurationMS != exited.Sub(row.StartedAt).Milliseconds() || row.DurationMS <= 0 { + t.Fatalf("the index closed the run as %+v, want it ended at the program's exit %v with its span", row, exited) + } +} + +// AND AN ORDINARY RUN — no program — is closed at its store's last evidence of +// life, not at the reopen instant. +func TestTheIndexClosesAnOrdinaryRunRowAtItsLastEvidenceOfLife(t *testing.T) { + var seen time.Time + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, lastSeen time.Time) { + if err := os.Remove(filepath.Join(taskDir, delegate.ProgramFile)); err != nil { + t.Fatal(err) + } + seen = lastSeen + // The reopen comes well after the run was last seen. + time.Sleep(300 * time.Millisecond) + }) + var row TaskIndexEntry + for _, entry := range lastPerNode(ReadTaskIndex(agent.config.taskIndexFile())) { + if entry.ID == strconv.FormatUint(id, 10) { + row = entry + } + } + if row.Live() || row.EndedAt.After(seen.Add(100*time.Millisecond)) || row.EndedAt.Before(seen) || row.DurationMS <= 0 { + t.Fatalf("the index closed the ordinary run as %+v, want it ended where it was last seen, %v", row, seen) + } +} From 120a73bfab812da4254917a376be1583931fce04 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:32:28 -0400 Subject: [PATCH 098/195] session, tui3: home counts a conversation's dollars once, and its books reach meta.json as work folds The home card and a project's facts added the books a conversation stamps on meta.json to the bills on its rows in the project's index. The books already hold every senior-dev run and every closed task folded into them, so a conversation whose only spend was a $2.30 run read 'spent $4.60' (the run's new index row made this reach program runs; graph tasks had the same double count). Both now take the larger of the two, as the live money segment does, and the same for tokens. For the larger to be exact the books on disk must already hold the work, so the session now stamps them when a run settles and when a task's tally folds, not only at the next turn's seal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/home.md | 19 +++++---- internal/session/task_run.go | 9 +++- internal/session/task_run_belt.go | 6 +++ internal/session/task_run_money_test.go | 35 ++++++++++++++++ internal/session/taskcost_test.go | 38 +++++++++++++++++ internal/tui3/home.go | 41 +++++++++++++----- internal/tui3/homeband_projectfacts.go | 10 +++-- internal/tui3/homefacts_spend_test.go | 55 +++++++++++++++++++++++++ 8 files changed, 190 insertions(+), 23 deletions(-) create mode 100644 internal/tui3/homefacts_spend_test.go diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index d9f3f08b8..d7093ae48 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2215,14 +2215,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 diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 7df61f9b8..d8b4218ef 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -7411,13 +7411,20 @@ func (a *Agent) foldTaskUsage(node *TaskNode, child *Agent) { // THE FOLD DOOR, not the ordinary auxiliary one: the node journaled these // same tokens into the machine's usage ledger as it spent them, and folding // the total in again would count them twice ([Agent.addFoldedUsage]). - a.spendLedger(node).addFoldedUsage(&ai.Response{Usage: &ai.Usage{ + ledger := a.spendLedger(node) + ledger.addFoldedUsage(&ai.Response{Usage: &ai.Usage{ PromptTokens: used.Input, CompletionTokens: used.Output, CacheReadInputTokens: used.CacheRead, CacheCreationInputTokens: used.CacheWrite, Cost: &cost, }}, child.Model(), used.Calls) + // The books on disk are told as the node's tally reaches them, for the + // reason [Agent.driveBeltRun] stamps a run's: home takes the larger of the + // stamped books and the index rows, and that is exact only while meta.json + // already holds every closed node the index names. A worker that folds a part + // has no place of its own, and its stamp writes nothing. + ledger.stampSpend() } // spendLedger is WHICH SET OF BOOKS this node's spend goes into: the agent that diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index c43abef0c..05c0c5221 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1097,6 +1097,12 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. fold.total(summary.USD) + // AND meta.json IS TOLD NOW, not at the next turn's seal. Home reads this + // conversation's bill as the larger of its stamped books and its index rows + // (tui3's homeFacts), which is exact only while the books on disk already + // hold every run the index names. A stamp that waited for the next turn left + // the card reading the run alone, with the conversation's own talking missing. + a.stampSpend() if run.cut != nil { defer run.cut() } diff --git a/internal/session/task_run_money_test.go b/internal/session/task_run_money_test.go index 0ddfcec77..6578cec8f 100644 --- a/internal/session/task_run_money_test.go +++ b/internal/session/task_run_money_test.go @@ -89,3 +89,38 @@ func TestARunsCallsAreFoldedWholeAndEachDollarOnce(t *testing.T) { t.Fatalf("the fold wrote %d ledger rows, want none", len(lines)) } } + +// A RUN'S DOLLARS REACH THE CONVERSATION'S meta.json THE MOMENT THEY REACH ITS +// BOOKS, not at the next turn's seal. Home reads a conversation's bill from two +// places — the books stamped on meta.json and the run's own row in the +// project's index — and takes the larger, because the books already hold every +// run they were told about. That is only exact if the books on disk are told +// when the run settles: a stamp that waited for the next turn left a card +// reading the run alone while the conversation's own talking was missing. +func TestARunsDollarsAreStampedOnTheConversationWhenItSettles(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run ended") + double.summary.USD = 2.30 + registerBeltRunEngine(t, double) + + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run ended"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = Place{Dir: dir}.Transcript() + config.AskConsent = false + }) + if _, _, _, err := agent.StartTask(context.Background(), "account for this run", false); err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + endBeltRun(t, agent, double) + + meta, err := LoadMeta(dir) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.SpentUSD != 2.30 { + t.Fatalf("meta.json says the conversation spent %v with no turn sealed since the run, want the run's $2.30", meta.SpentUSD) + } +} diff --git a/internal/session/taskcost_test.go b/internal/session/taskcost_test.go index b8ba1fb5f..886ce60ad 100644 --- a/internal/session/taskcost_test.go +++ b/internal/session/taskcost_test.go @@ -319,3 +319,41 @@ func awaitIndexRow(t *testing.T, path string, id uint64, want func(TaskIndexEntr time.Sleep(5 * time.Millisecond) } } + +// A CLOSED NODE'S DOLLARS REACH THE CONVERSATION'S meta.json AS THEY REACH ITS +// BOOKS. Home reads a conversation's bill as the larger of the books stamped +// there and its rows in the project's index, which is exact only while the +// stamp already holds every node the index names. A stamp that waited for the +// next turn's seal left a card reading the work alone, with the talking that +// commissioned it missing until the person spoke again. +func TestAClosedNodesDollarsAreStampedOnTheConversation(t *testing.T) { + dir := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Place = Place{Dir: dir} + config.SessionFile = Place{Dir: dir}.Transcript() + }) + child := spentChild(t, 0.75) + + graph := agent.graph() + graph.mu.Lock() + graph.run = func(node *TaskNode) { + agent.foldTaskUsage(node, child) + node.finish("the greeting is written", []string{"greet.go"}, "", mergeInPlace) + node.graph.complete(node, TaskDone) + } + graph.mu.Unlock() + id := graph.reserve() + graph.admit(id, taskSpec{ + title: "Add the greeting", brief: "write greet.go", acceptance: "the file is there", + model: "vendor/worker", + }) + waitDoneNode(t, graph.node(id)) + + meta, err := LoadMeta(dir) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.SpentUSD != 0.75 { + t.Fatalf("meta.json says the conversation spent %v once its node closed, want the node's $0.75", meta.SpentUSD) + } +} diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 9f764e3ca..fc8d04a61 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -5385,14 +5385,11 @@ func homeBands(bands [][]string, room int) []string { // is three absences dressed as three facts — so each part appears only when // there is something to say, and a footer with nothing to say is not drawn. // -// THE SUM IS THE TALKING PLUS THE WORK IT COMMISSIONED, and it is added up -// here because it is written down in two places for two good reasons. The -// conversation's own turns are stamped on its meta.json by the session that -// held them ([session.SessionRow.Spend]); every task it started is a row of the -// project's index with its own bill ([session.TaskRollup.Spend]). A person -// looking at a card does not have that distinction in their head — they asked -// what this conversation cost — so the card answers with one figure, and the -// two halves stay separate everywhere they are recorded. +// THE FIGURE IS THE TALKING AND THE WORK IT COMMISSIONED, read from the two +// places it is written down ([conversationSpend] says how they are joined). A +// person looking at a card asked what this conversation cost, so the card +// answers with one figure, and the two records stay separate everywhere they +// are kept. // // AND THE FILES ARE HERE TOO, because nothing else on the card carries them and // it is the most physical number the index holds: tokens are what the work @@ -5402,10 +5399,10 @@ func homeFacts(row session.SessionRow, now time.Time) string { if files := homeFilesTouched(row); files > 0 { parts = append(parts, "touched "+itoa(files)+plural(" file", files)) } - if spend := row.Spend + row.Tasks.Spend; spend > 0 { + if spend := conversationSpend(row); spend > 0 { parts = append(parts, "spent "+dollars(spend)) } - if tokens := row.Tokens + row.Tasks.Tokens; tokens > 0 { + if tokens := conversationTokens(row); tokens > 0 { parts = append(parts, tokenWord(tokens)+" tokens") } // The later of "somebody spoke" and "work landed": both are this @@ -5420,6 +5417,30 @@ func homeFacts(row session.SessionRow, now time.Time) string { return strings.Join(parts, " · ") } +// conversationSpend is what one conversation cost, from the two places it is +// written down: the books the session stamps on its meta.json +// ([session.SessionRow.Spend]) and the bills on its rows in the project's index +// ([session.TaskRollup.Spend]). +// +// IT IS THE LARGER OF THE TWO AND NEVER THEIR SUM, for the reason the live +// surface's [app.spendShown] is. The books already hold every run and every +// closed task this conversation folded in, and the session stamps them the +// moment the fold lands (internal/session's driveBeltRun and foldTaskUsage), so +// adding the index's bills on top counted that work twice: a conversation whose +// only spend was a $2.30 senior-dev run read `spent $4.60`. The index is ahead +// only while work is still running and has not folded yet, and then its figure +// is the truer one. +func conversationSpend(row session.SessionRow) float64 { + return max(row.Spend, row.Tasks.Spend) +} + +// conversationTokens is [conversationSpend]'s rule for tokens, for its reason: +// a closed task's tokens are folded into the books with its dollars, and its +// index row carries them again. +func conversationTokens(row session.SessionRow) int { + return max(row.Tokens, row.Tasks.Tokens) +} + // homeHolding says whether a window has this conversation open right now and // what it is doing, and says nothing at all when nobody has it. // diff --git a/internal/tui3/homeband_projectfacts.go b/internal/tui3/homeband_projectfacts.go index e114aa326..85810d672 100644 --- a/internal/tui3/homeband_projectfacts.go +++ b/internal/tui3/homeband_projectfacts.go @@ -57,10 +57,12 @@ func projectFacts(project session.Project, now time.Time) string { var touched time.Time for _, row := range project.Sessions { tasks += row.Tasks.Total() - // The talking and the work it commissioned, added the way [homeFacts] - // adds them for one conversation: one figure, because one figure is what - // "what has this project cost" means. - spend += row.Spend + row.Tasks.Spend + // The talking and the work it commissioned, joined the way [homeFacts] + // joins them for one conversation ([conversationSpend]'s larger of the + // two, because the books already hold the work they folded), then summed + // across conversations: one figure, because one figure is what "what has + // this project cost" means. + spend += conversationSpend(row) // The later of "somebody spoke" and "work landed", exactly as // [homeFacts] takes it for one conversation: both are this project being // active, and the footer is asked when, not how. diff --git a/internal/tui3/homefacts_spend_test.go b/internal/tui3/homefacts_spend_test.go new file mode 100644 index 000000000..d08e40204 --- /dev/null +++ b/internal/tui3/homefacts_spend_test.go @@ -0,0 +1,55 @@ +package tui3 + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// A CONVERSATION'S BILL ON HOME IS COUNTED ONCE, read from real rows on disk. +// The session stamps its books on meta.json, and those books already hold every +// run and every closed task it folded in; the run's own row in the project's +// index carries the same dollars again. The card and the project's facts added +// the two, so a conversation whose only spend was a $2.30 senior-dev run read +// `spent $4.60`. +func TestHomeCountsARunsDollarsOnce(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + transcript := lab.session("-tmp-alpha", "aaaa000000000001", "hand it off", "/tmp/alpha", now.Add(-time.Hour)) + dir := filepath.Dir(transcript) + meta, err := session.LoadMeta(dir) + if err != nil { + t.Fatal(err) + } + // What the session's own stamp writes once the run has folded: the books + // hold the run, and they hold the talking beside it. + meta.SpentUSD, meta.Tokens = 2.30, 1200 + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + // What the run's own row in the index writes: the run's dollars alone. + lab.task("-tmp-alpha", session.TaskIndexEntry{ + ID: "1", Name: "hand-it-off", Label: "Hand it off", Title: "Hand it off", + Status: string(session.TaskDone), Cost: 2.30, SessionID: "aaaa000000000001", + StartedAt: now.Add(-50 * time.Minute), EndedAt: now.Add(-30 * time.Minute), + }) + + world := session.ReadWorld(lab.root) + if len(world.Projects) != 1 || len(world.Projects[0].Sessions) != 1 { + t.Fatalf("read %+v, want one project holding one conversation", world.Projects) + } + project := world.Projects[0] + row := project.Sessions[0] + if row.Spend != 2.30 || row.Tasks.Spend != 2.30 { + t.Fatalf("the rows read books %v and index %v, want $2.30 in both", row.Spend, row.Tasks.Spend) + } + if card := homeFacts(row, now); !strings.Contains(card, "spent $2.30") { + t.Fatalf("the card reads %q, want the run's $2.30 once", card) + } + if facts := projectFacts(project, now); !strings.Contains(facts, "spent $2.30") { + t.Fatalf("the project's facts read %q, want the run's $2.30 once", facts) + } +} From 9151a5d7257489ab91d6b76434136437c6e50c45 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:33:38 -0400 Subject: [PATCH 099/195] modelapi: a sibling model that answered is named, and only a dated build or variant counts as the ask sameModel read any hyphenated suffix as a build of the same model, so an ask for openai/gpt-5.5 answered by openai/gpt-5.5-mini (or deepseek-v4 by deepseek-v4-flash, kimi-k2 by kimi-k2-thinking) named the ask as the speaker on the task page. Only a four-to-eight-digit build stamp or a :variant now counts as the same model; any other suffix is a sibling and is named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 5 +++- internal/provider/modelapi/server.go | 36 +++++++++++++++++++---- internal/provider/modelapi/server_test.go | 11 ++++++- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index dc3cf2fd0..57d671811 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -232,7 +232,10 @@ submit. call on the run's own work model — the one a task's own worker would use — and the conversation on the task page names 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. Which models it asks for is the next section. +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 diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go index f65306113..bc8e7c3f0 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -760,8 +760,9 @@ func answeredBy(turn delegate.Turn, billed string, response *ai.Response) string // 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`) is still it. An ask -// that named no model is never the same as the model that answered it. +// 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)) @@ -774,10 +775,35 @@ func sameModel(asked, answered string) bool { if a == "" || b == "" { return a == b } - build := func(long, short string) bool { - return strings.HasPrefix(long, short+"-") || strings.HasPrefix(long, short+":") + 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 a == b || build(a, b) || build(b, a) + 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 diff --git a/internal/provider/modelapi/server_test.go b/internal/provider/modelapi/server_test.go index 20438834f..5276bb3ad 100644 --- a/internal/provider/modelapi/server_test.go +++ b/internal/provider/modelapi/server_test.go @@ -780,7 +780,10 @@ func TestCloseWaitsForTheReceiptOwedOnACutCall(t *testing.T) { // 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. +// 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 @@ -788,6 +791,12 @@ func TestATurnNamesTheModelTheFunnelBilledWhenItIsNotTheAsk(t *testing.T) { {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) { From a155d49ba0634be6d3635ba417b9a38b8ef2493a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:35:53 -0400 Subject: [PATCH 100/195] provider: a paid answer cut for its text is priced by its receipt on work that asked for that The opt-in that prices an answer with no usage block by its receipt (WithUnmeteredReceipts) covered only the clean epilogues. An answer cut after it arrived, for the model's own tool grammar written as text or a rescue that was not language, still went through the bare billing door, which banks nothing without usage, so on a program run that charge reached no book and was not even kept as unpriced. All three cut epilogues now go through the answered door. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 5 ++- internal/provider/client.go | 14 +++++-- internal/provider/receipt_test.go | 64 ++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 57d671811..934e3fed2 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -267,8 +267,9 @@ 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: priced by its receipt, or kept as a call nobody could price. codeaf -never guesses a figure for either. +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, its own list, --high diff --git a/internal/provider/client.go b/internal/provider/client.go index 09f08c9f4..d1ab74559 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) @@ -2116,7 +2121,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 @@ -2130,7 +2136,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 diff --git a/internal/provider/receipt_test.go b/internal/provider/receipt_test.go index a1851c49a..91f0f28c2 100644 --- a/internal/provider/receipt_test.go +++ b/internal/provider/receipt_test.go @@ -828,3 +828,67 @@ func TestAnAnswerWithNoUsageIsSettledOnlyWhereTheWorkAskedForIt(t *testing.T) { }) } } + +// 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) + } + }) + } +} From aa28935f76cc22ddf2dfb611d694bb42d6b1f7d7 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:38:07 -0400 Subject: [PATCH 101/195] provider: a receipt's 70-second bound is counted from when it was owed, not when a worker reached it A client drains its receipts with four workers, and each receipt's whole schedule started only when a worker picked it up. A fifth receipt owed behind four slow ones was answered about 80 seconds after it was queued, past the 70 a senior-dev run waits for its books, so the run closed without that call's price. Each receipt now carries the instant it was queued and its ceiling is counted from there, so the run's one wait covers every receipt it owes, as the manual already says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/provider/modelapi/server.go | 7 +++-- internal/provider/receipt.go | 35 +++++++++++++++++++-- internal/provider/receipt_test.go | 47 ++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go index bc8e7c3f0..028658d71 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -286,8 +286,11 @@ func (s *Server) Close() error { // 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. A variable only so a -// test can shorten it. +// 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 diff --git a/internal/provider/receipt.go b/internal/provider/receipt.go index 33c236772..6557e459d 100644 --- a/internal/provider/receipt.go +++ b/internal/provider/receipt.go @@ -60,7 +60,10 @@ const ( 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. It is exported for work that waits + // 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. @@ -91,9 +94,31 @@ var receiptRetrySchedule = [...]time.Duration{ // 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 @@ -180,6 +205,9 @@ func (c *Client) settle(ctx context.Context, model string, response *ai.Response 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. work.sink(result) @@ -232,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), "/") @@ -240,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 91f0f28c2..4c1cb7d1a 100644 --- a/internal/provider/receipt_test.go +++ b/internal/provider/receipt_test.go @@ -892,3 +892,50 @@ func TestACutAnswerWithNoUsageIsStillSettledWhereTheWorkAskedForIt(t *testing.T) }) } } + +// 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) + } +} From c066b0a25376a9079ffa885534b10db758ae13c2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:42:28 -0400 Subject: [PATCH 102/195] codeaf, delegate: a shell run's time is the program's, its summary is its last line, and a second ctrl-c leaves Three things were wrong with `codeaf <program>` at a shell. Its time and the record's ended_at were taken after the launch returned, which waits for stdout to drain, so a helper the program left holding stdout added up to the grace (15 s) that the chat's worker already clamped out; both now read the process's own exit through one helper on delegate.Result. The line naming the record folder followed the calls/dollars/time summary, so the summary the manual calls the last line never was; the folder is now named first. And the signals stayed registered for the whole ending, so a second ctrl-c was swallowed for up to about a minute and a half while the run waited for receipts; the first signal now gives the terminal its ctrl-c back, and the manual says what leaving early costs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried.go | 37 ++++++++++++++++---- cmd/codeaf/carried_child_test.go | 9 +++++ cmd/codeaf/carried_money_test.go | 37 ++++++++++++++++++-- cmd/codeaf/carried_signal_test.go | 55 ++++++++++++++++++++++++++++++ internal/delegate/launch.go | 18 ++++++++++ internal/manual/chat/senior-dev.md | 7 ++-- internal/run/delegateworker.go | 15 ++------ 7 files changed, 156 insertions(+), 22 deletions(-) create mode 100644 cmd/codeaf/carried_signal_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index b1afcdce7..00a8a84e8 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -78,7 +78,7 @@ func runCarried(program delegate.Delegate, args []string) error { } // 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 := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := carriedSignals() defer stop() if _, child := delegate.ModelAPIFromEnv(); child { return carriedExit(delegate.RunChild(ctx, inv, os.Stdout)) @@ -86,6 +86,22 @@ func runCarried(program delegate.Delegate, args []string) error { return runCarriedHost(ctx, inv) } +// carriedSignals is a shell run's context: it ends on the first ctrl-c or +// SIGTERM, and that first signal hands the rest 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: a price still being +// waited for is then not in the run's line. +func carriedSignals() (context.Context, context.CancelFunc) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + context.AfterFunc(ctx, 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. @@ -283,7 +299,12 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { StderrPath: filepath.Join(record, carriedStderrName), Grace: grace, }, view) - ended := time.Now() + // 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 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 — @@ -648,8 +669,15 @@ func (v *carriedView) end(result delegate.Result, runErr error, limited bool, sp v.say(" %s observed: %s", name, observed) } } + // 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. + // 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" @@ -667,9 +695,6 @@ func (v *carriedView) end(result delegate.Result, runErr error, limited bool, sp if len(summary) > 0 { v.say(" %s", strings.Join(summary, " · ")) } - if _, err := os.Stat(v.record); err == nil { - v.say(" the run's record is in %s", v.record) - } return carriedExit(status) } diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go index 2c6a7fc86..9f1e641db 100644 --- a/cmd/codeaf/carried_child_test.go +++ b/cmd/codeaf/carried_child_test.go @@ -17,6 +17,7 @@ import ( "io" "net/http" "os" + "os/exec" "strings" "github.com/Agent-Field/codeaf/internal/delegate" @@ -43,6 +44,7 @@ func fakeCarriedProgram() delegate.Delegate { 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("implement", "running") @@ -61,6 +63,13 @@ func fakeCarriedProgram() delegate.Delegate { } host.Stage("verify", "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 } }, diff --git a/cmd/codeaf/carried_money_test.go b/cmd/codeaf/carried_money_test.go index 3d6bd3684..bab3f1532 100644 --- a/cmd/codeaf/carried_money_test.go +++ b/cmd/codeaf/carried_money_test.go @@ -79,7 +79,10 @@ func TestAShellRunWaitsForItsLastCallsPriceAndKeepsItsClock(t *testing.T) { } // 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. +// 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 @@ -93,12 +96,42 @@ func TestAShellRunsLastLineSaysHowLongItRan(t *testing.T) { } { printed := &lockedBuffer{} inv := &delegate.Invocation{Program: fakeCarriedProgram(), Workspace: t.TempDir()} - view := newCarriedView(printed, inv, filepath.Join(t.TempDir(), "never-written")) + 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_signal_test.go b/cmd/codeaf/carried_signal_test.go new file mode 100644 index 000000000..35a1ca207 --- /dev/null +++ b/cmd/codeaf/carried_signal_test.go @@ -0,0 +1,55 @@ +//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)) + } +} diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go index e1fe1c8da..559b17334 100644 --- a/internal/delegate/launch.go +++ b/internal/delegate/launch.go @@ -68,6 +68,24 @@ type Result struct { 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. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 934e3fed2..a18bc3a78 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -290,7 +290,8 @@ 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 At a shell, `codeaf senior-dev` prints each stage, step and model call as it happens, then -how the run ended, then one line with what it came to: +how the run ended, 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 @@ -302,7 +303,9 @@ 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`. +`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; a price still owed is then missing from the run's line and +from this machine's spending ledger. 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 diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index bf4ba5689..30bd8084b 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -433,18 +433,9 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err StderrPath: filepath.Join(taskDir, delegateStderrName), Grace: w.setup.Grace, }, sink) - // THE INSTANT THE PROCESS WAS GONE, which is the launch's own measure of the - // process's life laid on the instant it was started, and never later than - // now. The 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 has exited; the program's wall time is its - // process's, not the drain's. - ended = time.Now() - if result.Elapsed > 0 { - if exited := started.Add(result.Elapsed); exited.Before(ended) { - ended = exited - } - } + // 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 From 2dd087ad3e190e8ec8e60b1f33c8d140c2169d15 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:52:53 -0400 Subject: [PATCH 103/195] delegate: the ignores-TERM test waits for its program's trap before it stops it On a loaded box the fake program had not yet run `trap '' TERM` inside the test's 300 ms, so it died of the TERM and the test read it as a kill that never happened. It now has a second and a half to install the trap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delegate/launch_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go index d9097df20..b06e026a8 100644 --- a/internal/delegate/launch_test.go +++ b/internal/delegate/launch_test.go @@ -199,7 +199,13 @@ func TestRunKillsAProgramThatIgnoresTerm(t *testing.T) { `trap '' TERM`, `sleep 30`, }, "\n")) - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + // 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{}) From ed6288657fef644a4b07099f8a237cafcdd09321 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:04:58 -0400 Subject: [PATCH 104/195] session, seniordev: a program works in the folder it was named, never the home folder A chat opened in the home folder made ~/Desktop/pong, named it as ground and said `in place`. The ground ladder's `in place` rung answered with the conversation's folder before ground was read, so senior-dev was handed the whole home folder; finding no git history there it began to snapshot all of it and died on the first folder macOS keeps to itself (`open ~/.Trash: operation not permitted`), 60 ms in. - A program's folder is its proposal's ground, or the conversation's folder when it names none; `where`, the brief and the touched paths are not read for it (programGround). - A tree program is never handed the home folder or one above it; both the chat's hand-off and `/senior-dev` typed there are refused with what to do. - The receipt names the folder and reads its history off the folder, not the live run a program that died at once has already left. - senior-dev's snapshot walk skips a folder or file it may not read instead of ending the run; it is in no snapshot and a restore never touches it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 15 +++- internal/manual/chat/senior-dev.md | 9 +- .../app/workspace_recorder_snapshot.go | 25 +++++- .../seniordev/app/workspace_recorder_test.go | 42 +++++++++ internal/session/delegate_door.go | 60 ++++++++++--- internal/session/delegate_landing_test.go | 16 ++-- internal/session/program_ground_test.go | 90 +++++++++++++++++++ internal/session/task.go | 4 +- internal/session/taskstands.go | 62 ++++++++++--- 9 files changed, 282 insertions(+), 41 deletions(-) create mode 100644 internal/session/program_ground_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 9135d366d..60a39ba92 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -48,11 +48,20 @@ calls, the dollars and how long the program ran. ## Which folder a program works in — a repository I have not cloned, it edited files outside its copy, a folder with no git -A program that edits code works in a copy of one folder: the one this conversation works -in, or the one the task names. **Only what it changes inside that copy is kept**, on the -task's own branch. Anything it changed anywhere else is not part of the task, and the +A program that edits code works in a copy of one folder: the one the task names as its +`ground`, or this conversation's own folder when it 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. **Only what it changes inside that copy is kept**, on +the task's own branch. Anything it changed anywhere else is not part of the task, and the task's ending does not see it. +**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 `<name> works in one +project's folder, and <folder> is your home folder; say which folder the work is in, as +ground`. `/<name>` 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. + **The brief it reads names its copy.** Wherever the brief names the task's folder, codeaf rewrites that path to the copy's before the program reads it, so it is never pointed at your checkout. The copy is of the whole repository: a subfolder is rewritten to the same diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index a18bc3a78..2c924480a 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -113,7 +113,7 @@ that for a folder with no git history (see the section on plain folders). engine needs a Unix shell, process groups and file locks, so Windows builds leave it out rather than carry something that fails every time. -## senior-dev on a folder that is not a git repository — a plain folder, no git, --in-place +## senior-dev on a folder that is not a git repository — a plain folder, no git, --in-place, operation not permitted, .Trash From the chat, codeaf reads the task's folder before it starts senior-dev. A repository with at least one commit gets a copy, and the work lands on a branch of its own. **A folder with no git history — @@ -133,6 +133,13 @@ It works in your folder itself, so leave that folder alone while it runs: once i submitted, anything changed there is put back to what it submitted, and a file added there is removed. +**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. + At a shell, pass `--in-place` yourself. Without it senior-dev stops at once with `workspace is not a git repository: <folder>; run with --in-place to work in a plain folder`. A shell run moves nothing: delete its `.senior-dev/` when you are done with it. diff --git a/internal/seniordev/app/workspace_recorder_snapshot.go b/internal/seniordev/app/workspace_recorder_snapshot.go index 3f3884ac0..414aed614 100644 --- a/internal/seniordev/app/workspace_recorder_snapshot.go +++ b/internal/seniordev/app/workspace_recorder_snapshot.go @@ -6,8 +6,10 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" + "io/fs" "os" "path/filepath" "sort" @@ -314,6 +316,13 @@ func (recorder *snapshotRecorder) walk() ([]treeEntry, error) { // 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 { @@ -322,7 +331,7 @@ func walkTree(root string, honourIgnores bool) ([]treeEntry, error) { var entries []treeEntry err := filepath.Walk(root, func(name string, info os.FileInfo, err error) error { if err != nil { - return err + return skipUnreadable(err, name != root && honourIgnores, info) } relative, relErr := filepath.Rel(root, name) if relErr != nil { @@ -361,7 +370,7 @@ func walkTree(root string, honourIgnores bool) ([]treeEntry, error) { } hash, hashErr := hashFile(name) if hashErr != nil { - return hashErr + return skipUnreadable(hashErr, honourIgnores, info) } entries = append(entries, treeEntry{ path: relative, mode: info.Mode().Perm(), @@ -376,6 +385,18 @@ func walkTree(root string, honourIgnores bool) ([]treeEntry, error) { 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 { diff --git a/internal/seniordev/app/workspace_recorder_test.go b/internal/seniordev/app/workspace_recorder_test.go index ba8d600c3..a5e232936 100644 --- a/internal/seniordev/app/workspace_recorder_test.go +++ b/internal/seniordev/app/workspace_recorder_test.go @@ -356,3 +356,45 @@ func TestGitRecorderLeavesPromptsByteIdentical(t *testing.T) { 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/session/delegate_door.go b/internal/session/delegate_door.go index 18ea4a256..ea3ecdf83 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -178,24 +178,61 @@ func branchOnlySentence(branch, root string) string { } // delegateReceipt is the sentence an approved hand-off to a program adds to -// its receipt: who has the work and where it will be when it ends. It is read -// off the run just started under row, which knows whether its folder had a -// history to copy. +// its receipt: who has the work, where, and where it will be when it ends. +// ground is the folder the run was started on; whether it has a history to +// copy is read off it the way the run read it ([delegateOnPlainFolder]), and +// not off the live run, which a program that dies in its first second has +// already left by the time the receipt is written. // // IT NEVER SAYS THE WORK LANDS. It said "lands when it ends", and a program's // work is left on the task's own branch and merged by nobody; a model that read // "lands" told the person their folder held work it did not. -func (a *Agent) delegateReceipt(row uint64, via delegate.Delegate) string { +// +// IT NAMES THE FOLDER. A receipt that said "a copy" and "the folder itself" +// without saying which let a model that had named ~/Desktop/pong read that its +// program was there while it had been handed the person's home folder. +func delegateReceipt(ground string, via delegate.Delegate) string { if !via.LandsTree() { return "It is " + via.Name + "'s: it works alone, and its answer arrives when it ends." } - a.beltMu.Lock() - plain := a.beltRun != nil && a.beltRun.row == row && a.beltRun.plain - a.beltMu.Unlock() - if plain { - return "It is " + via.Name + "'s: it works alone in the folder itself, which has no git history, so its changes are there as it makes them." + if root, ok := repositoryRoot(ground); !ok || !hasCommit(root) { + return "It is " + via.Name + "'s: it works alone in " + ground + " itself, which has no git history, so its changes are there as it makes them." } - return "It is " + via.Name + "'s: it works alone in a copy, and when it ends its work is left on the task's own branch; nothing is merged into the checkout." + return "It is " + via.Name + "'s: it works alone in a copy of " + ground + ", and when it ends its work is left on the task's own branch; nothing is merged into the checkout." +} + +// delegateStartedReceipt is an approved hand-off's receipt: a task's first line +// and its wake sentence, with the program's own account of where it works +// ([Agent.delegateReceipt]) in place of a task's "in a copy of its own", which +// a program on a plain folder is not. +func delegateStartedReceipt(id uint64, title, where, elsewhere string) string { + return withElsewhere(fmt.Sprintf("task %d started: %s\n%s %s", id, title, where, taskHandoffWakeSentence), elsewhere) +} + +// programHomeRefusal is the one folder a tree program is never handed: the +// person's home folder, or one that holds it. It is not a project, and a +// program on a folder with no git history snapshots the whole of it to know +// what it changed — every file under the home folder, and a refusal from the +// first one macOS keeps to itself. instead is what the one refused can do. +func programHomeRefusal(program delegate.Delegate, dir, instead string) string { + if !program.LandsTree() || !holdsHomeFolder(dir) { + return "" + } + what := "holds your home folder" + if home, err := os.UserHomeDir(); err == nil && canonicalPath(home) == canonicalPath(dir) { + what = "is your home folder" + } + return program.Name + " works in one project's folder, and " + dir + " " + what + "; " + instead +} + +// holdsHomeFolder says dir is the person's home folder or a folder above it. +func holdsHomeFolder(dir string) bool { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" || strings.TrimSpace(dir) == "" { + return false + } + rel, err := filepath.Rel(canonicalPath(dir), canonicalPath(home)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // DelegateUnknownError is the refusal for a `via` or a command naming no @@ -249,6 +286,9 @@ func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, if a.config.InTask { return 0, "", "", errors.New("a task cannot hand its work to " + program.Name + "; only the conversation can") } + if refusal := programHomeRefusal(program, canonicalPath(a.config.Workspace), "open codeaf in that folder, or ask for the work in the chat and say which folder it is in"); refusal != "" { + return 0, "", "", errors.New(refusal) + } g := a.graph() if chatRunEngine == nil || g == nil || g.planPath() == "" { return 0, "", "", errors.New(program.Name + " needs the run road, and this build has none") diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index ae63a456e..3e44d9fa8 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -219,26 +219,20 @@ func TestTheConversationIsToldABranchOnlyLandingWasNotMerged(t *testing.T) { // folder itself for a folder with no history, in the conversation for one that // only answers. func TestAProgramsReceiptSaysWhereTheWorkWillBeAndPromisesNoMerge(t *testing.T) { - agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, nil) tree := testPrograms("fake")[0] - if got := agent.delegateReceipt(4, tree); got != "It is fake's: it works alone in a copy, and when it ends its work is left on the task's own branch; nothing is merged into the checkout." { + repo, plain := newTestRepo(t), t.TempDir() + if got := delegateReceipt(repo, tree); !strings.Contains(got, "it works alone in a copy of "+repo+", and when it ends its work is left on the task's own branch; nothing is merged into the checkout.") { t.Fatalf("the receipt for a copy = %q", got) } - agent.beltMu.Lock() - agent.beltRun = &beltRun{row: 4, plain: true} - agent.beltMu.Unlock() - if got := agent.delegateReceipt(4, tree); !strings.Contains(got, "in the folder itself, which has no git history") { + if got := delegateReceipt(plain, tree); !strings.Contains(got, "in "+plain+" itself, which has no git history") { t.Fatalf("the receipt for a plain folder = %q", got) } - agent.beltMu.Lock() - agent.beltRun = nil - agent.beltMu.Unlock() reader := tree reader.Lands = delegate.LandsText - if got := agent.delegateReceipt(4, reader); got != "It is fake's: it works alone, and its answer arrives when it ends." { + if got := delegateReceipt(plain, reader); got != "It is fake's: it works alone, and its answer arrives when it ends." { t.Fatalf("the receipt for a program that answers = %q", got) } - for _, got := range []string{agent.delegateReceipt(4, tree), agent.delegateReceipt(4, reader)} { + for _, got := range []string{delegateReceipt(repo, tree), delegateReceipt(plain, tree), delegateReceipt(plain, reader)} { if strings.Contains(got, "lands") { t.Fatalf("a receipt promises a landing: %q", got) } diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go new file mode 100644 index 000000000..8378e5fda --- /dev/null +++ b/internal/session/program_ground_test.go @@ -0,0 +1,90 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// A PROGRAM WORKS IN THE FOLDER IT WAS GIVEN. A chat opened in a plain folder +// made ~/Desktop/pong, named it as ground and said `in place`; the ladder's +// `in place` rung answered with the conversation's folder before ground was +// read, and senior-dev was handed the person's home folder. A program's folder +// is its ground, or the conversation's folder when it names none, and `where` +// is not read for it. +func TestAProgramWorksInTheGroundItWasGivenAndNowhereElse(t *testing.T) { + conversation := t.TempDir() + ground := newTestRepo(t) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = conversation + config.Delegates = testPrograms("fake") + }) + for _, where := range []string{"in place", "", filepath.Join(conversation, "elsewhere")} { + stand := agent.resolveTaskGround(taskSpec{via: "fake", where: where, ground: ground, brief: "build it", deliverable: "the game", acceptance: "it runs"}) + if stand.refusal != "" || stand.ask != "" || stand.dir != canonicalPath(ground) || stand.mode != TaskModeWorktree { + t.Fatalf("where %q: stand = %+v, want a copy of the ground %s", where, stand, canonicalPath(ground)) + } + } + stand := agent.resolveTaskGround(taskSpec{via: "fake", where: "in place", brief: "build it", deliverable: "the game", acceptance: "it runs"}) + if stand.refusal != "" || stand.dir != canonicalPath(conversation) { + t.Fatalf("no ground: stand = %+v, want the conversation's folder %s", stand, canonicalPath(conversation)) + } + if stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: filepath.Join(conversation, "missing"), brief: "b", deliverable: "d", acceptance: "a"}); !strings.Contains(stand.refusal, "not there") { + t.Fatalf("a ground that is not there: stand = %+v, want the refusal", stand) + } +} + +// A PROGRAM IS NEVER HANDED THE HOME FOLDER, or one above it: it is not a +// project, and senior-dev on a folder with no git history snapshots all of it. +// Both doors refuse it and say what to do instead; a folder under it is fine. +func TestAProgramIsNeverHandedTheHomeFolder(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "Desktop", "pong") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = home + config.Delegates = testPrograms("fake") + }) + spec := taskSpec{via: "fake", where: "in place", brief: "build pong", deliverable: "the game", acceptance: "it runs"} + want := "fake works in one project's folder, and " + canonicalPath(home) + " is your home folder; say which folder the work is in, as ground" + if stand := agent.resolveTaskGround(spec); stand.refusal != want { + t.Fatalf("the conversation's folder is home: refusal = %q, want %q", stand.refusal, want) + } + above := spec + above.ground = filepath.Dir(home) + if stand := agent.resolveTaskGround(above); !strings.Contains(stand.refusal, canonicalPath(filepath.Dir(home))+" holds your home folder") { + t.Fatalf("a ground above home: stand = %+v", stand) + } + under := spec + under.ground = project + if stand := agent.resolveTaskGround(under); stand.refusal != "" || stand.dir != canonicalPath(project) { + t.Fatalf("a ground under home: stand = %+v, want %s", stand, canonicalPath(project)) + } + _, _, _, err := agent.StartDelegate(context.Background(), "fake", "build pong") + if err == nil || !strings.Contains(err.Error(), "is your home folder; open codeaf in that folder") { + t.Fatalf("/fake typed in the home folder: err = %v", err) + } +} + +// THE RECEIPT NAMES THE FOLDER, and whether it has a history is read off the +// folder rather than off a live run a program that died at once has left. +func TestAProgramsReceiptNamesItsFolder(t *testing.T) { + tree := testPrograms("fake")[0] + repo := newTestRepo(t) + plain := t.TempDir() + if got, want := delegateReceipt(repo, tree), "It is fake's: it works alone in a copy of "+repo+", and when it ends its work is left on the task's own branch; nothing is merged into the checkout."; got != want { + t.Fatalf("the receipt for a copy = %q, want %q", got, want) + } + if got, want := delegateReceipt(plain, tree), "It is fake's: it works alone in "+plain+" itself, which has no git history, so its changes are there as it makes them."; got != want { + t.Fatalf("the receipt for a plain folder = %q, want %q", got, want) + } + got := delegateStartedReceipt(3, "Pong", delegateReceipt(plain, tree), "") + if !strings.HasPrefix(got, "task 3 started: Pong\nIt is fake's: it works alone in "+plain+" itself") || strings.Contains(got, "a copy of its own") || !strings.Contains(got, taskHandoffWakeSentence) { + t.Fatalf("the started receipt = %q", got) + } +} diff --git a/internal/session/task.go b/internal/session/task.go index d585c2ba5..c72a0e17b 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -192,7 +192,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + - `"via":{"type":"string","description":"Optional: a program your instructions list, to do the whole task alone in a copy of ground (or of this conversation's folder)"},` + + `"via":{"type":"string","description":"Optional: a program your instructions list, to do the whole task alone in ground (or this conversation's folder)"},` + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + `"no_progress":{"type":"integer","description":"Optional. Tool calls in a row that may add nothing before it is stopped as stuck (default ` + strconv.Itoa(taskNoProgress) + `). Raise it for work that must read a great deal first"}` + `},"required":["title","summary","brief","deliverable","acceptance"],"additionalProperties":false}` @@ -890,7 +890,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) switch { case via != nil: - receipt = withReport(receipt, a.delegateReceipt(p.id, *via)) + receipt = delegateStartedReceipt(p.id, spec.title, delegateReceipt(canonicalPath(stand.dir), *via), elsewhere) case joined: receipt = withReport(receipt, "It joined the work already underway and shares its copy.") } diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 45790b89d..e6798dc82 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -64,6 +64,8 @@ import ( "strings" "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/codeaf/internal/delegate" ) // taskStand is what the ladder came to: the ground, how this task stands on it, @@ -151,6 +153,9 @@ const taskGroundPathsRead = 400 // answer to the one question this file exists to have one answer to. func (a *Agent) resolveTaskGround(spec taskSpec) taskStand { workspace := canonicalPath(strings.TrimSpace(a.config.Workspace)) + if program, err := a.delegateFor(spec.via); err == nil { + return programGround(spec, workspace, program) + } redirect := "" // A MODEL'S PLACEMENT IS EVIDENCE, NOT AUTHORITY, INSIDE A REPOSITORY. A // branch is the repository's isolation boundary even when `where` asked for @@ -309,18 +314,7 @@ func (a *Agent) taskGroundOrStandingIn(spec taskSpec) taskStand { // answered for. func (a *Agent) groundLadder(spec taskSpec, workspace string) taskStand { if said := strings.TrimSpace(spec.ground); said != "" { - dir, err := resolveTaskWhere(said, workspace) - if err != nil { - return taskStand{refusal: "this task names a folder it cannot work in: " + said} - } - if info, err := os.Stat(dir); err != nil || !info.IsDir() { - // A ground is a place that IS there. Unlike `where`, which is somebody - // saying where work should go and may name a folder to be made, this - // argument names the project the work is about — and a project nobody - // can find is a mistake worth saying out loud rather than creating. - return taskStand{refusal: "this task names a folder that is not there: " + dir} - } - return taskStand{dir: groundRoot(dir), rung: taskGroundSaid} + return saidGround(said, workspace) } // A PART STANDS WHERE ITS PARENT STANDS, and the rungs below are not climbed // for it. A sub-task's branch is cut from its parent's worktree and merges @@ -358,6 +352,50 @@ func (a *Agent) groundLadder(spec taskSpec, workspace string) taskStand { return taskStand{dir: workspace, rung: taskGroundNothing} } +// saidGround is the rung a proposal's own `ground` answers at. +func saidGround(said, workspace string) taskStand { + dir, err := resolveTaskWhere(said, workspace) + if err != nil { + return taskStand{refusal: "this task names a folder it cannot work in: " + said} + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + // A ground is a place that IS there. Unlike `where`, which is somebody + // saying where work should go and may name a folder to be made, this + // argument names the project the work is about — and a project nobody + // can find is a mistake worth saying out loud rather than creating. + return taskStand{refusal: "this task names a folder that is not there: " + dir} + } + return taskStand{dir: groundRoot(dir), rung: taskGroundSaid} +} + +// programGround is where a program works: the `ground` its proposal names, or +// this conversation's own folder when it names none. NOTHING ELSE IS READ. +// +// The ladder above weighs `where`, the brief and the paths this conversation +// touched, because a task of codeaf's own may be placed by any of them. A +// program is handed ONE folder for an hour, and it has to be the one the model +// said. The ladder's `in place` rung answered with the conversation's folder +// before `ground` was read, so a chat opened in the person's home folder that +// made ~/Desktop/pong, named it as ground and said `in place` handed senior-dev +// the whole home folder; senior-dev, finding no git history there, began to +// snapshot all of it and died on the first folder macOS keeps to itself +// (`open /Users/…/.Trash: operation not permitted`). So a program's placement +// is its own ([delegateStand]) and `where` is not read for it. +func programGround(spec taskSpec, workspace string, program delegate.Delegate) taskStand { + stand := taskStand{dir: workspace, rung: taskGroundHere} + if said := strings.TrimSpace(spec.ground); said != "" { + if stand = saidGround(said, workspace); stand.refusal != "" { + return stand + } + } + if refusal := programHomeRefusal(program, stand.dir, "say which folder the work is in, as ground"); refusal != "" { + return taskStand{refusal: refusal} + } + placed := delegateStand(stand.dir, program) + placed.rung = stand.rung + return placed +} + // groundPlainlyNamedByBrief reports the one ground that holds every existing // absolute place the contract writes down: the repository they are all inside, // or, where none is in a repository, the one named folder that holds them all. From 2091b5668bd2594b315db8461ce6bf5c6ba6cc77 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:07:57 -0400 Subject: [PATCH 105/195] session: a program's card names the project it will work in The card a person approves said `where:` and the path the copy would have under codeaf's state, a folder that does not exist yet and is nobody's. A program's card now says the folder, or `a copy of` it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/delegate_door.go | 19 ++++++++++++++++++- internal/session/program_ground_test.go | 13 +++++++++++++ internal/session/task.go | 15 ++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index ea3ecdf83..88bd84f55 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -195,12 +195,29 @@ func delegateReceipt(ground string, via delegate.Delegate) string { if !via.LandsTree() { return "It is " + via.Name + "'s: it works alone, and its answer arrives when it ends." } - if root, ok := repositoryRoot(ground); !ok || !hasCommit(root) { + if !hasGitHistory(ground) { return "It is " + via.Name + "'s: it works alone in " + ground + " itself, which has no git history, so its changes are there as it makes them." } return "It is " + via.Name + "'s: it works alone in a copy of " + ground + ", and when it ends its work is left on the task's own branch; nothing is merged into the checkout." } +// programPlace is where a program works, in a person's words: the folder +// itself, or a copy of it when it has a history to copy from and the program +// edits code. +func programPlace(program delegate.Delegate, ground string) string { + if !program.LandsTree() || !hasGitHistory(ground) { + return ground + } + return "a copy of " + ground +} + +// hasGitHistory says ground is in a repository with at least one commit — the +// same reading [delegateOnPlainFolder] makes of the copy it was given. +func hasGitHistory(ground string) bool { + root, ok := repositoryRoot(ground) + return ok && hasCommit(root) +} + // delegateStartedReceipt is an approved hand-off's receipt: a task's first line // and its wake sentence, with the program's own account of where it works // ([Agent.delegateReceipt]) in place of a task's "in a copy of its own", which diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go index 8378e5fda..5f95dcf06 100644 --- a/internal/session/program_ground_test.go +++ b/internal/session/program_ground_test.go @@ -88,3 +88,16 @@ func TestAProgramsReceiptNamesItsFolder(t *testing.T) { t.Fatalf("the started receipt = %q", got) } } + +// THE CARD NAMES THE PROJECT. A program's card said `where:` and the path its +// copy would have under codeaf's state; it says the folder, or a copy of it. +func TestAProgramsCardNamesTheProject(t *testing.T) { + repo, plain := newTestRepo(t), t.TempDir() + config := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} + if got := taskCardWhere(config, 1, taskSpec{via: "fake", ground: repo}); got != "a copy of "+repo { + t.Fatalf("a repository's card says where: %q", got) + } + if got := taskCardWhere(config, 1, taskSpec{via: "fake", ground: plain}); got != plain { + t.Fatalf("a plain folder's card says where: %q", got) + } +} diff --git a/internal/session/task.go b/internal/session/task.go index c72a0e17b..0203bd05d 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -1421,7 +1421,7 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T Summary: spec.summary, Brief: spec.brief, Acceptance: spec.acceptance, - Where: taskWhereNotice(config.Place, config.Workspace, id, spec.where, spec.mode), + Where: taskCardWhere(config, id, spec), Ground: spec.ground, Mode: spec.mode, DependsOn: spec.dependsOn, @@ -1509,6 +1509,19 @@ func (a *Agent) taskClockTimer(after time.Duration) (<-chan time.Time, func()) { return timer.C, func() { timer.Stop() } } +// taskCardWhere is the card's `where`. A PROGRAM'S CARD NAMES ITS PROJECT: the +// folder it will work in, or a copy of it. The copy's own path under codeaf's +// state does not exist yet and is nobody's folder, and a card that showed it +// asked a person to approve work going somewhere they had never heard of. +func taskCardWhere(config Config, id uint64, spec taskSpec) string { + for _, program := range config.Delegates { + if program.Name == spec.via && strings.TrimSpace(spec.ground) != "" { + return programPlace(program, spec.ground) + } + } + return taskWhereNotice(config.Place, config.Workspace, id, spec.where, spec.mode) +} + func taskWhereNotice(place Place, workspace string, id uint64, where string, mode TaskMode) string { where = strings.TrimSpace(where) redirectedInPlace := false From 61a349fe33211628251bb1039f0a84115c410801 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:58:30 -0400 Subject: [PATCH 106/195] session, seniordev, delegate: senior-dev works with the models the person asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proposal's `model` was resolved and shown on the card, then dropped: the run was handed the crew's working seat whatever the person had asked. Now one model or several (comma-separated) resolve to the models the run is handed as senior-dev's working pool (`--asked --high …`), the card and the receipt name them, and a proposal naming none is still handed the crew. senior-dev takes an asked pool whole: a model its catalog cannot size ends the run before its first call, naming it, instead of being dropped for its own list the way an unsizable crew seat is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/delegate/delegate.go | 19 +++++++--- internal/manual/chat/senior-dev.md | 15 ++++++-- internal/seniordev/app/crew_test.go | 13 +++++++ internal/seniordev/app/run.go | 47 +++++++++++++++++++++---- internal/seniordev/crew_test.go | 9 +++++ internal/seniordev/seniordev.go | 25 +++++++++++-- internal/session/delegate_door.go | 8 +++-- internal/session/delegate_door_test.go | 8 ++++- internal/session/program_ground_test.go | 34 +++++++++++++++++- internal/session/task.go | 8 +++-- internal/session/task_run_belt.go | 15 ++++++-- internal/session/taskmodel.go | 47 +++++++++++++++++++++++++ 12 files changed, 222 insertions(+), 26 deletions(-) diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index f9042c065..c185160d6 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -173,10 +173,17 @@ type Crew struct { 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 == Crew{} } +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 @@ -263,9 +270,13 @@ func (d Delegate) validateLineFlags() error { return fs.Parse(flags) == nil && fs.NArg() == 0 } if d.CrewFlags != nil { - sample := Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"} - 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) + 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) { diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 2c924480a..86fbf1503 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -278,9 +278,18 @@ about the same way, including one codeaf then set aside because it was not usabl 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, its own list, --high - -**From the chat it uses your crew.** codeaf hands senior-dev two of the conversation's +## 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 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 <model>: …`, 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. diff --git a/internal/seniordev/app/crew_test.go b/internal/seniordev/app/crew_test.go index 25f90913e..b0100be85 100644 --- a/internal/seniordev/app/crew_test.go +++ b/internal/seniordev/app/crew_test.go @@ -47,3 +47,16 @@ func TestCrewPoolsDropWhatTheCatalogLacksAndFallBackToTheOwnList(t *testing.T) { 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/run.go b/internal/seniordev/app/run.go index 6aa2b3981..6aadd335d 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -14,6 +14,7 @@ import ( "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" ) @@ -68,6 +69,11 @@ type Options struct { // 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. @@ -140,14 +146,24 @@ func runWith(ctx context.Context, host delegate.Host, options Options, notes io. } 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 { - args = crewPools(args, 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 - }, notes) + high := args.High + args = crewPools(args, known, notes) + if options.Asked { + args.High = high + } } } @@ -188,6 +204,23 @@ func loadCatalog(ctx context.Context, notes io.Writer) (modelsdev.Catalog, error 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 { diff --git a/internal/seniordev/crew_test.go b/internal/seniordev/crew_test.go index d00adbe65..2b26d0fb9 100644 --- a/internal/seniordev/crew_test.go +++ b/internal/seniordev/crew_test.go @@ -22,3 +22,12 @@ func TestTheCrewBecomesSeniorDevsOwnPools(t *testing.T) { 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/seniordev.go b/internal/seniordev/seniordev.go index 09c12afa4..7550eab6e 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -100,13 +100,30 @@ var Program = delegate.Delegate{ // one (baked/tier.go). No call rides the frontier tier, so the flag changed // nothing, while the manual told the person their mastermind model handled // senior-dev's hardest calls. +// +// MODELS THE PERSON ASKED FOR ARE THE WORKING POOL, AND ARE KEPT AS ASKED. +// They go on --high in place of the crew's working seat, with --asked, which +// takes that pool out of the crew's leniency: a model the person named that +// senior-dev cannot size ends the run before its first call, naming it, +// rather than being quietly swapped for its own list. func crewFlags(crew delegate.Crew) []string { flags := []string{"--crew"} + high := app.CrewModel(crew.Hands) + if len(crew.Asked) > 0 { + asked := make([]string, 0, len(crew.Asked)) + for _, model := range crew.Asked { + if model = app.CrewModel(model); model != "" { + asked = append(asked, model) + } + } + high = strings.Join(asked, ",") + flags = append(flags, "--asked") + } for _, seat := range []struct{ flag, model string }{ - {"--high", crew.Hands}, {"--low", crew.Light}, + {"--high", high}, {"--low", app.CrewModel(crew.Light)}, } { - if model := app.CrewModel(seat.model); model != "" { - flags = append(flags, seat.flag, model) + if seat.model != "" { + flags = append(flags, seat.flag, seat.model) } } return flags @@ -130,6 +147,7 @@ func bindRun(fs *flag.FlagSet) delegate.Body { low := fs.String("low", "", "models for the history summary (default: --high)") frontier := fs.String("frontier", "", "models for the frontier tier (no call uses it)") crew := fs.Bool("crew", false, "the models came from codeaf's crew: skip any it cannot size") + asked := fs.Bool("asked", false, "the --high models were asked for by name; none is skipped") return func(ctx context.Context, host delegate.Host, args []string) error { run(ctx, host, app.Options{ Goal: strings.Join(args, " "), @@ -139,6 +157,7 @@ func bindRun(fs *flag.FlagSet) delegate.Body { Variant: *variant, InPlace: *inPlace, Crew: *crew, + Asked: *asked, }, os.Stderr) return nil } diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 88bd84f55..a232bceb5 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -222,8 +222,12 @@ func hasGitHistory(ground string) bool { // and its wake sentence, with the program's own account of where it works // ([Agent.delegateReceipt]) in place of a task's "in a copy of its own", which // a program on a plain folder is not. -func delegateStartedReceipt(id uint64, title, where, elsewhere string) string { - return withElsewhere(fmt.Sprintf("task %d started: %s\n%s %s", id, title, where, taskHandoffWakeSentence), elsewhere) +// on is the models the person asked it to work with, "" for the crew's. +func delegateStartedReceipt(id uint64, title, on, where, elsewhere string) string { + if on != "" { + on = " on " + on + } + return withElsewhere(fmt.Sprintf("task %d started%s: %s\n%s %s", id, on, title, where, taskHandoffWakeSentence), elsewhere) } // programHomeRefusal is the one folder a tree program is never handed: the diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 30560dd0b..4c8d94f39 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -5,6 +5,7 @@ import ( "flag" "os" "path/filepath" + "reflect" "slices" "strconv" "strings" @@ -243,9 +244,14 @@ func TestAProgramIsHandedTheConversationsCrew(t *testing.T) { }) program := testPrograms("fake")[0] got := agent.delegateCrew(&beltRun{delegate: &program}) - if want := (delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"}); got != want { + if want := (delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"}); !reflect.DeepEqual(got, want) { t.Fatalf("crew = %+v, want %+v", got, want) } + // A run the person asked a model for is handed that model beside the crew. + asked := agent.delegateCrew(&beltRun{delegate: &program, asked: []string{"vendor/one", "vendor/two"}}) + if want := (delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light", Asked: []string{"vendor/one", "vendor/two"}}); !reflect.DeepEqual(asked, want) { + t.Fatalf("asked crew = %+v, want %+v", asked, want) + } if got := agent.delegateCrew(&beltRun{}); !got.IsZero() { t.Fatalf("a run no program works was handed a crew: %+v", got) } diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go index 5f95dcf06..38ebfc1eb 100644 --- a/internal/session/program_ground_test.go +++ b/internal/session/program_ground_test.go @@ -83,7 +83,7 @@ func TestAProgramsReceiptNamesItsFolder(t *testing.T) { if got, want := delegateReceipt(plain, tree), "It is fake's: it works alone in "+plain+" itself, which has no git history, so its changes are there as it makes them."; got != want { t.Fatalf("the receipt for a plain folder = %q, want %q", got, want) } - got := delegateStartedReceipt(3, "Pong", delegateReceipt(plain, tree), "") + got := delegateStartedReceipt(3, "Pong", "", delegateReceipt(plain, tree), "") if !strings.HasPrefix(got, "task 3 started: Pong\nIt is fake's: it works alone in "+plain+" itself") || strings.Contains(got, "a copy of its own") || !strings.Contains(got, taskHandoffWakeSentence) { t.Fatalf("the started receipt = %q", got) } @@ -101,3 +101,35 @@ func TestAProgramsCardNamesTheProject(t *testing.T) { t.Fatalf("a plain folder's card says where: %q", got) } } + +// A PROGRAM WORKS WITH THE MODELS THE PERSON ASKED FOR. The card showed the +// model a proposal named and the run was handed the crew's; now one word or +// several (comma-separated) resolve to the models the run is handed, a word +// that names no model is refused, and a proposal naming none is handed the +// crew rather than the default a task's card shows. +func TestAProgramWorksWithTheModelsThePersonAskedFor(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.TaskModels = func() []string { + return []string{"moonshotai/kimi-k2.6", "z-ai/glm-5.1", "z-ai/glm-5.3-flash", "deepseek/deepseek-v4-pro"} + } + }) + choice := agent.resolveProgramModels("kimi-k2.6, deepseek-v4-pro") + if choice.problem != "" || choice.model != "moonshotai/kimi-k2.6,deepseek/deepseek-v4-pro" { + t.Fatalf("two words = %+v", choice) + } + if got := programAsked(taskSpec{modelWord: "kimi-k2.6, deepseek-v4-pro", model: choice.model}); strings.Join(got, " ") != "moonshotai/kimi-k2.6 deepseek/deepseek-v4-pro" { + t.Fatalf("asked = %q", got) + } + if choice := agent.resolveProgramModels("kimi-k2.6, nosuchmodel"); choice.problem == "" { + t.Fatalf("a word naming no model was not refused: %+v", choice) + } + if choice := agent.resolveProgramModels("glm, kimi-k2.6"); choice.problem == "" { + t.Fatalf("a word naming several models in a list was not refused: %+v", choice) + } + if got := programAsked(taskSpec{model: "z-ai/glm-5.3-flash"}); got != nil { + t.Fatalf("a proposal naming no model asked for %q", got) + } + if got := delegateStartedReceipt(2, "Invaders", "moonshotai/kimi-k2.6", "It is fake's.", ""); !strings.HasPrefix(got, "task 2 started on moonshotai/kimi-k2.6: Invaders\n") { + t.Fatalf("receipt = %q", got) + } +} diff --git a/internal/session/task.go b/internal/session/task.go index 0203bd05d..c5f0f6486 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -663,6 +663,9 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged // several is not refused at all: the shortlist rides on the proposal, and the // person settles it in the same breath as the work. choice := a.resolveTaskModel(spec.modelWord) + if spec.via != "" { + choice = a.resolveProgramModels(spec.modelWord) + } if choice.problem != "" { return bare.Settled(choice.problem, true) } @@ -882,7 +885,8 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec if via != nil { stand = delegateStand(stand.dir, *via) } - err := a.startKnownTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via) + asked := programAsked(spec) + err := a.startKnownTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { return refusal.Error(), true, true } @@ -890,7 +894,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) switch { case via != nil: - receipt = delegateStartedReceipt(p.id, spec.title, delegateReceipt(canonicalPath(stand.dir), *via), elsewhere) + receipt = delegateStartedReceipt(p.id, spec.title, strings.Join(asked, ", "), delegateReceipt(canonicalPath(stand.dir), *via), elsewhere) case joined: receipt = withReport(receipt, "It joined the work already underway and shares its copy.") } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 05c0c5221..454b24331 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -281,6 +281,9 @@ type beltRun struct { workspace string ground string tree taskTree + // asked is the models the person asked this run's program to work with, in + // place of the crew's working seat; empty for the crew ([Agent.delegateCrew]). + asked []string // joined is every hand-off that joined this run after it started, by the // number its row wears. Each was published as a running row of its own, and // each is settled with the run ([Agent.settleBeltRun]); it is written and @@ -385,7 +388,10 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s // is handed to (delegate_door.go). One body serves both because a // delegated run IS a run — the store, the copy, the row and the stop road are // the same — and a second body would be two roads that must stay in step. -func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Delegate) error { +// +// asked is the models the person asked the program to work with, resolved; +// none means the conversation's crew ([Agent.delegateCrew]). +func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Delegate, asked ...string) error { engine := chatRunEngine g := a.graph() if engine == nil || g == nil || g.planPath() == "" { @@ -435,7 +441,7 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie plan: plan, store: store, root: store.RootID(), row: id, title: title, workspace: tree.dir, ground: canonicalPath(stand.dir), tree: tree, cut: cut, born: born, delegate: via, startSha: delegateStartSha(tree, via), - plain: delegateOnPlainFolder(tree, via), + plain: delegateOnPlainFolder(tree, via), asked: asked, } if via != nil && via.LandsTree() && !run.plain { run.groundMoves = delegateGroundMoves(stand.dir, tree.root, tree.dir) @@ -601,7 +607,10 @@ func (a *Agent) delegateCrew(run *beltRun) delegate.Crew { model, _ := roles.SplitEffort(strings.TrimSpace(value)) return strings.TrimSpace(model) } - return delegate.Crew{Brain: seat(roles.TierMastermind), Hands: seat(roles.TierWorker), Light: seat(roles.TierLow)} + return delegate.Crew{ + Brain: seat(roles.TierMastermind), Hands: seat(roles.TierWorker), Light: seat(roles.TierLow), + Asked: append([]string(nil), run.asked...), + } } // delegateGroundMoves is every way a brief is likely to spell the folder a diff --git a/internal/session/taskmodel.go b/internal/session/taskmodel.go index f127a7fc6..665937985 100644 --- a/internal/session/taskmodel.go +++ b/internal/session/taskmodel.go @@ -36,6 +36,7 @@ package session // caller had before this file existed. import ( + "slices" "sort" "strings" @@ -396,3 +397,49 @@ func taskModelMovedNote(model, next string) string { func taskModelMovedSentence(from, to string) string { return from + " stopped answering, so this ran again on " + to } + +// resolveProgramModels is [Agent.resolveTaskModel] for a program, which works +// with one model or several: a `model` naming more than one, separated by +// commas, is resolved word by word, and each word must name exactly one model +// this install has. One word is resolved as any task's is, its shortlist and +// all. +func (a *Agent) resolveProgramModels(word string) taskModelChoice { + words := strings.Split(word, ",") + if len(words) < 2 { + return a.resolveTaskModel(word) + } + var models []string + for _, part := range words { + if strings.TrimSpace(part) == "" { + continue + } + choice := a.resolveTaskModel(part) + switch { + case choice.problem != "": + return choice + case len(choice.options) > 0: + return taskModelChoice{problem: taskModelVague(strings.TrimSpace(part), choice.options)} + } + if !slices.Contains(models, choice.model) { + models = append(models, choice.model) + } + } + return taskModelChoice{model: strings.Join(models, ",")} +} + +// programAsked is the models a proposal asked its program to work with: what +// its `model` resolved to when the proposal named one, and nothing when it +// named none, so the run is handed the crew rather than the default a card +// shows for a task. +func programAsked(spec taskSpec) []string { + if strings.TrimSpace(spec.modelWord) == "" || strings.TrimSpace(spec.model) == "" { + return nil + } + var asked []string + for _, model := range strings.Split(spec.model, ",") { + if model = strings.TrimSpace(model); model != "" { + asked = append(asked, model) + } + } + return asked +} From 5f57c2d633dc94476d65f7e52dd67a83272ae3c2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:02:32 -0400 Subject: [PATCH 107/195] session: a model no connected service serves is refused for a program, by name The model a proposal names is matched against the whole catalog, and one none of the person's services could reach was handed to senior-dev and then answered, call after call, on the crew's working seat by the run's model API. It is now refused before the card, naming the model; a shortlist keeps only the models a service serves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 3 +- internal/session/program_ground_test.go | 31 +++++++++++++ internal/session/taskmodel.go | 61 +++++++++++++++++++++---- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 86fbf1503..166555fb2 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -284,7 +284,8 @@ for either. 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 senior-dev's model catalog does not know how to size cannot be used: the +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 <model>: …`, 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. diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go index 38ebfc1eb..812852989 100644 --- a/internal/session/program_ground_test.go +++ b/internal/session/program_ground_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" ) // A PROGRAM WORKS IN THE FOLDER IT WAS GIVEN. A chat opened in a plain folder @@ -108,10 +110,12 @@ func TestAProgramsCardNamesTheProject(t *testing.T) { // that names no model is refused, and a proposal naming none is handed the // crew rather than the default a task's card shows. func TestAProgramWorksWithTheModelsThePersonAskedFor(t *testing.T) { + router := modelsource.DefaultSource("https://openrouter.ai/api/v1") agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.TaskModels = func() []string { return []string{"moonshotai/kimi-k2.6", "z-ai/glm-5.1", "z-ai/glm-5.3-flash", "deepseek/deepseek-v4-pro"} } + config.Sources = modelsource.NewSet(modelsource.Connected{Source: router, Key: "sk-or-v1-routerkey0000000000", Address: router.Address}) }) choice := agent.resolveProgramModels("kimi-k2.6, deepseek-v4-pro") if choice.problem != "" || choice.model != "moonshotai/kimi-k2.6,deepseek/deepseek-v4-pro" { @@ -133,3 +137,30 @@ func TestAProgramWorksWithTheModelsThePersonAskedFor(t *testing.T) { t.Fatalf("receipt = %q", got) } } + +// A MODEL NO CONNECTED SERVICE SERVES IS REFUSED BY NAME, before a card. It was +// handed to the program and every call on it was answered on the crew's +// working seat instead: the person asked for one model and got another. +func TestAProgramIsNotHandedAModelNoServiceServes(t *testing.T) { + 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"} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.TaskModels = func() []string { return []string{"moonshotai/kimi-k2.6", "mybox/qwen3-coder"} } + config.Sources = modelsource.NewSet( + modelsource.Connected{Source: router, Address: router.Address}, + modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address}, + ) + }) + if choice := agent.resolveProgramModels("kimi-k2.6"); !strings.Contains(choice.problem, "none of the model services connected here can serve moonshotai/kimi-k2.6") { + t.Fatalf("an unserved model = %+v", choice) + } + if choice := agent.resolveProgramModels("qwen3-coder, kimi-k2.6"); !strings.Contains(choice.problem, "none of the model services connected here can serve moonshotai/kimi-k2.6") { + t.Fatalf("an unserved model in a list = %+v", choice) + } + if choice := agent.resolveProgramModels("qwen3-coder"); choice.problem != "" || choice.model != "mybox/qwen3-coder" { + t.Fatalf("a served model = %+v", choice) + } + if choice := agent.resolveProgramModels(""); choice.problem != "" { + t.Fatalf("no model named was refused: %+v", choice) + } +} diff --git a/internal/session/taskmodel.go b/internal/session/taskmodel.go index 665937985..283311846 100644 --- a/internal/session/taskmodel.go +++ b/internal/session/taskmodel.go @@ -400,25 +400,52 @@ func taskModelMovedSentence(from, to string) string { // resolveProgramModels is [Agent.resolveTaskModel] for a program, which works // with one model or several: a `model` naming more than one, separated by -// commas, is resolved word by word, and each word must name exactly one model -// this install has. One word is resolved as any task's is, its shortlist and -// all. +// commas, is resolved word by word, and each word must name exactly one model. +// One word is resolved as any task's is, its shortlist and all. +// +// EVERY MODEL NAMED MUST BE ONE A CONNECTED SERVICE CAN SERVE. The list a word +// is matched against is the whole catalog, and a model none of the person's +// services can reach was handed to the program anyway and then answered, call +// after call, on the crew's working seat by the run's model API — the person +// asked for one model and was quietly given another. It is refused here, by +// name, before a card is shown. func (a *Agent) resolveProgramModels(word string) taskModelChoice { - words := strings.Split(word, ",") + var words []string + for _, part := range strings.Split(word, ",") { + if part = strings.TrimSpace(part); part != "" { + words = append(words, part) + } + } if len(words) < 2 { - return a.resolveTaskModel(word) + choice := a.resolveTaskModel(word) + if len(words) == 0 || choice.problem != "" { + return choice + } + if len(choice.options) > 0 { + choice.options = slices.DeleteFunc(choice.options, func(model string) bool { return !a.programServes(model) }) + switch len(choice.options) { + case 0: + return taskModelChoice{problem: programUnservedProblem(words[0])} + case 1: + return taskModelChoice{model: choice.options[0]} + } + return choice + } + if !a.programServes(choice.model) { + return taskModelChoice{problem: programUnservedProblem(choice.model)} + } + return choice } var models []string for _, part := range words { - if strings.TrimSpace(part) == "" { - continue - } choice := a.resolveTaskModel(part) switch { case choice.problem != "": return choice case len(choice.options) > 0: - return taskModelChoice{problem: taskModelVague(strings.TrimSpace(part), choice.options)} + return taskModelChoice{problem: taskModelVague(part, choice.options)} + case !a.programServes(choice.model): + return taskModelChoice{problem: programUnservedProblem(choice.model)} } if !slices.Contains(models, choice.model) { models = append(models, choice.model) @@ -427,6 +454,22 @@ func (a *Agent) resolveProgramModels(word string) taskModelChoice { return taskModelChoice{model: strings.Join(models, ",")} } +// programServes says a connected service can take a call on model, by the one +// test the run's model API makes ([ServesModel]). A conversation with no +// services at all cannot be asked, and is not refused on that account. +func (a *Agent) programServes(model string) bool { + a.mu.Lock() + sources := a.config.Sources.OrDefault(a.config.APIKey, a.config.BaseURL) + a.mu.Unlock() + return sources.Empty() || ServesModel(sources, model) +} + +// programUnservedProblem is the refusal for a model no connected service serves. +func programUnservedProblem(model string) string { + return "none of the model services connected here can serve " + model + + ", so a program cannot be handed it; name a model one of them serves, spelled with its service's prefix when it is not the default service's" +} + // programAsked is the models a proposal asked its program to work with: what // its `model` resolved to when the proposal named one, and nothing when it // named none, so the run is handed the crew rather than the default a card From 134d85092ad8d10d548f4d0111d84eb4711bec9e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:04:52 -0400 Subject: [PATCH 108/195] session: a model spelled with a connected service's prefix is taken as written for a program The catalog a model word is matched against lists nothing of a service the person connected themselves, so a proxy's own model could not be asked for. A word that names a connected service and a model is kept as written when that service can take a call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/program_ground_test.go | 8 +++ internal/session/taskmodel.go | 79 ++++++++++++++++--------- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go index 812852989..1c134d989 100644 --- a/internal/session/program_ground_test.go +++ b/internal/session/program_ground_test.go @@ -160,6 +160,14 @@ func TestAProgramIsNotHandedAModelNoServiceServes(t *testing.T) { if choice := agent.resolveProgramModels("qwen3-coder"); choice.problem != "" || choice.model != "mybox/qwen3-coder" { t.Fatalf("a served model = %+v", choice) } + // A model spelled with a connected service's prefix is taken as written, + // though the catalog lists nothing of that service. + if choice := agent.resolveProgramModels("mybox/some-local-model"); choice.problem != "" || choice.model != "mybox/some-local-model" { + t.Fatalf("a model named with its service = %+v", choice) + } + if choice := agent.resolveProgramModels("openrouter/moonshotai/kimi-k2.6"); !strings.Contains(choice.problem, "serve openrouter/moonshotai/kimi-k2.6") { + t.Fatalf("a keyless service's model named with its prefix = %+v", choice) + } if choice := agent.resolveProgramModels(""); choice.problem != "" { t.Fatalf("no model named was refused: %+v", choice) } diff --git a/internal/session/taskmodel.go b/internal/session/taskmodel.go index 283311846..7a60d2367 100644 --- a/internal/session/taskmodel.go +++ b/internal/session/taskmodel.go @@ -40,6 +40,7 @@ import ( "sort" "strings" + "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/roles" ) @@ -409,6 +410,13 @@ func taskModelMovedSentence(from, to string) string { // after call, on the crew's working seat by the run's model API — the person // asked for one model and was quietly given another. It is refused here, by // name, before a card is shown. +// +// A WORD SPELLED WITH A CONNECTED SERVICE'S PREFIX IS TAKEN AS WRITTEN. The +// catalog a word is matched against lists no model of a service the person +// connected themselves (a local proxy, a box of their own), so `mybox/qwen3` +// matched nothing there while it is exactly how that service is asked; it is +// the person naming a service and a model, and is kept when the service can +// take a call. func (a *Agent) resolveProgramModels(word string) taskModelChoice { var words []string for _, part := range strings.Split(word, ",") { @@ -416,36 +424,20 @@ func (a *Agent) resolveProgramModels(word string) taskModelChoice { words = append(words, part) } } - if len(words) < 2 { - choice := a.resolveTaskModel(word) - if len(words) == 0 || choice.problem != "" { - return choice - } - if len(choice.options) > 0 { - choice.options = slices.DeleteFunc(choice.options, func(model string) bool { return !a.programServes(model) }) - switch len(choice.options) { - case 0: - return taskModelChoice{problem: programUnservedProblem(words[0])} - case 1: - return taskModelChoice{model: choice.options[0]} - } - return choice - } - if !a.programServes(choice.model) { - return taskModelChoice{problem: programUnservedProblem(choice.model)} - } - return choice + switch len(words) { + case 0: + return a.resolveTaskModel(word) + case 1: + return a.resolveProgramWord(words[0]) } var models []string for _, part := range words { - choice := a.resolveTaskModel(part) + choice := a.resolveProgramWord(part) switch { case choice.problem != "": return choice case len(choice.options) > 0: return taskModelChoice{problem: taskModelVague(part, choice.options)} - case !a.programServes(choice.model): - return taskModelChoice{problem: programUnservedProblem(choice.model)} } if !slices.Contains(models, choice.model) { models = append(models, choice.model) @@ -454,14 +446,43 @@ func (a *Agent) resolveProgramModels(word string) taskModelChoice { return taskModelChoice{model: strings.Join(models, ",")} } -// programServes says a connected service can take a call on model, by the one -// test the run's model API makes ([ServesModel]). A conversation with no -// services at all cannot be asked, and is not refused on that account. -func (a *Agent) programServes(model string) bool { +// resolveProgramWord is one word of [Agent.resolveProgramModels]: a model, a +// shortlist of the ones a service serves, or the refusal. +func (a *Agent) resolveProgramWord(word string) taskModelChoice { + sources := a.programSources() + if segment, bare := modelsource.Split(word, sources.Written()); segment != "" && bare != "" { + if !ServesModel(sources, word) { + return taskModelChoice{problem: programUnservedProblem(word)} + } + return taskModelChoice{model: word} + } + choice := a.resolveTaskModel(word) + serves := func(model string) bool { return sources.Empty() || ServesModel(sources, model) } + switch { + case choice.problem != "": + return choice + case len(choice.options) > 0: + choice.options = slices.DeleteFunc(choice.options, func(model string) bool { return !serves(model) }) + switch len(choice.options) { + case 0: + return taskModelChoice{problem: programUnservedProblem(word)} + case 1: + return taskModelChoice{model: choice.options[0]} + } + return choice + case !serves(choice.model): + return taskModelChoice{problem: programUnservedProblem(choice.model)} + } + return choice +} + +// programSources is this conversation's model services, read under the lock +// the surface moves them under. A conversation with none cannot be asked what +// they serve, and a model is not refused on that account. +func (a *Agent) programSources() modelsource.Set { a.mu.Lock() - sources := a.config.Sources.OrDefault(a.config.APIKey, a.config.BaseURL) - a.mu.Unlock() - return sources.Empty() || ServesModel(sources, model) + defer a.mu.Unlock() + return a.config.Sources.OrDefault(a.config.APIKey, a.config.BaseURL) } // programUnservedProblem is the refusal for a model no connected service serves. From c167939a93895432df066da6f183993d45f4dbd0 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:49:12 -0400 Subject: [PATCH 109/195] delegate, seniordev: senior-dev says which step of its process each action served MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step record said only what was run and the head of what came back, a stage record only its name and status, and the build and tests senior-dev runs on the tree itself emitted nothing at all; everything else it knew went to stderr. Now a step names its tool, the step of senior-dev's process it served (brief, explore, pin, checklist, implement, submit, verify — read off the calls in the order they finished, never steering them) and a command's exit code; every command of senior-dev's own checks is a verify step; a stage carries a curated copy of its data, at most 1 KB; and a compaction and the coder's move to another model are stages of their own. The fields are optional and additive, so the protocol stays version 2. What senior-dev runs and decides is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried.go | 15 +- cmd/codeaf/carried_child_test.go | 8 +- docs/design/delegate/PROTOCOL.md | 22 +- internal/delegate/cli.go | 4 +- internal/delegate/cli_test.go | 2 +- internal/delegate/emit.go | 32 ++- internal/delegate/emit_test.go | 17 +- internal/delegate/host.go | 4 +- internal/delegate/protocol.go | 120 ++++++++++- internal/delegate/protocol_test.go | 77 ++++++- internal/run/delegate_child_test.go | 8 +- internal/run/delegateworker.go | 9 +- internal/run/stage_words_internal_test.go | 2 +- internal/seniordev/app/compaction_events.go | 42 +++- internal/seniordev/app/engine_router.go | 12 ++ internal/seniordev/app/events.go | 49 ++++- .../seniordev/app/events_contract_test.go | 35 +++- .../seniordev/app/full_verification_run.go | 9 + .../seniordev/app/full_verification_test.go | 41 ++++ .../seniordev/app/router_cancellation_test.go | 39 ++++ internal/seniordev/app/run.go | 9 +- internal/seniordev/app/runtime.go | 2 +- internal/seniordev/app/stage_data.go | 114 +++++++++++ internal/seniordev/app/stage_data_test.go | 91 +++++++++ internal/seniordev/app/step_ids.go | 190 ++++++++++++++++++ internal/seniordev/app/step_ids_test.go | 72 +++++++ internal/seniordev/app/step_records.go | 60 +++++- internal/seniordev/app/testsupport_test.go | 8 +- internal/seniordev/seniordev.go | 2 + internal/seniordev/seniordev_test.go | 27 ++- 30 files changed, 1022 insertions(+), 100 deletions(-) create mode 100644 internal/seniordev/app/stage_data.go create mode 100644 internal/seniordev/app/stage_data_test.go create mode 100644 internal/seniordev/app/step_ids.go create mode 100644 internal/seniordev/app/step_ids_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index 00a8a84e8..e5875b774 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -528,11 +528,12 @@ func (v *carriedView) remember(change func(record *delegate.ProgramRecord)) { _ = delegate.WriteProgram(v.record, v.program) } -func (v *carriedView) Stage(stage, status string) { +func (v *carriedView) Stage(record delegate.StageRecord) { if v.records != nil { - _ = v.records.Stage(stage, status) + _ = v.records.Stage(record) return } + stage, status := record.Stage, record.Status if !v.moved(stage, status) { return } @@ -553,16 +554,16 @@ func (v *carriedView) moved(stage, status string) bool { return changed } -func (v *carriedView) Step(command, observation string) { +func (v *carriedView) Step(record delegate.StepRecord) { if v.records != nil { - _ = v.records.Step(command, observation) + _ = v.records.Step(record) return } - if head := firstLineOf(observation); head != "" { - v.say(" %s · %s", command, head) + if head := firstLineOf(record.Observation); head != "" { + v.say(" %s · %s", record.Command, head) return } - v.say(" %s", command) + v.say(" %s", record.Command) } func (v *carriedView) Terminal(t delegate.Terminal) { diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go index 9f1e641db..dd83a0b01 100644 --- a/cmd/codeaf/carried_child_test.go +++ b/cmd/codeaf/carried_child_test.go @@ -47,21 +47,21 @@ func fakeCarriedProgram() delegate.Delegate { 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("implement", "running") + 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("model: ask", "refused: "+err.Error()) + host.Step(delegate.StepRecord{Command: "model: ask", Observation: "refused: " + err.Error()}) continue } - host.Step("model: ask", reply) + 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("verify", "pass") + 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 diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index ed218fdde..963bf3618 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -133,14 +133,32 @@ and the command's own flags survive. | record | when | fields | | --- | --- | --- | | `hello` | first | `protocol` (2), `delegate`, `stages` (the whole list, in order) | -| `stage` | on every phase change | `stage`, `status` | -| `step` | once per finished action | `command` (one line, 200 bytes at most), `observation` (2048 bytes at most) | +| `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) and `exit` (a command's exit code, only for an action that ran one) | | `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` or `exit` 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 diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index fd1063daf..edc70257a 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -222,8 +222,8 @@ 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, status string) { _ = h.emitter.Stage(stage, status) } -func (h *childHost) Step(command, observation string) { _ = h.emitter.Step(command, observation) } +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 diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index f989ae58c..0d1f2ce4d 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -24,7 +24,7 @@ func testProgram(body Body) Delegate { variant := fs.String("variant", "", "how hard the model thinks") return func(ctx context.Context, host Host, args []string) error { if *variant != "" { - host.Stage("variant", *variant) + host.Stage(StageRecord{Stage: "variant", Status: *variant}) } return body(ctx, host, args) } diff --git a/internal/delegate/emit.go b/internal/delegate/emit.go index ae1ccf171..d36acc836 100644 --- a/internal/delegate/emit.go +++ b/internal/delegate/emit.go @@ -79,17 +79,33 @@ 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. -func (e *Emitter) Stage(stage, status string) error { - return e.write(map[string]any{"type": RecordStage, "stage": stage, "status": status}) +// 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. -func (e *Emitter) Step(command, observation string) error { - record := map[string]any{"type": RecordStep, "command": cut(oneLine(command), commandCap)} - if observation != "" { - record["observation"] = cut(observation, observationCap) +// 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 } return e.write(record) } diff --git a/internal/delegate/emit_test.go b/internal/delegate/emit_test.go index 13adfe679..a74804f4c 100644 --- a/internal/delegate/emit_test.go +++ b/internal/delegate/emit_test.go @@ -11,8 +11,10 @@ func TestTheEmitterWritesWhatTheReaderReads(t *testing.T) { var stdout bytes.Buffer emitter := NewEmitter(&stdout) _ = emitter.Hello("senior-dev", []string{"implement", "submit"}) - _ = emitter.Stage("implement", "running") - _ = emitter.Step("bash: go test\n./...", "ok") + 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{} @@ -20,12 +22,21 @@ func TestTheEmitterWritesWhatTheReaderReads(t *testing.T) { if err != nil { t.Fatal(err) } - if reading.Hello == nil || reading.Hello.Protocol != ProtocolVersion || reading.LastStage != "implement" || reading.Steps != 1 { + 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) diff --git a/internal/delegate/host.go b/internal/delegate/host.go index e74c798c5..15091e1e4 100644 --- a/internal/delegate/host.go +++ b/internal/delegate/host.go @@ -31,8 +31,8 @@ type Host interface { // Hello, Stage, Step and Terminal are the records (protocol.go). Hello // comes first and Terminal last, once. Hello(stages []string) - Stage(stage, status string) - Step(command, observation string) + Stage(stage StageRecord) + Step(step StepRecord) Terminal(end Ending) // Models is this run's model API. Models() ModelAPI diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go index 83692495e..ab4420119 100644 --- a/internal/delegate/protocol.go +++ b/internal/delegate/protocol.go @@ -40,6 +40,11 @@ const ( // 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 @@ -68,8 +73,65 @@ const ( 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, `<tool>: <what it was about>`. + 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"` +} + +// 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. @@ -165,11 +227,12 @@ func KnownStatus(status string) bool { type Sink interface { // Hello is the program's first record, told once. Hello(h Hello) - // Stage is a phase change: the live step. - Stage(stage, status string) + // 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. - Step(command, observation string) + // 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. @@ -227,9 +290,13 @@ func Read(r io.Reader, sink Sink) (Reading, error) { 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"` + Stage string `json:"stage"` + Status string `json:"status"` + Data json.RawMessage `json:"data"` } if json.Unmarshal([]byte(line), &rec) != nil || rec.Stage == "" { reading.Ignored++ @@ -237,12 +304,19 @@ func Read(r io.Reader, sink Sink) (Reading, error) { } reading.LastStage, reading.LastStatus = rec.Stage, rec.Status if sink != nil { - sink.Stage(rec.Stage, rec.Status) + 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"` + Command string `json:"command"` + Observation string `json:"observation"` + Tool json.RawMessage `json:"tool"` + Step json.RawMessage `json:"step"` + Exit json.RawMessage `json:"exit"` } if json.Unmarshal([]byte(line), &rec) != nil || strings.TrimSpace(rec.Command) == "" { reading.Ignored++ @@ -250,7 +324,13 @@ func Read(r io.Reader, sink Sink) (Reading, error) { } reading.Steps++ if sink != nil { - sink.Step(cut(oneLine(rec.Command), commandCap), cut(rec.Observation, observationCap)) + 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), + }) } case RecordTerminal: if reading.Terminal != nil { @@ -273,6 +353,26 @@ func Read(r io.Reader, sink Sink) (Reading, error) { 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), " ") diff --git a/internal/delegate/protocol_test.go b/internal/delegate/protocol_test.go index 3ed4f963a..0207c3211 100644 --- a/internal/delegate/protocol_test.go +++ b/internal/delegate/protocol_test.go @@ -12,13 +12,17 @@ import ( // 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 - terminal *Terminal + 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{})} } @@ -29,18 +33,20 @@ func (r *recorder) Hello(h Hello) { r.hello = &h } -func (r *recorder) Stage(stage, status string) { +func (r *recorder) Stage(stage StageRecord) { r.mu.Lock() defer r.mu.Unlock() - r.stages = append(r.stages, stage+"·"+status) + 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(command, observation string) { +func (r *recorder) Step(step StepRecord) { r.mu.Lock() defer r.mu.Unlock() - r.steps = append(r.steps, command+"→"+observation) + r.steps = append(r.steps, step.Command+"→"+step.Observation) + r.stepRecords = append(r.stepRecords, step) } func (r *recorder) Terminal(t Terminal) { r.mu.Lock() @@ -178,3 +184,52 @@ func TestTheReaderTakesOneHelloWithItsStages(t *testing.T) { 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) + } + } +} diff --git a/internal/run/delegate_child_test.go b/internal/run/delegate_child_test.go index c73b65baf..ba06b8154 100644 --- a/internal/run/delegate_child_test.go +++ b/internal/run/delegate_child_test.go @@ -54,7 +54,7 @@ func childBody(ctx context.Context, host delegate.Host, args []string) error { _ = os.WriteFile(path, []byte(strings.Join(os.Environ(), "\n")), 0o600) } host.Hello([]string{"implement", "verify"}) - host.Stage("implement", "running") + 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 { @@ -62,7 +62,7 @@ func childBody(ctx context.Context, host delegate.Host, args []string) error { } reply, err := askModel(ctx, host.Models(), fmt.Sprintf("call %d: %s", call, strings.Join(args, " "))) if err != nil { - host.Step("model: ask", "refused: "+err.Error()) + 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 @@ -72,14 +72,14 @@ func childBody(ctx context.Context, host delegate.Host, args []string) error { } continue } - host.Step("model: ask", reply) + 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("verify", "pass") + 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 } diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 30bd8084b..5dd77c0c3 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -195,7 +195,8 @@ func (s *delegateSink) Hello(h delegate.Hello) { } } -func (s *delegateSink) Stage(stage, status string) { +func (s *delegateSink) Stage(record delegate.StageRecord) { + stage, status := record.Stage, record.Status // THE LIVE STEP IS THE PROGRAM'S PHASE, numbered after the last step // recorded, so the row reads "senior-dev: working" while the program is // inside that phase and the count on the row stays the steps'. @@ -219,13 +220,13 @@ func (s *delegateSink) Stage(stage, status string) { _ = s.worker.store.SetLive(s.taskID, s.steps+1, label) } -func (s *delegateSink) Step(command, observation string) { +func (s *delegateSink) Step(record delegate.StepRecord) { s.steps++ if err := appendTrajectory(s.storeDir, s.taskID, Step{ Kind: trajectoryStepKind, Step: s.steps, - Command: command, - Observation: observationHead(observation), + Command: record.Command, + Observation: observationHead(record.Observation), }); err != nil && s.lastErr == nil { s.lastErr = err } diff --git a/internal/run/stage_words_internal_test.go b/internal/run/stage_words_internal_test.go index 5da3d1a94..ce710b8b9 100644 --- a/internal/run/stage_words_internal_test.go +++ b/internal/run/stage_words_internal_test.go @@ -23,7 +23,7 @@ func TestAProgramsStageIsShownInTheWordItGaveAPerson(t *testing.T) { defer store.Close() sink := &delegateSink{worker: &DelegateWorker{store: store, program: program}, taskID: "root", name: program.Name} for _, stage := range stages { - sink.Stage(stage[0], stage[1]) + sink.Stage(delegate.StageRecord{Stage: stage[0], Status: stage[1]}) } return store.LiveSteps()["root"].Command } diff --git a/internal/seniordev/app/compaction_events.go b/internal/seniordev/app/compaction_events.go index 699e0bd27..2d1b50030 100644 --- a/internal/seniordev/app/compaction_events.go +++ b/internal/seniordev/app/compaction_events.go @@ -11,19 +11,51 @@ var seniorDevCompactionDecisionEvent = bus.Define( "session.compaction.decision", compaction.CompactionDecision{}, ) -type seniorDevCompactionDecisionSink struct{ bus *bus.Bus } +// 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) compaction.DecisionSink { - if instance == nil { +func newSeniorDevCompactionDecisionSink(instance *bus.Bus, events *eventWriter) compaction.DecisionSink { + if instance == nil && events == nil { return nil } - return seniorDevCompactionDecisionSink{bus: instance} + return seniorDevCompactionDecisionSink{bus: instance, events: events} } func (sink seniorDevCompactionDecisionSink) CompactionDecision( decision compaction.CompactionDecision, ) { - sink.bus.Publish(seniorDevCompactionDecisionEvent, decision) + 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/engine_router.go b/internal/seniordev/app/engine_router.go index 5f58a380f..9d7367326 100644 --- a/internal/seniordev/app/engine_router.go +++ b/internal/seniordev/app/engine_router.go @@ -31,6 +31,18 @@ func initRunRouter(args cliArgs, events ...*eventWriter) *adaptive.AdaptiveModel "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) diff --git a/internal/seniordev/app/events.go b/internal/seniordev/app/events.go index 936fd7ab8..4d1375309 100644 --- a/internal/seniordev/app/events.go +++ b/internal/seniordev/app/events.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/seniordev/bus" ) @@ -26,9 +27,14 @@ type event struct { // `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. + // `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"` } // recordSink is where the run's protocol records go: codeaf, through the @@ -36,8 +42,8 @@ type event struct { // 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, status string) - Step(command, observation string) + Stage(stage delegate.StageRecord) + Step(step delegate.StepRecord) } // eventWriter is the run's one outlet for what it has to say. @@ -65,12 +71,16 @@ type eventWriter struct { 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 only the stage and its status. + // 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. + progress stepProgress } // newEventWriter is a writer whose only outlet is output: every record and @@ -113,12 +123,17 @@ func (writer *eventWriter) emit(value event) { switch value.Type { case "stage": if writer.records != nil { - writer.records.Stage(value.Stage, value.Status) + 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(value.Command, value.Observation) + writer.records.Step(delegate.StepRecord{ + Command: value.Command, Observation: value.Observation, + Tool: value.Tool, Step: value.Step, Exit: value.Exit, + }) } } } @@ -159,6 +174,10 @@ func (writer *eventWriter) busEvent(value bus.Payload) { 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() @@ -168,6 +187,7 @@ func (writer *eventWriter) busEvent(value bus.Payload) { if isStep { writer.emit(event{ Type: "step", Command: step.command, Observation: step.observation, + Tool: step.action.tool, Step: step.step, Exit: step.exit, }) } // The running total, after the message that moved it, for the log only: @@ -182,3 +202,20 @@ func (writer *eventWriter) busEvent(value bus.Payload) { 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_contract_test.go b/internal/seniordev/app/events_contract_test.go index 95fb4353c..ae8f08851 100644 --- a/internal/seniordev/app/events_contract_test.go +++ b/internal/seniordev/app/events_contract_test.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -99,22 +100,28 @@ func TestPipelineStreamsBusEventsToTheLog(t *testing.T) { // recordedHost is the part of a delegate host the event writer reports to. type recordedHost struct { - stages []string - steps []string + stages []string + steps []string + stageRecords []delegate.StageRecord + stepRecords []delegate.StepRecord } -func (host *recordedHost) Stage(stage, status string) { - host.stages = append(host.stages, stage+"/"+status) +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(command, observation string) { - host.steps = append(host.steps, command) +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 to -// the notes, which are stderr, for a person. +// 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 @@ -122,16 +129,24 @@ func TestAHostedRunReportsOnlyStagesAndSteps(t *testing.T) { writer.stage("implement", "running", map[string]any{"attempt": 0}) writer.busEvent(toolPartPayload("c1", "bash", "running", map[string]any{"command": "go test ./..."}, "", "")) - writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok", "")) - writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok", "")) + 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()) } diff --git a/internal/seniordev/app/full_verification_run.go b/internal/seniordev/app/full_verification_run.go index 4dbed6795..b9c19e7b6 100644 --- a/internal/seniordev/app/full_verification_run.go +++ b/internal/seniordev/app/full_verification_run.go @@ -217,6 +217,15 @@ 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, diff --git a/internal/seniordev/app/full_verification_test.go b/internal/seniordev/app/full_verification_test.go index 579718887..161de53b8 100644 --- a/internal/seniordev/app/full_verification_test.go +++ b/internal/seniordev/app/full_verification_test.go @@ -178,3 +178,44 @@ func TestABareWorkspaceVerifiesVacuouslyRatherThanFailing(t *testing.T) { 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 last := host.stages[len(host.stages)-1]; last != "verification/fail" { + t.Fatalf("the last stage is %q, want the verification's own result after its steps", last) + } +} diff --git a/internal/seniordev/app/router_cancellation_test.go b/internal/seniordev/app/router_cancellation_test.go index bed39f04c..e317a1abd 100644 --- a/internal/seniordev/app/router_cancellation_test.go +++ b/internal/seniordev/app/router_cancellation_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "testing" "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" @@ -38,3 +39,41 @@ func TestRouterCancellationDecisionsAreTraced(t *testing.T) { } } } + +// 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 index 6aadd335d..d825f4d77 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -29,9 +29,10 @@ var version = buildinfo.String() // // 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 and router-cancellation happen inside -// a model turn, only when a rejection pins a window or a call is withdrawn, so -// they sit where the turns are. +// (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", @@ -40,7 +41,9 @@ var Stages = []string{ "implement", "agent-runtime", "compaction-capacity", + "compaction", "router-cancellation", + "model-switch", "submit", "verification", "ship", diff --git a/internal/seniordev/app/runtime.go b/internal/seniordev/app/runtime.go index 70f19ff60..7ccf0b651 100644 --- a/internal/seniordev/app/runtime.go +++ b/internal/seniordev/app/runtime.go @@ -287,7 +287,7 @@ func (runtime *runtimeAdapter) runTurn(ctx context.Context, request turn) (turnR } request.Store = runtime.durable if request.CompactionDecisions == nil { - request.CompactionDecisions = newSeniorDevCompactionDecisionSink(runtime.bus) + request.CompactionDecisions = newSeniorDevCompactionDecisionSink(runtime.bus, runtime.events) } if request.ModelRequests == nil { request.ModelRequests = newModelRequestSink(runtime.bus) diff --git a/internal/seniordev/app/stage_data.go b/internal/seniordev/app/stage_data.go new file mode 100644 index 000000000..bfc57a75e --- /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 000000000..d78b8cf32 --- /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/step_ids.go b/internal/seniordev/app/step_ids.go new file mode 100644 index 000000000..fe93c03b8 --- /dev/null +++ b/internal/seniordev/app/step_ids.go @@ -0,0 +1,190 @@ +//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. +type stepProgress struct { + changed bool + submitted bool +} + +// 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. +// - 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" { + if !action.failed { + progress.submitted = true + } + 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 000000000..55e38e32e --- /dev/null +++ b/internal/seniordev/app/step_ids_test.go @@ -0,0 +1,72 @@ +//go:build !windows + +package app + +import "testing" + +// 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. +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 refused submit", stepAction{tool: "submit", failed: true}, changed, StepSubmit, changed}, + {"an accepted submit", stepAction{tool: "submit"}, changed, 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) + } + }) + } +} + +// 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) + } + } +} diff --git a/internal/seniordev/app/step_records.go b/internal/seniordev/app/step_records.go index 2f8ffc64b..66ac51174 100644 --- a/internal/seniordev/app/step_records.go +++ b/internal/seniordev/app/step_records.go @@ -12,10 +12,12 @@ import ( ) // The `step` record projects one finished tool call into a shape a reader can -// display without understanding the message model: what was run, and what came -// back. Every byte of it is already on stdout inside the `message.part.updated` -// payload for the same call — this adds no information to the stream, it -// rearranges information the stream already carries. +// 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 @@ -33,11 +35,16 @@ const ( // 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. +// 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 } // toolStepRecord reads a bus payload and reports the finished tool call in it, @@ -57,8 +64,13 @@ func toolStepRecord(value bus.Payload) (stepRecord, bool) { return stepRecord{}, false } tool := stringAt(part, "tool") - record := stepRecord{key: "tool:" + stringAt(part, "callID") + ":" + status} - if argument := toolArgument(mapAt(state, "input")); argument != "" { + 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 argument := toolArgument(input); argument != "" { record.command = tool + ": " + argument } else { record.command = tool @@ -72,6 +84,40 @@ func toolStepRecord(value bus.Payload) (stepRecord, bool) { 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 "" +} + +// 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. diff --git a/internal/seniordev/app/testsupport_test.go b/internal/seniordev/app/testsupport_test.go index 5fd345883..51edeafe2 100644 --- a/internal/seniordev/app/testsupport_test.go +++ b/internal/seniordev/app/testsupport_test.go @@ -55,16 +55,16 @@ func (host *testHost) Hello(stages []string) { host.hellos = append(host.hellos, stages) } -func (host *testHost) Stage(stage, status string) { +func (host *testHost) Stage(stage delegate.StageRecord) { host.mu.Lock() defer host.mu.Unlock() - host.stages = append(host.stages, stage+"/"+status) + host.stages = append(host.stages, stage.Stage+"/"+stage.Status) } -func (host *testHost) Step(command, observation string) { +func (host *testHost) Step(step delegate.StepRecord) { host.mu.Lock() defer host.mu.Unlock() - host.steps = append(host.steps, command) + host.steps = append(host.steps, step.Command) } func (host *testHost) Terminal(end delegate.Ending) { diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 7550eab6e..92ec08999 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -49,7 +49,9 @@ var stageWords = map[string]string{ "implement": "working", "agent-runtime": "working", "compaction-capacity": "working", + "compaction": "working", "router-cancellation": "working", + "model-switch": "working", "submit": "handing in its work", "verification": "checking its work", "ship": "finishing", diff --git a/internal/seniordev/seniordev_test.go b/internal/seniordev/seniordev_test.go index 3b2eae1fa..e56464664 100644 --- a/internal/seniordev/seniordev_test.go +++ b/internal/seniordev/seniordev_test.go @@ -32,6 +32,8 @@ type hostRecord struct { stages []string stage string step string + // record is the step record whole: its tool, its step and its exit. + record delegate.StepRecord ending delegate.Ending } @@ -49,10 +51,12 @@ func (h *recordingHost) Workspace() string { return h.workspace } func (h *recordingHost) Ceilings() delegate.Ceilings { return h.ceilings } func (h *recordingHost) Models() delegate.ModelAPI { return h.api } func (h *recordingHost) Hello(stages []string) { h.add(hostRecord{kind: "hello", stages: stages}) } -func (h *recordingHost) Stage(stage, status string) { - h.add(hostRecord{kind: "stage", stage: stage + "/" + status}) +func (h *recordingHost) Stage(stage delegate.StageRecord) { + h.add(hostRecord{kind: "stage", stage: stage.Stage + "/" + stage.Status}) +} +func (h *recordingHost) Step(step delegate.StepRecord) { + h.add(hostRecord{kind: "step", step: step.Command, record: step}) } -func (h *recordingHost) Step(command, _ string) { h.add(hostRecord{kind: "step", step: command}) } func (h *recordingHost) Terminal(end delegate.Ending) { h.add(hostRecord{kind: "terminal", ending: end}) } @@ -276,10 +280,11 @@ func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { if len(records) == 0 || records[0].kind != "hello" { t.Fatalf("the first record is not hello: %+v", records) } - if !slices.Equal(records[0].stages, app.Stages) || len(records[0].stages) != 13 { - t.Fatalf("hello names %v, want the run's thirteen stages %v", records[0].stages, app.Stages) + if !slices.Equal(records[0].stages, app.Stages) || len(records[0].stages) != 15 { + t.Fatalf("hello names %v, want the run's fifteen stages %v", records[0].stages, app.Stages) } var steps, terminals int + named := map[string]bool{} for at, record := range records { switch record.kind { case "hello": @@ -288,6 +293,11 @@ func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { } case "step": steps++ + // EVERY STEP NAMES ITS TOOL AND THE STEP OF THE PROCESS IT SERVED. + if record.record.Tool == "" || !slices.Contains(app.Steps, record.record.Step) { + t.Fatalf("step %q says tool %q and step %q, want a tool and one of %v", record.step, record.record.Tool, record.record.Step, app.Steps) + } + named[record.record.Step] = true case "terminal": terminals++ if at != len(records)-1 { @@ -298,6 +308,13 @@ func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { if steps < 1 { t.Fatalf("no step records: %+v", records) } + // The scripted model writes the feature, then its checklist, then submits; + // senior-dev then runs the project's own build and tests itself. + for _, want := range []string{app.StepImplement, app.StepChecklist, app.StepSubmit, app.StepVerify} { + if !named[want] { + t.Errorf("no step was named %q: %v", want, named) + } + } if terminals != 1 { t.Fatalf("%d terminal records, want exactly one", terminals) } From d979f47159ed35736a010e899275d54ce794e291 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:00:08 -0400 Subject: [PATCH 110/195] delegate, run, session, seniordev: a program's task keeps the actions it took, read in its own words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program's stages fed only the live step and were lost as the next one came, so a page could show its conversation with codeaf and nothing of what it did. Now every stage, step and ending is written to the task's action log (delegate-actions.jsonl) as it arrives, stamped with codeaf's own clock, by the run's worker and by a shell run alike; a program's own vocabulary (Delegate.Present) reads each line as an action under the step of its process it served — senior-dev's says `handed in its work · 4 files · 5 of 5 ticked` under `submit`, and tells a nudge from a retry of the same attempt — and the task page carries the newest of them, across --host too. The live step now names the step senior-dev is in (`explore`, `implement`, `verify`), falling back to the stage words before one is named. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried.go | 16 +- cmd/codeaf/carried_host_test.go | 10 + cmd/codeaf/carried_seniordev_test.go | 6 +- cmd/codeaf/carried_seniordev_worker_test.go | 18 + docs/design/delegate/DESIGN.md | 4 +- docs/design/delegate/PROTOCOL.md | 21 +- internal/delegate/actions.go | 239 ++++++++++++ internal/delegate/actions_test.go | 94 +++++ internal/delegate/delegate.go | 14 + internal/remote/client_test.go | 10 +- internal/run/delegateworker.go | 100 +++-- internal/run/stage_words_internal_test.go | 60 +++ internal/seniordev/actions.go | 392 ++++++++++++++++++++ internal/seniordev/actions_test.go | 168 +++++++++ internal/seniordev/app/run.go | 8 +- internal/seniordev/seniordev.go | 9 +- internal/session/plandb_program.go | 116 ++++-- internal/session/plandb_program_test.go | 79 ++++ internal/session/plandb_tasks.go | 9 +- 19 files changed, 1308 insertions(+), 65 deletions(-) create mode 100644 internal/delegate/actions.go create mode 100644 internal/delegate/actions_test.go create mode 100644 internal/seniordev/actions.go create mode 100644 internal/seniordev/actions_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index e5875b774..bafec4c25 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -331,7 +331,7 @@ func carriedSettlingLine(owed int) string { const carriedStderrName = "delegate-stderr.log" // carriedRecordDir is a shell run's record folder: the conversation log, the -// program record and the program's stderr, under this machine's state root +// 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 { @@ -528,7 +528,19 @@ func (v *carriedView) remember(change func(record *delegate.ProgramRecord)) { _ = 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 @@ -555,6 +567,7 @@ func (v *carriedView) moved(stage, status string) bool { } func (v *carriedView) Step(record delegate.StepRecord) { + v.kept(delegate.StepAction(time.Now(), record)) if v.records != nil { _ = v.records.Step(record) return @@ -568,6 +581,7 @@ func (v *carriedView) Step(record delegate.StepRecord) { func (v *carriedView) Terminal(t delegate.Terminal) { v.keep(t) + v.kept(delegate.EndAction(time.Now(), t)) if v.records == nil { return } diff --git a/cmd/codeaf/carried_host_test.go b/cmd/codeaf/carried_host_test.go index 86d6387ab..f2e78b291 100644 --- a/cmd/codeaf/carried_host_test.go +++ b/cmd/codeaf/carried_host_test.go @@ -160,6 +160,16 @@ func TestAShellRunHostsTheModelAPIForARealChildAndPrintsItsWork(t *testing.T) { 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 diff --git a/cmd/codeaf/carried_seniordev_test.go b/cmd/codeaf/carried_seniordev_test.go index e7bad9244..1f054c172 100644 --- a/cmd/codeaf/carried_seniordev_test.go +++ b/cmd/codeaf/carried_seniordev_test.go @@ -5,6 +5,7 @@ package main import ( "context" "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -50,7 +51,10 @@ func (m *seniorDevModel) CompleteWithMessages(ctx context.Context, _ []ai.Messag encoded, _ := json.Marshal(arguments) return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ - ID: "call-" + name, Type: "function", Function: ai.ToolCallFunction{Name: name, Arguments: string(encoded)}, + // 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 diff --git a/cmd/codeaf/carried_seniordev_worker_test.go b/cmd/codeaf/carried_seniordev_worker_test.go index e71a6b73f..4639ee19c 100644 --- a/cmd/codeaf/carried_seniordev_worker_test.go +++ b/cmd/codeaf/carried_seniordev_worker_test.go @@ -114,6 +114,24 @@ func TestSeniorDevWorksATaskAsTheChatsRunWorker(t *testing.T) { 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") { diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 8353d002b..ef1f0a050 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -5,7 +5,8 @@ > 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. The protocol is now internal, +> 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 landing (one squashed commit for a tree, the answer folded in for @@ -57,6 +58,7 @@ at the person's discretion. | 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 | diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index 963bf3618..7768d7f2a 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -170,15 +170,30 @@ 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`). -## 6. The conversation log +## 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. The page draws the turns as -the conversation between the program and codeaf. +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 diff --git a/internal/delegate/actions.go b/internal/delegate/actions.go new file mode 100644 index 000000000..fda7ac07f --- /dev/null +++ b/internal/delegate/actions.go @@ -0,0 +1,239 @@ +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"` + // 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, + } +} + +// 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"` + // 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 000000000..44d985cab --- /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/delegate.go b/internal/delegate/delegate.go index c185160d6..6cc899e75 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -122,6 +122,20 @@ type Delegate struct { // 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: `/<name> <brief>` in the chat // and `codeaf <name> <brief>` in a shell. It names one of Commands. Default string diff --git a/internal/remote/client_test.go b/internal/remote/client_test.go index e93a355bd..ffb96eb82 100644 --- a/internal/remote/client_test.go +++ b/internal/remote/client_test.go @@ -987,7 +987,8 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) { 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. + // 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{ @@ -999,6 +1000,13 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) { {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} diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 5dd77c0c3..d1439d7a7 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -11,9 +11,12 @@ package run // What differs is inside: there is no model turn here. The program runs as a // child process of codeaf's own executable (`codeaf <name> run --json …`) in // the run's working copy, its stdout is the records, and its terminal record is -// the ending. Its stages feed the live step only; its `step` records are 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. +// 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 ──────────────────────── // @@ -172,6 +175,68 @@ type delegateSink struct { // 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. + reader delegate.ActionReader + stepped bool +} + +// 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 + _ = s.worker.store.SetLive(s.taskID, s.steps+1, s.name+": "+strings.TrimSpace(shown.Step)) + 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) { @@ -196,28 +261,7 @@ func (s *delegateSink) Hello(h delegate.Hello) { } func (s *delegateSink) Stage(record delegate.StageRecord) { - stage, status := record.Stage, record.Status - // THE LIVE STEP IS THE PROGRAM'S PHASE, numbered after the last step - // recorded, so the row reads "senior-dev: working" while the program is - // inside that phase and the count on the row stays the steps'. - // - // 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. - label := s.name + ": " + stage - if words := s.worker.program.StageWords; words != nil { - word := strings.TrimSpace(words[stage]) - if word == "" { - return - } - label = s.name + ": " + word - } else if status != "" { - label += " · " + status - } - _ = s.worker.store.SetLive(s.taskID, s.steps+1, label) + s.remember(delegate.StageAction(time.Now(), record)) } func (s *delegateSink) Step(record delegate.StepRecord) { @@ -230,9 +274,13 @@ func (s *delegateSink) Step(record delegate.StepRecord) { }); 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 } +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 diff --git a/internal/run/stage_words_internal_test.go b/internal/run/stage_words_internal_test.go index ce710b8b9..9e0b81b5d 100644 --- a/internal/run/stage_words_internal_test.go +++ b/internal/run/stage_words_internal_test.go @@ -2,7 +2,9 @@ package run import ( "path/filepath" + "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" @@ -38,3 +40,61 @@ func TestAProgramsStageIsShownInTheWordItGaveAPerson(t *testing.T) { 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/seniordev/actions.go b/internal/seniordev/actions.go new file mode 100644 index 000000000..d382d0af3 --- /dev/null +++ b/internal/seniordev/actions.go @@ -0,0 +1,392 @@ +//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("its model stopped without handing in; told it what it found and to finish (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) + } + return shown, strings.TrimSpace(shown.Text) != "" +} + +// 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": + return delegate.Shown{Step: setupWord, Text: "set up its workspace", Outcome: facts.text("recorder")}, 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/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 000000000..6a8cdf2b4 --- /dev/null +++ b/internal/seniordev/actions_test.go @@ -0,0 +1,168 @@ +//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), + 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", "its model stopped without handing in; told it what it found and to finish (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", "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) + } +} diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go index d825f4d77..e438fc53f 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -349,13 +349,15 @@ func observedOf(data map[string]any) string { 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)) + 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. -func restoredFrom(source string) string { +// 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" diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 92ec08999..7dd167489 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -85,9 +85,12 @@ var Program = delegate.Delegate{ // handing it in, checking it, wrapping up. A test holds every stage in // app.Stages to a word. StageWords: stageWords, - Default: "run", - Page: "senior-dev", - Commands: []delegate.Command{runCommand}, + // What each line of its action log reads as on its task's page, under the + // step of its process it served (actions.go). + Present: presentActions, + Default: "run", + Page: "senior-dev", + Commands: []delegate.Command{runCommand}, } // crewFlags is the conversation's crew as senior-dev's own flags: the working diff --git a/internal/session/plandb_program.go b/internal/session/plandb_program.go index 1c1bf3e41..4a0bcf7f8 100644 --- a/internal/session/plandb_program.go +++ b/internal/session/plandb_program.go @@ -1,20 +1,26 @@ package session -// A PROGRAM'S TASK IS READ AS A CONVERSATION. A task a run handed to a program -// codeaf carries (senior-dev first; internal/delegate) has no steps worth a -// page of their own: the program's work happens inside the calls it makes to a -// model, and every one of those goes through the model API codeaf serves the -// run, which writes each call as one turn of the program's conversation with -// codeaf into the task's own record folder (delegate.ConversationFile). This -// file is the reading side of that record for the task page: whose -// conversation it is, the stages it said it would move through, the stage it -// is in now, and the turns themselves, cut to what a page draws. +// A PROGRAM'S TASK IS READ AS THE ACTIONS IT TOOK. A task a run handed to a +// program codeaf carries (senior-dev first; internal/delegate) is drawn as what +// the program did, step by step through its own process: every stage, step and +// ending it reported, which the run's worker writes to the task's action log +// as each arrives (delegate.ActionsFile), read through the program's own +// vocabulary for them (delegate.Delegate's Present). Beside them the page +// carries the program's calls to a model — every one of which goes through the +// model API codeaf serves the run, which writes each as one turn into the +// task's own record folder (delegate.ConversationFile) — because the calls +// know two things the actions do not always say (a compaction, a change of +// model) and because the raw calls stay one key away on the page. This file is +// the reading side of both for the task page: whose run it is, the stages it +// said it would move through, the step it is in now, its actions and its +// turns, cut to what a page draws. // // NOTHING HERE WRITES. The run's worker writes the program record when the -// program says hello (delegate.ProgramFile) and the model API writes the -// turns; this file reads both, inside the page read the surface already makes -// off its loop ([Agent.PlanTaskPage]) and the row read the side list already -// makes on its beat ([Agent.PlanTasks]), and never on a frame. +// program says hello (delegate.ProgramFile) and the action log as the records +// arrive, and the model API writes the turns; this file reads all three, +// inside the page read the surface already makes off its loop +// ([Agent.PlanTaskPage]) and the row read the side list already makes on its +// beat ([Agent.PlanTasks]), and never on a frame. import ( "path/filepath" @@ -31,6 +37,11 @@ import ( // a page and not the size of the log. const planProgramTurns = 200 +// planProgramActions is how many of a program's actions one page carries: the +// newest, for the reason [planProgramTurns] keeps the newest calls, and more of +// them, because a call that ran five tools is five actions. +const planProgramActions = 400 + // planProgramHead is the most of any one text a page carries, in bytes. The // page draws the first line of what a program sent and of what the model // answered and never more, so the rest of each text would cross the wire on @@ -71,6 +82,17 @@ type PlanProgram struct { // and zero when the run set none or the program has not said hello yet — // which the page draws as no ceiling at all rather than as $0.00. CeilingUSD float64 + // Actions is what the program did: the newest [planProgramActions] lines of + // its action log, in the order they arrived, each as the program's own + // vocabulary reads it (delegate.Delegate.Reader) — the step of its process + // it served, the words, how it came out — and the lines it leaves out + // absent. Every text is cut to its head and the run's own copy is taken out + // of it, as the turns' are. Empty for a run from before the log existed, + // whose page is drawn from its turns. + Actions []delegate.Shown + // EarlierActions is how many actions came before the first of Actions, + // which the page says rather than draws. + EarlierActions int } // planProgramRecord is the program a task was handed to: the record the run's @@ -93,12 +115,13 @@ func planProgramRecord(dir, id, carried string) (delegate.ProgramRecord, bool) { return delegate.ProgramRecord{}, false } -// planProgramStage is the stage a program says it is in, read off its task's -// live step. The worker publishes a program's phase as the live step in the -// program's own words, `<name>: <stage> · <status>` (internal/run's -// delegateSink.Stage), so the stage is what is left without the name in front -// and without the status behind — `implement` — and nothing at all when no -// step is live, because a run whose program has ended is in no stage. +// planProgramStage is where a program says it is, read off its task's live +// step. The worker publishes it as the live step in the program's own words — +// the step of its process, `<name>: explore`, or before it has named one its +// stage's word, and for a program that gave no words `<name>: <stage> · +// <status>` (internal/run's delegateSink.live) — so the word is what is left +// without the name in front and without a status behind, and nothing at all +// when no step is live, because a run whose program has ended is in no step. // // IT IS READ FOR A PROGRAM'S TASK AND FOR NO OTHER. A live step of any other // task is a command a worker is running, and a command is not a stage. @@ -164,13 +187,16 @@ func (a *Agent) planRootIsProgram(store *plandb.Store, rootID string) bool { // skips a line cut mid-write; anything worse leaves the page with its brief and // its pinned line, which is still the truth about a run that has said nothing // this page can read. -func planProgramPage(dir, id, carried string, copies planRunCopies) *PlanProgram { +func planProgramPage(dir, id, carried string, copies planRunCopies, programs []delegate.Delegate) *PlanProgram { record, known := planProgramRecord(dir, id, carried) - all, _ := delegate.ReadTurns(plandb.TaskDir(dir, id), 0) - if !known && len(all) == 0 { + taskDir := plandb.TaskDir(dir, id) + all, _ := delegate.ReadTurns(taskDir, 0) + logged, _ := delegate.ReadActions(taskDir, 0) + if !known && len(all) == 0 && len(logged) == 0 { return nil } program := &PlanProgram{Name: record.Name, CeilingUSD: record.CeilingUSD} + program.Actions, program.EarlierActions = planProgramActionsFor(logged, planProgramOf(programs, record.Name), copies) if len(record.Stages) > 0 { program.Stages = append([]string(nil), record.Stages...) } @@ -193,6 +219,52 @@ func planProgramPage(dir, id, carried string, copies planRunCopies) *PlanProgram return program } +// planProgramOf is the program of this build's list with this name, and a +// program of that name with no vocabulary of its own — read plainly — when the +// list does not carry it: a run of a program this build no longer carries is +// still a run whose page draws what it did. +func planProgramOf(programs []delegate.Delegate, name string) delegate.Delegate { + for _, program := range programs { + if program.Name == name { + return program + } + } + return delegate.Delegate{Name: name} +} + +// planProgramActionsFor is a program's action log as its page carries it: every +// line read, from the first, by one reader of the program's own vocabulary — +// which may need the lines before to read the one in front of it — with the +// run's copy taken out of what it names; the newest [planProgramActions] of +// what it shows, every text cut to its head; and how many shown actions came +// before those. +func planProgramActionsFor(logged []delegate.Action, program delegate.Delegate, copies planRunCopies) ([]delegate.Shown, int) { + read := program.Reader() + var shown []delegate.Shown + for _, action := range logged { + action.Command = copies.strip(action.Command) + action.Observation = copies.strip(action.Observation) + action.Message = copies.strip(action.Message) + line, ok := read(action) + if !ok { + continue + } + line.Text = planTextHead(copies.strip(line.Text)) + line.Outcome = planTextHead(line.Outcome) + line.Reason = planTextHead(line.Reason) + if line.Text == "" { + continue + } + shown = append(shown, line) + } + earlier := 0 + if len(shown) > planProgramActions { + earlier = len(shown) - planProgramActions + shown = shown[earlier:] + } + return shown, earlier +} + // planTurnForPage is one turn as a page carries it: every text cut to its head // and the run's own copy taken out of it, and every other field — the call's // clock, its models, its size and price, whether it was refused or failed — diff --git a/internal/session/plandb_program_test.go b/internal/session/plandb_program_test.go index bf9bfe5c6..32174023a 100644 --- a/internal/session/plandb_program_test.go +++ b/internal/session/plandb_program_test.go @@ -311,3 +311,82 @@ func TestStripTakesOnlyTheRunsCopyOut(t *testing.T) { } } } + +// A PROGRAM'S PAGE CARRIES WHAT IT DID, IN ITS OWN WORDS. Every line of the +// task's action log is read, in the order it arrived, by the program's own +// reader of it — which is handed the lines with the run's copy taken out of +// what they name — and what it shows is carried with its moment, its step's +// word, its head and its outcome; what it leaves out is absent. A program this +// build does not carry is read plainly, so its page still says what it did. +func TestAProgramsPageCarriesItsActionsInItsOwnWords(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + place := t.TempDir() + var seen []string + own := delegate.Delegate{Name: "senior-dev", Present: func() delegate.ActionReader { + return func(action delegate.Action) (delegate.Shown, bool) { + seen = append(seen, action.Command) + if action.Kind == delegate.ActionStage { + return delegate.Shown{}, false + } + return delegate.Shown{Step: "explore", Text: "ran " + action.Command + "\nand a second line", Outcome: delegate.ExitWord(action.Exit)}, true + } + }} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = Place{Dir: place} + c.Delegates = []delegate.Delegate{own} + }) + armPlanStore(t, agent, path, "chat-a") + copyDir := filepath.Join(agent.treesDir(), "7") + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + exit := 1 + for _, action := range []delegate.Action{ + delegate.StageAction(began, delegate.StageRecord{Stage: "run-contract", Status: "ready"}), + delegate.StepAction(began.Add(time.Second), delegate.StepRecord{Command: "go test " + copyDir + "/internal/auth", Tool: "bash", Step: "explore", Exit: &exit}), + } { + if err := delegate.AppendAction(folder, action); err != nil { + t.Fatal(err) + } + } + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil { + t.Fatal("the program's task answered no program page") + } + if len(page.Program.Actions) != 1 || page.Program.EarlierActions != 0 { + t.Fatalf("actions = %+v, want the one step its reader showed", page.Program.Actions) + } + got := page.Program.Actions[0] + want := delegate.Shown{At: began.Add(time.Second), Step: "explore", Text: "ran go test internal/auth", Outcome: "fails · exit 1"} + if got != want { + t.Fatalf("the action = %+v, want %+v", got, want) + } + if len(seen) != 2 || strings.Contains(seen[1], copyDir) { + t.Fatalf("the reader was handed %q, want both lines with the copy taken out", seen) + } + + // A PROGRAM THIS BUILD DOES NOT CARRY IS READ PLAINLY. + plain := planProgramOf(nil, "senior-dev") + shown, _ := planProgramActionsFor([]delegate.Action{delegate.StageAction(began, delegate.StageRecord{Stage: "intake", Status: "captured"})}, plain, planRunCopies{}) + if len(shown) != 1 || shown[0].Text != "intake · captured" { + t.Fatalf("a program with no vocabulary read %+v, want its stage and status", shown) + } +} + +// A LONG RUN'S PAGE CARRIES ITS NEWEST ACTIONS AND COUNTS THE REST, as it does +// its calls. +func TestAProgramsPageCarriesTheNewestActionsAndCountsTheRest(t *testing.T) { + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + var logged []delegate.Action + for i := 0; i < planProgramActions+5; i++ { + logged = append(logged, delegate.StepAction(began.Add(time.Duration(i)*time.Second), delegate.StepRecord{Command: "bash: step " + itoa(i)})) + } + shown, earlier := planProgramActionsFor(logged, planProgramOf(nil, "senior-dev"), planRunCopies{}) + if len(shown) != planProgramActions || earlier != 5 || shown[0].Text != "bash: step 5" { + t.Fatalf("carried %d actions from %q with %d earlier, want the newest %d and 5 earlier", len(shown), shown[0].Text, earlier, planProgramActions) + } +} diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index eb0214431..341d899b5 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -84,9 +84,10 @@ type PlanTaskRow struct { // Program is the name of the program this task was handed to — senior-dev — // read off the program record in the task's own record folder // ([planProgramRecord]), and empty for every task a worker of this - // conversation's own drives. Stage is the stage that program says it is in - // right now, its live step read without its name in front - // ([planProgramStage]), and empty whenever nothing is live. The rail draws + // conversation's own drives. Stage is the word for where that program says + // it is right now — the step of its own process, `explore`, or before it + // has named one its stage's word — its live step read without its name in + // front ([planProgramStage]), and empty whenever nothing is live. The rail draws // both under the run's own row, where a program's run used to wear only its // clock. Program string @@ -339,7 +340,7 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { Live: pageRow.Live, Children: children, WaitRows: waitRows, - Program: planProgramPage(dir, task.ID, carried[task.ID], copies.or(pageRow.Folder)), + Program: planProgramPage(dir, task.ID, carried[task.ID], copies.or(pageRow.Folder), a.config.Delegates), }, true } From e574ecceb2ff6fafc46dc8a5de822b9b7e847cdd Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:13:50 -0400 Subject: [PATCH 111/195] tui3, seniordev, manual: senior-dev's page shows the actions it took, step by step, with its raw calls one key away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program's task page drew senior-dev's work as a dialogue between it and a model, which made its pipeline — spec, exploration, a pinned check, a checklist, the change, the hand-in and its own check of the tree — read like one chat. The page now draws the actions senior-dev took, each under the step of its process it served (a bold step word down the left, printed once per run of that step), with how each came out at the right edge, merged by time with what only the calls know: a compaction, a switch of model with its reason, a refused or failed call. senior-dev steering its own model is drawn as such; no model is named elsewhere; `◐ thinking` is the call in flight; a run from before the action log is drawn from its calls. `ctrl+y` turns the page to the old dialogue of raw calls and back, in the room and on the stored page alike, and the key rows say so. senior-dev also reports when its own check starts, so the row reads `verify` from the first command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 10 +- internal/manual/chat/keys.md | 21 +- internal/manual/chat/senior-dev.md | 63 +- internal/manual/chat/tasks.md | 5 +- internal/manual/chat/worker-harness.md | 94 ++- internal/manual/chat_test.go | 7 + internal/seniordev/actions.go | 12 +- internal/seniordev/actions_test.go | 4 +- .../seniordev/app/full_verification_run.go | 6 + .../seniordev/app/full_verification_test.go | 4 +- internal/seniordev/seniordev.go | 11 +- internal/tui3/place_sessions.go | 7 +- internal/tui3/programcalls.go | 382 +++++++++ internal/tui3/programroom.go | 24 +- internal/tui3/programroom_test.go | 32 +- internal/tui3/programtab_test.go | 8 +- internal/tui3/room.go | 8 + internal/tui3/taskconversation.go | 732 ++++++++++-------- internal/tui3/taskconversation_test.go | 385 ++++++--- internal/tui3/taskplan.go | 15 +- 20 files changed, 1292 insertions(+), 538 deletions(-) create mode 100644 internal/tui3/programcalls.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 60a39ba92..0b2931108 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -16,10 +16,12 @@ none of them runs on its own outside codeaf. Each is a command in the chat, `/<n **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 shown as one turn of a conversation on the -run's task page, which opens inside the conversation's own tab like any task's. When your -services cannot serve the model the program asks for, the run's own work model answers, -and the page names the model that did. +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. 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. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 7213acc59..cae81bcf3 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2465,6 +2465,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 @@ -2530,6 +2536,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 in the conversation's tab and on its page in 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: @@ -3099,7 +3117,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, in its room or in the tasks place — 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+<digit>` | **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+7` (`opt+1` … `opt+7` 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+7` reach the same seven places. The map's line says `alt+1…7 or ctrl+1…7 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+<digit>` | | `alt+<letter>` | 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+<letter>` does nothing | diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 166555fb2..b1c8993e3 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -18,32 +18,58 @@ Use it for one change big enough to want an agent of its own for an hour, and sp well enough that nobody will be asked anything: a rewrite across a package, a migration, a feature with its tests. A change you would make in a few steps is not worth it. -## Watching senior-dev work — open its task, its conversation with codeaf, how long it has run, stop it +## 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 with the stage it is in and what it has spent so far, and a card in the conversation +list with the step it is in and what it has spent so far, 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 `Home` beside it. senior-dev gets no tab of its own. -The task shows senior-dev's conversation with codeaf: its brief, each model call with -what senior-dev sent and what the model answered, and the call in flight. The line over -it pins the stage, the spend of the run's ceiling, the number of 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 task shows **what senior-dev is doing**, action by action, each under the step of its +process it served — its brief, 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. The line over it pins 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 stage is said in plain words.** On the row and on that line senior-dev's stage -reads `starting`, `reading the brief`, `working`, `handing in its work`, `checking its -work` or `finishing` — never senior-dev's own names for its inner phases. The whole of its -work on the change, every model call and tool included, reads `working`; the build and -tests it runs at the end, and a last turn to leave its work in a state that stands, read -`checking its work`. +**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 `Home` 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 <model>` +with its reason. + ## 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 @@ -236,8 +262,8 @@ and never more than a quarter of it. When that window opens it gets one last tur 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 -conversation on the task page names the model that answered. When nothing here can serve +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 @@ -442,9 +468,10 @@ own, under its own task number, with its own brief and its own page. The old run stays as the record of what it did. 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. Its `agent-summary` there +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 stages and when it -started and ended — is kept in a folder of its own under +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/tasks.md b/internal/manual/chat/tasks.md index c9def9e9f..c94874681 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5585,5 +5585,6 @@ 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 its conversation with -codeaf, and its row already says its stage, so it has no four lines. +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 d2c74af59..46f11827c 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -200,7 +200,7 @@ steps When the command ends the store clears the live step and the next read draws it as an ordinary step, with its number and the head of what came back. -## A program's task page is a conversation, not steps — a delegate's page: open it, leave it, no tab of its own, no note box, what the box says +## 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 (`/<name> <brief>`, such as `/senior-dev`) opens **inside the conversation's own tab**, as any task does: from its row on the side @@ -210,58 +210,72 @@ it, and the program gets no tab of its own. ``` the run ▸ rewrite the auth middleware esc/← main -─ working · $1.24 of $5.00 · 3 calls · 14m 3s ────────────────────── Stop ─ - <program> rewrite the auth middleware to use the new session store - deepseek-v4-flash I'll read the middleware and the store first. - ▤ read internal/auth/middleware.go - <program> read: package auth - ◐ deepseek-v4-flash · 12s +─ 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 `Home` leave it; none of them -stops the run. `ctrl+o` opens and folds a long brief. `x` over an empty box, `/stop`, or -`Stop` at the end of the line over the conversation asks `Stop this task?` and ends the -whole run. +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 `<program> 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 conversation as a page -of that place, with no box at all. - -## Reading a program's conversation — what the program sent, what the model answered, the call in flight, how long it has run - -Every model call a program makes goes through codeaf, so its task's page is that -conversation: the program on one side, like a very particular person asking codeaf -things, and the model that answered on the other. - -The line over the conversation stays put while you scroll: the stage the program says it -is in, in the word the program gives a person for it rather than its own name for the -stage (the task's own word, such as `running` or `done`, when there is none), 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 is the one the side list and the landed card -show for the run: it counts from the moment codeaf handed the work over, and once the run -has ended it is the whole span, up to the moment the program's own process ended. It stops -there as soon as that process ends, while codeaf is still landing the work. A run -nothing is driving any more, because codeaf closed while the program worked, reads -`incomplete` with its time stopped at the last thing it did. On a tall window with the side list open, the line sits -beside the task's title instead. - -The conversation opens on the brief. Each call is the program's side — a tool's result as +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 <model>` when another model started +answering its work (with the program's reason after it when it gave one), `codeaf +refused a call · <why>` and `a call to its model failed · <why>`. 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`. + +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 `<tool>: <first line>`, 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 · <why>`; a failed one is `the call failed · <why>`. The call in flight is the -last line, `◐`, the model and its seconds, gone when the call returns. 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. - -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. On -the side list the run's row says the stage and the spend so far. +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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 6b2a48caf..1af8f0ddc 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -935,6 +935,11 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how long did the senior-dev run take", "senior-dev"}, {"senior-dev's page still says running after codeaf crashed", "senior-dev"}, {"can my other window see the senior-dev run", "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"}, {"which folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, @@ -2826,6 +2831,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, } for _, ask := range asked { found := Chat().Search(ask.question, DefaultResults) diff --git a/internal/seniordev/actions.go b/internal/seniordev/actions.go index d382d0af3..58d695c02 100644 --- a/internal/seniordev/actions.go +++ b/internal/seniordev/actions.go @@ -76,7 +76,7 @@ func presentActions() delegate.ActionReader { func nudged(attempt int) delegate.Shown { return delegate.Shown{ Step: stepWords[app.StepImplement], Steer: true, - Text: fmt.Sprintf("its model stopped without handing in; told it what it found and to finish (nudge %d)", attempt), + Text: fmt.Sprintf("told its model what it found, and to finish and hand in (nudge %d)", attempt), } } @@ -199,7 +199,13 @@ func patchedFile(tool, about string) string { func presentStage(stage, status string, facts stageFacts) (delegate.Shown, bool) { switch stage + "/" + status { case "bootstrap/ready": - return delegate.Shown{Step: setupWord, Text: "set up its workspace", Outcome: facts.text("recorder")}, true + // 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 @@ -251,6 +257,8 @@ func presentStage(stage, status string, facts stageFacts) (delegate.Shown, bool) 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 { diff --git a/internal/seniordev/actions_test.go b/internal/seniordev/actions_test.go index 6a8cdf2b4..dacfd2560 100644 --- a/internal/seniordev/actions_test.go +++ b/internal/seniordev/actions_test.go @@ -94,6 +94,7 @@ func TestARunReadsAsSeniorDevsActionsUnderItsSteps(t *testing.T) { 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}), @@ -130,10 +131,11 @@ func TestARunReadsAsSeniorDevsActionsUnderItsSteps(t *testing.T) { {"implement", "edited internal/auth/middleware.go", ""}, {"", "compacted its memory", ""}, {"", "switched to two", ""}, - {"implement", "its model stopped without handing in; told it what it found and to finish (nudge 1)", ""}, + {"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"}, diff --git a/internal/seniordev/app/full_verification_run.go b/internal/seniordev/app/full_verification_run.go index b9c19e7b6..0388315a2 100644 --- a/internal/seniordev/app/full_verification_run.go +++ b/internal/seniordev/app/full_verification_run.go @@ -58,6 +58,12 @@ func newProjectVerificationRun(runner *pipeline, ctx context.Context) *projectVe } 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) diff --git a/internal/seniordev/app/full_verification_test.go b/internal/seniordev/app/full_verification_test.go index 161de53b8..7eb8bbe38 100644 --- a/internal/seniordev/app/full_verification_test.go +++ b/internal/seniordev/app/full_verification_test.go @@ -215,7 +215,7 @@ func TestEachVerificationCommandIsReportedAsAVerifyStep(t *testing.T) { if !failed || verification.Failed == nil { t.Fatalf("the failing build is not a verify step with its exit and tail: %+v", host.stepRecords) } - if last := host.stages[len(host.stages)-1]; last != "verification/fail" { - t.Fatalf("the last stage is %q, want the verification's own result after its steps", last) + 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/seniordev.go b/internal/seniordev/seniordev.go index 7dd167489..3d75b0ce7 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -80,13 +80,16 @@ var Program = delegate.Delegate{ // conversation (app's seniorDevDataDirectory, which git never sees). Notes: ".senior-dev", CrewFlags: crewFlags, - // What a person reads while it works, one plain word per phase: getting + // What a person reads for its stages, one plain word per phase: getting // ready, doing the work (every inner stage of a model turn included), - // handing it in, checking it, wrapping up. A test holds every stage in - // app.Stages to a word. + // handing it in, checking it, wrapping up. Its task's row reads them only + // until a record has named a step of its process — which its first, + // `bootstrap`, already does — so they are the words for a run whose records + // say no step. A test holds every stage in app.Stages to a word. StageWords: stageWords, // What each line of its action log reads as on its task's page, under the - // step of its process it served (actions.go). + // step of its process it served, and the step's word its task's row reads + // while it is in it (actions.go). Present: presentActions, Default: "run", Page: "senior-dev", diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index f592beb70..c9b4b9fc4 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -109,8 +109,11 @@ type tasksPlace struct { plan session.PlanTaskPage planOn bool planBriefFull bool - planAt int - planBack []session.PlanTaskPage + // planCalls says a program's page shows its raw calls instead of its + // actions ([programCallsKey]); every page opens on the actions. + planCalls bool + planAt int + planBack []session.PlanTaskPage // planNote is the note a person types on a plan task's page, and it is the // [editor] every other box on this surface is rather than a string of its own // (the filter is one, and so is the conversation's composer). Typing on the diff --git a/internal/tui3/programcalls.go b/internal/tui3/programcalls.go new file mode 100644 index 000000000..d88e54c49 --- /dev/null +++ b/internal/tui3/programcalls.go @@ -0,0 +1,382 @@ +package tui3 + +// programcalls.go draws a PROGRAM'S RAW CALLS: the dialogue between the program +// and the model that answered it, one call at a time, which is what a program's +// page used to open on. It opens on the program's actions now +// (taskconversation.go), and this is one key away ([programCallsKey]) — for a +// person debugging what the model was actually sent and what it said. +// +// senior-dev rewrite the auth middleware to use the new session store +// deepseek-v4-flash I'll read the middleware first. +// ▤ read internal/auth/middleware.go +// senior-dev read: package auth +// ◐ deepseek-v4-flash · 12s +// +// THE PAGE READS NOTHING, here as on the actions: every line is drawn from the +// page the surface already holds, with the frame's own clock for the call in +// flight. +// +// WHAT IS DRAWN IS THE PERSON'S, NEVER THE MACHINERY'S. A program's system +// prompt and the model's own words handed back to it are part of every call and +// say nothing new, so neither is ever a row; a program that summarized its own +// history is said in one line, not replayed. + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// programCallsKey turns a program's page between its actions and its raw +// calls, and back — on the stored page in the tasks place and in the program's +// room alike, named ONCE so both key rows and the manual say the one chord. +// +// WHY THIS ONE. The room has a message box, so the key must be a chord that +// types no character; the stored page takes the reading keys and `ctrl+o`; and +// every chord the conversation already spends is spent (keys.md's table). ctrl+y +// means nothing in either surface — its only other binding is the `/files` +// list's copy, a different page with a different keyboard — and it is a plain +// control byte every terminal delivers. +const programCallsKey = "ctrl+y" + +// The key row's words for it: what the next press shows. +const ( + programCallsWord = programCallsKey + " calls" + programActionsWord = programCallsKey + " actions" +) + +// programCallsHint is the key row's clause for a program's page: the view the +// next press of [programCallsKey] turns to. +func programCallsHint(calls bool) string { + if calls { + return programActionsWord + } + return programCallsWord +} + +const ( + // convSaidMost is how many of the program's messages one of its turns draws. + // A turn that answers eight tool calls sends eight results, and the eight + // calls are already drawn one row each on the model's side just above it. + convSaidMost = 3 + // convCodeaf is who answers a call codeaf refused. No model saw it, so the + // line under the program's is codeaf's own. + convCodeaf = "codeaf" +) + +// The words this page says in its own voice, each quoted in the manual as it is +// spelled here (worker-harness.md). +const ( + // convRestartedWord is the program's side of a call made after it rewrote its + // own history as a summary: what it sent is its whole history again, and that + // is one sentence rather than a replay. + convRestartedWord = "summarized its history so far" + // convFailedWord leads the line a call the model's side failed draws. + convFailedWord = "the call failed" + // convEarlierWord follows the count of calls the page does not carry. + convEarlierWord = "earlier calls" +) + +// convSide is one speaker's turn at talking: the name in the column and the +// lines it said, each already painted and not yet fitted. +type convSide struct { + name string + lines []convLine +} + +// convLine is one line a speaker said. lead is a painted mark drawn in front of +// the words and outside their fitting, so a narrow row gives up the tail of the +// words and never half a mark; text is the words, painted by ink. +type convLine struct { + lead string + text string + ink func(string) string +} + +// programCalls is a program's page as the raw calls it made: the brief the +// program was handed, then every call, each as the program's side and the side +// that answered it. It is one key away from the actions the page opens on +// ([programCallsKey]), for a person who wants to see what the model was sent +// and what it said. +// +// THE NAMES STAND IN A COLUMN OF THEIR OWN while the frame has the room, so the +// eye reads down the speakers and across to what each said; under +// [convTextLeast] cells of words each name stands on its own line instead. The +// column is as wide as the widest name on the page, so it does not move as the +// conversation grows by a call from the same model. +func (a *app) programCalls(page session.PlanTaskPage, width int, briefFull bool) []string { + if width < 1 { + return nil + } + program := convProgramOf(page) + pal := a.pal + speaker := convProgramName(page) + running := planStateWord(page.Row) == "running" + column, text := convColumns(convNames(program, speaker), width) + + var out []string + // THE BRIEF OPENS THE CONVERSATION. It is what the program was handed, in + // the person's own words, and it stands for the program's side of the first + // call — whose own words are the program's prompt around the same brief. + // Folded to the brief's own three lines, with the key that unfolds it, the + // way every other page folds a brief. + opening := convSide{name: speaker} + for _, line := range taskConversationBrief(page, text, briefFull) { + opening.lines = append(opening.lines, convLine{text: line, ink: pal.ink}) + } + if len(opening.lines) > 0 { + out = append(out, convDraw(opening, column, width, pal)...) + } + // THE CALLS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, never in + // a speaker's column, where the count read as something the program said. It + // is spelled the way every fold line on this surface is ([bandFoldWord]). + if program.Earlier > 0 { + out = append(out, pal.dim(fit(glyphMore+itoa(program.Earlier)+" "+convEarlierWord, width))) + } + briefHead := convBriefHead(page.Description) + for i, turn := range program.Turns { + first := i == 0 && program.Earlier == 0 + if said := a.convProgramSide(turn, speaker, briefHead, first); len(said.lines) > 0 { + out = append(out, convDraw(said, column, width, pal)...) + } + switch { + case convHead(turn.Refused) != "": + out = append(out, convDraw(convSide{name: convCodeaf, lines: []convLine{{ + text: taskPlanRefusedWord + railSep + convHead(turn.Refused), ink: pal.dim, + }}}, column, width, pal)...) + case convHead(turn.Failed) != "": + out = append(out, convDraw(convSide{name: convModelWord(turn), lines: []convLine{{ + text: convFailedWord + railSep + convHead(turn.Failed), ink: pal.dim, + }}}, column, width, pal)...) + case turn.InFlight(): + // THE CALL IN FLIGHT IS THE LIVE EDGE, and it is drawn only while the + // task can still be waiting on it. A call whose ending never reached the + // log before the run ended is not in flight on a page about work that + // is over: it draws no line at all rather than a clock that never stops. + if running { + if line := a.convInFlight(turn, width); line != "" { + out = append(out, line) + } + } + default: + if answer := a.convModelSide(turn); len(answer.lines) > 0 { + out = append(out, convDraw(answer, column, width, pal)...) + } + } + } + return out +} + +// convProgramSide is what the program said on one call, in the lines a person +// reads for it: nothing new on the first call, whose words the brief above +// already stands for; one sentence on a call made after it summarized its own +// history; and otherwise its newest messages, each as its first line — a tool's +// result as `<tool>: <line>` and its own words as they were. +// +// NEITHER A PROMPT NOR AN ECHO IS A ROW. A `system` message is the program +// instructing its model, and an `assistant` one is the model's last answer +// handed back to it, which the model's own side has already drawn; and a +// message that is the brief again says nothing the opening has not. +func (a *app) convProgramSide(turn delegate.Turn, speaker, briefHead string, first bool) convSide { + pal := a.pal + side := convSide{name: speaker} + if turn.Restarted { + side.lines = append(side.lines, convLine{text: convRestartedWord, ink: pal.dim}) + return side + } + if first { + return side + } + var said []string + for _, message := range turn.Sent { + line := convHead(message.Text) + switch strings.TrimSpace(message.Role) { + case "system", "assistant": + continue + case "tool": + if tool := strings.TrimSpace(message.Tool); tool != "" { + line = tool + ": " + line + } + default: + if convRepeatsBrief(line, briefHead) { + continue + } + } + if strings.TrimSpace(line) != "" { + said = append(said, line) + } + } + shown := said + if len(shown) > convSaidMost { + shown = shown[:convSaidMost] + } + for _, line := range shown { + side.lines = append(side.lines, convLine{text: line, ink: pal.dim}) + } + if more := len(said) - len(shown); more > 0 { + side.lines = append(side.lines, convLine{text: "+" + itoa(more) + " more", ink: pal.dim}) + } + return side +} + +// convModelSide is what the model answered on one call: the first line of its +// words, and every tool it asked the program to run, one dim row each behind +// that tool's action mark — the same family marks the conversation's own steps +// wear ([app.actionMarkFor]), so a person who has learned `✎` for an edit there +// reads it here. +func (a *app) convModelSide(turn delegate.Turn) convSide { + pal := a.pal + side := convSide{name: convModelWord(turn)} + if reply := convHead(turn.Reply); reply != "" { + side.lines = append(side.lines, convLine{text: reply, ink: pal.ink}) + } + for _, call := range turn.Calls { + name := convHead(call.Name) + if name == "" { + continue + } + words := name + if about := convHead(convCallAbout(call.Args)); about != "" { + words += " " + about + } + mark := a.actionMarkFor(session.ActionCategoryForTool(name)) + side.lines = append(side.lines, convLine{lead: pal.dim(mark) + " ", text: words, ink: pal.dim}) + } + return side +} + +// convInFlight is the call in flight: the running mark, the model it went to, +// and how long it has been out — one line, the last on the page, gone the moment +// its ending reaches the log. The mark comes off the vocabulary's own door, so +// the line gets this terminal's repertoire; the clock is the frame's and says +// nothing for the call's first second. +func (a *app) convInFlight(turn delegate.Turn, width int) string { + mark := a.icon(tokens.GStepRunning) + room := width - ansi.StringWidth(mark) - 1 + if room < 1 { + return "" + } + var words []string + if model := convModelWordOf(turn.Model); model != "" { + words = append(words, model) + } + if !turn.Started.IsZero() { + if clock := countUpWord(a.now().Sub(turn.Started)); clock != "" { + words = append(words, clock) + } + } + return a.pal.ink(mark) + " " + a.pal.dim(fit(strings.Join(words, railSep), room)) +} + +// convDraw lays one side out: its name in the column on its first line and its +// words beside it, or — where the frame is too narrow for a column — its name +// on a line of its own and its words hung under it. EVERY ROW IS FITTED TO THE +// WIDTH: the name is cut in the middle when it must be ([rowTrim]), a mark in +// front of the words is kept whole, and the words give up their tail. +func convDraw(side convSide, column, width int, pal palette) []string { + var out []string + if column == 0 { + if name := strings.TrimSpace(side.name); name != "" { + label, _ := rowTrim(name, width, false) + out = append(out, pal.muted(label)) + } + indent := strings.Repeat(" ", convIndent) + for _, line := range side.lines { + out = append(out, indent+convWords(line, width-convIndent)) + } + return out + } + gap := strings.Repeat(" ", convGap) + blank := strings.Repeat(" ", column) + for i, line := range side.lines { + cell := blank + if i == 0 && strings.TrimSpace(side.name) != "" { + label, _ := rowTrim(side.name, column, false) + cell = padTo(pal.muted(label), column) + } + out = append(out, cell+gap+convWords(line, width-column-convGap)) + } + return out +} + +// convWords is one line's words at their width, behind its mark when it has +// one. A width too small to hold the mark draws the words alone. +func convWords(line convLine, width int) string { + if width < 1 { + return "" + } + ink := line.ink + if ink == nil { + ink = func(s string) string { return s } + } + lead := line.lead + if lead != "" { + if cells := ansi.StringWidth(ansi.Strip(lead)); cells < width { + return lead + ink(fit(line.text, width-cells)) + } + } + return ink(fit(line.text, width)) +} + +// convColumns decides the page's two widths from the names on it: the column +// the names stand in, and the room their words get beside it. A column of zero +// is the narrow layout, where every name stands on its own line and the words +// hang [convIndent] cells under it. +func convColumns(names []string, width int) (int, int) { + widest := 0 + for _, name := range names { + if cells := ansi.StringWidth(strings.TrimSpace(name)); cells > widest { + widest = cells + } + } + column := widest + if column > convLabelMost { + column = convLabelMost + } + if third := width / 3; column > third { + column = third + } + if column < 1 || width-column-convGap < convTextLeast { + return 0, width - convIndent + } + return column, width - column - convGap +} + +// convNames is every name the page will draw in its column: the program's, +// codeaf's when a call was refused, and the model of every call on the page. +func convNames(program *session.PlanProgram, speaker string) []string { + names := []string{speaker} + for _, turn := range program.Turns { + if strings.TrimSpace(turn.Refused) != "" { + names = append(names, convCodeaf) + continue + } + if !turn.InFlight() { + names = append(names, convModelWord(turn)) + } + } + return names +} + +// convBriefHead is the brief's first line as a program's own message would +// carry it, so a message that is the brief again can be told from one that +// says something new. +func convBriefHead(description string) string { return convHead(description) } + +// convRepeatsBrief reports whether a message's first line is the brief's again. +// The page carries a message's head cut at a couple of hundred bytes with the +// cut marked, so a long brief repeated is the brief's own line up to that mark. +func convRepeatsBrief(line, briefHead string) bool { + if line == "" || briefHead == "" { + return false + } + if line == briefHead { + return true + } + cut := strings.TrimSuffix(line, glyphMore) + return cut != line && cut != "" && strings.HasPrefix(briefHead, cut) +} diff --git a/internal/tui3/programroom.go b/internal/tui3/programroom.go index a913291fc..01fd89358 100644 --- a/internal/tui3/programroom.go +++ b/internal/tui3/programroom.go @@ -48,8 +48,11 @@ type programRoom struct { readAt time.Time reading bool briefFull bool - inner int - said []string + // calls says the room shows the program's raw calls instead of its actions + // ([programCallsKey]); a room opens on the actions. + calls bool + inner int + said []string } // programRoomRefusal is what a program's room says about its box: the fact, and @@ -268,7 +271,7 @@ func (a *app) programRoomRows(width int) []row { p.inner = inner pal := a.pal var out []row - for _, line := range a.programBody(p.page, inner, p.briefFull) { + for _, line := range a.programBody(p.page, inner, p.briefFull, p.calls) { out = append(out, row{text: line, entry: -1}) } if len(p.said) > 0 { @@ -397,9 +400,11 @@ func (a *app) programStopTarget() stopTarget { } // programRoomKey is what a program's room takes before the room's own keys: -// `ctrl+o` folds and unfolds the brief when it is long enough to fold, and the -// thinking chord is taken and does nothing, because a program's run has no -// thinking level this surface can move. Everything else is the room's. +// `ctrl+o` folds and unfolds the brief when it is long enough to fold, +// [programCallsKey] turns the page between the program's actions and its raw +// calls, and the thinking chord is taken and does nothing, because a program's +// run has no thinking level this surface can move. Everything else is the +// room's. func (a *app) programRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { p := a.programOf() if p == nil { @@ -411,13 +416,18 @@ func (a *app) programRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if width <= 0 { width = gutterInner(a.bodyWidth()) } - if !convBriefFolds(p.page, width) { + if !convBriefFolds(p.page, width, p.calls) { return nil, false } p.briefFull = !p.briefFull a.room.dirty = true a.touch() return nil, true + case programCallsKey: + p.calls = !p.calls + a.room.dirty = true + a.touch() + return nil, true case effortKey: return nil, true } diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go index d7759c493..19aac9b3f 100644 --- a/internal/tui3/programroom_test.go +++ b/internal/tui3/programroom_test.go @@ -272,14 +272,15 @@ func TestStopOnAProgramsRoomRaisesThePlanCard(t *testing.T) { } // THE ROOM AT A PHONE'S WIDTH keeps the conversation's strip and the trail, its -// facts row keeps the stage and the spend, the conversation stands each name -// on a line of its own, and no row of the frame is wider than the frame. +// facts row keeps the step and the spend, the actions stand each step's word on +// a line of its own with its actions hung under it, and no row of the frame is +// wider than the frame. func TestAProgramsRoomAtFortyFourColumns(t *testing.T) { a, _ := programRoomApp(t, 44, 30) openProgramRoomNow(t, a) frame, _, _ := a.frame() text := plain(frame) - for _, want := range []string{"Home", "the run", "implement · $1.24", "senior-dev", "I'll read the middleware"} { + for _, want := range []string{"Home", "the run", "implement · $1.24", "IMPLEMENT", "edited internal/auth/"} { if !strings.Contains(text, want) { t.Fatalf("the room at 44 columns lost %q:\n%s", want, text) } @@ -372,3 +373,28 @@ func TestAProgramRoomsClockStopsAtTheProgramsExit(t *testing.T) { t.Fatalf("a running program's page reads %q, want 21m 5s", got) } } + +// THE ROOM TURNS TO THE RAW CALLS AND BACK ON ONE KEY, and its key row says +// which: the calls while the room shows the actions, and the actions while it +// shows the calls — beside the stop while there is work to stop. +func TestAProgramRoomTurnsToItsRawCallsAndBack(t *testing.T) { + a, _ := programRoomApp(t, 120, 30) + openProgramRoomNow(t, a) + if hint := a.roomHint(); hint != roomStopHint+railSep+programCallsWord { + t.Fatalf("the room's key row reads %q, want the stop and the calls", hint) + } + if text := roomText(a); strings.Contains(text, "I'll read the middleware") || !strings.Contains(text, programTabSaid) { + t.Fatalf("the room does not open on the actions:\n%s", text) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") || !strings.Contains(text, "deepseek-v4-flash") { + t.Fatalf("the key did not turn the room to its calls:\n%s", text) + } + if hint := a.roomHint(); !strings.HasSuffix(hint, programActionsWord) { + t.Fatalf("the room's key row reads %q, want the way back to the actions", hint) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); strings.Contains(text, "I'll read the middleware") || !strings.Contains(text, programTabSaid) { + t.Fatalf("the key did not turn the room back to its actions:\n%s", text) + } +} diff --git a/internal/tui3/programtab_test.go b/internal/tui3/programtab_test.go index 49a9e4709..85d1f800e 100644 --- a/internal/tui3/programtab_test.go +++ b/internal/tui3/programtab_test.go @@ -18,10 +18,10 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -// programTabSaid is a line only the program's conversation draws: the model's -// first answer in [programTurns]. Its presence on the frame is the program's -// page being on screen. -const programTabSaid = "I'll read the middleware and the store first." +// programTabSaid is a line only the program's page draws: one of its actions in +// [programActions]. Its presence on the frame is the program's page being on +// screen. +const programTabSaid = "wrote your brief down as its spec" // newProgramTabLab is a window in one conversation, "the run", that handed a // task to senior-dev: the task is node 7 of its graph and the run's root in diff --git a/internal/tui3/room.go b/internal/tui3/room.go index e24efa8ce..3fb73d210 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -2154,6 +2154,9 @@ func (a *app) roomHint() string { // card and never through the dismiss key (stop.go), so this is the key a // person reaching for esc actually wants. It is drawn only while there is // something to stop, which is the emptiness law applied to a hint. + if p := a.programOf(); p != nil { + return roomStopHint + railSep + programCallsHint(p.calls) + } return roomStopHint case a.roomLandingAsking(): // THE ROOM'S ANSWER TO "IT SAYS LOOK IT OVER, NOW WHAT". The node has @@ -2167,6 +2170,11 @@ func (a *app) roomHint() string { // question ([app.landingHintAt]). return a.landingHintAt(a.room.id, a.width, "") } + // A PROGRAM'S ROOM WITH NOTHING TO STOP still turns between its actions and + // its raw calls, and says the key that does it. + if p := a.programOf(); p != nil { + return programCallsHint(p.calls) + } return "" } diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go index 928697655..456083c57 100644 --- a/internal/tui3/taskconversation.go +++ b/internal/tui3/taskconversation.go @@ -1,22 +1,34 @@ package tui3 -// taskconversation.go draws a PROGRAM'S task page as the conversation it is. +// taskconversation.go draws a PROGRAM'S task page as the actions it took. // -// A task a run handed to a program codeaf carries (senior-dev first) used to -// open on the same page as every other task: a telemetry line, the brief, and a -// list of steps, with a box for a note the program would never read. What the -// program was actually doing was invisible — its work happens inside the calls -// it makes to a model, and every one of those goes through the model API codeaf -// serves the run. So codeaf has the whole exchange, and this page draws it: the -// program on one side, like a very particular person asking codeaf things, and -// the model that answered on the other, one call at a time, with the call in -// flight as the last line while it is out. +// A task a run handed to a program codeaf carries (senior-dev first) is a +// process with steps of its own, and the page shows the program doing it: each +// thing it did, under the step of its process that action served, with how it +// came out at the right edge — not a dialogue between the program and a model, +// which made a pipeline of spec, exploration, a pinned check, a checklist, an +// implementation, a hand-in and its own check of the tree read like one chat. // -// senior-dev rewrite the auth middleware to use the new session store -// deepseek-v4-flash I'll read the middleware first. -// ▤ read internal/auth/middleware.go -// senior-dev read: package auth -// ◐ deepseek-v4-flash · 12s +// 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 +// compacted its memory +// ◐ thinking · 12s +// +// THE ACTIONS ARE THE PROGRAM'S, IN ITS OWN WORDS. The run keeps every stage, +// step and ending the program reported, stamped as codeaf received it, and the +// program's own vocabulary reads them (internal/delegate's Present; senior-dev's +// is internal/seniordev's actions.go) before the page ever holds them. What only +// the program's calls to a model know is merged in by time: a history rewritten +// as a summary is `compacted its memory` (once, when the program said so too), a +// change of the model answering is `switched to <model>` with the program's +// reason when it gave one, and a call refused or failed is one plain line. A +// model is named nowhere else on the page; the cost and the clock stay on the +// pinned line. The raw calls are one key away (programcalls.go, +// [programCallsKey]). // // THE PAGE READS NOTHING. Every line here is drawn from the page the surface // already holds ([session.PlanTaskPage.Program], read off the loop on the page's @@ -25,13 +37,9 @@ package tui3 // with the run's copy taken out of their paths (internal/session's // plandb_program.go), so a row here is a choice of which line to show and never // a reading of the record. -// -// WHAT IS DRAWN IS THE PERSON'S, NEVER THE MACHINERY'S. A program's system -// prompt and the model's own words handed back to it are part of every call and -// say nothing new, so neither is ever a row; a program that summarized its own -// history is said in one line, not replayed. import ( + "sort" "strconv" "strings" "time" @@ -60,47 +68,11 @@ const ( // stand on lines of their own: the two-cell lead every piece of this // surface's machinery keeps. convIndent = 2 - // convSaidMost is how many of the program's messages one of its turns draws. - // A turn that answers eight tool calls sends eight results, and the eight - // calls are already drawn one row each on the model's side just above it. - convSaidMost = 3 - // convCodeaf is who answers a call codeaf refused. No model saw it, so the - // line under the program's is codeaf's own. - convCodeaf = "codeaf" // convProgramFallback names the program's side in the one case its name is // unknown: a conversation log with no program record beside it. convProgramFallback = "program" ) -// The words this page says in its own voice, each quoted in the manual as it is -// spelled here (worker-harness.md). -const ( - // convRestartedWord is the program's side of a call made after it rewrote its - // own history as a summary: what it sent is its whole history again, and that - // is one sentence rather than a replay. - convRestartedWord = "summarized its history so far" - // convFailedWord leads the line a call the model's side failed draws. - convFailedWord = "the call failed" - // convEarlierWord follows the count of calls the page does not carry. - convEarlierWord = "earlier calls" -) - -// convSide is one speaker's turn at talking: the name in the column and the -// lines it said, each already painted and not yet fitted. -type convSide struct { - name string - lines []convLine -} - -// convLine is one line a speaker said. lead is a painted mark drawn in front of -// the words and outside their fitting, so a narrow row gives up the tail of the -// words and never half a mark; text is the words, painted by ink. -type convLine struct { - lead string - text string - ink func(string) string -} - // taskPlanIsProgram reports whether the open stored page is a program's: its // read carries the program's conversation, or its row names the program, which // is how a read that came back for a side-list press is known to belong in the @@ -215,43 +187,51 @@ func (a *app) taskPlanAge(row session.PlanTaskRow) string { } // taskProgramBody is what a person reads on a program's page, under the pinned -// line: the conversation, and under it the notes the run left — its outcome and -// where its work went, which arrive when it ends and so belong at the bottom -// edge the page opens on, not above an hour of calls. +// line: the actions the program took, and under them the notes the run left — +// its outcome and where its work went, which arrive when it ends and so belong +// at the bottom edge the page opens on, not above an hour of work. // // EVERYTHING AN ORDINARY PAGE SPENDS ON ITS OWN MACHINERY IS ABSENT. The -// telemetry line is the pinned one; the brief opens the conversation; and a -// program's run records no steps worth a list of their own when the calls that -// did the work are on the page. A run whose conversation was never written — -// one from before the model API kept one — still has the steps its program -// reported, and draws them, so no page shows less than it did. +// telemetry line is the pinned one; the brief opens the actions; and a +// program's run needs no list of steps of its own when the actions that did +// the work are on the page. func (a *app) taskProgramBody(width int) []string { page, pal := a.taskSheet.plan, a.pal var out []string if n := len(a.taskSheet.planBack); n > 0 { out = append(out, pal.dim("esc/← "+a.taskSheet.planBack[n-1].Row.Title)) } - return append(out, a.programBody(page, width, a.taskSheet.planBriefFull)...) + return append(out, a.programBody(page, width, a.taskSheet.planBriefFull, a.taskSheet.planCalls)...) } // programBody is what both of a program's pages draw under their head — the // tasks place's stored page ([app.taskProgramBody]) and the program's room in -// the conversation's own tab (programroom.go): the conversation, the steps a -// run with no conversation reported, and the notes. briefFull is the page's -// own fold, because each page folds its brief with its own key. -func (a *app) programBody(page session.PlanTaskPage, width int, briefFull bool) []string { +// the conversation's own tab (programroom.go): the program's actions, or its +// raw calls when the page's own [programCallsKey] asked for them, and the +// notes. briefFull and calls are the page's own, because each page folds its +// brief and turns to its calls with its own keys. +// +// A RUN FROM BEFORE ITS CALLS WERE LOGGED still has the steps its program +// reported: the actions draw them in their own shape, and the calls, which +// have none to draw, list them under their own heading as they always did. +func (a *app) programBody(page session.PlanTaskPage, width int, briefFull, calls bool) []string { pal := a.pal - out := a.taskConversation(page, width, briefFull) - if len(convProgramOf(page).Turns) == 0 && len(page.Steps) > 0 { - out = append(out, "", pal.dim("steps")) - for _, step := range page.Steps { - if step.NotRun { - continue - } - if command := planDisplayCommand(step.Command, step.Parts); command != "" { - out = append(out, pal.ink(itoa(step.Step)+" "+command)) + var out []string + if calls { + out = a.programCalls(page, width, briefFull) + if len(convProgramOf(page).Turns) == 0 && len(page.Steps) > 0 { + out = append(out, "", pal.dim("steps")) + for _, step := range page.Steps { + if step.NotRun { + continue + } + if command := planDisplayCommand(step.Command, step.Parts); command != "" { + out = append(out, pal.ink(itoa(step.Step)+" "+command)) + } } } + } else { + out = a.taskConversation(page, width, briefFull) } if len(page.Notes) > 0 { if len(out) > 0 { @@ -263,202 +243,170 @@ func (a *app) programBody(page session.PlanTaskPage, width int, briefFull bool) return out } +// The words this page says in its own voice, each quoted in the manual as it is +// spelled here (senior-dev.md, worker-harness.md). +const ( + // actBriefWord leads the brief, in the column the step words stand in: the + // brief is what the program was handed, before any step of its own. + actBriefWord = "brief" + // actCompactedWord is a program's history rewritten as a summary, said once. + actCompactedWord = "compacted its memory" + // actSwitchedWord leads the line a change of the model answering draws. + actSwitchedWord = "switched to" + // actRefusedWord and actFailedWord lead the line a call codeaf refused, or + // the model's side failed, draws. + actRefusedWord = "codeaf refused a call" + actFailedWord = "a call to its model failed" + // actThinkingWord is what is in flight while a call to the model is out. + actThinkingWord = "thinking" + // actEarlierWord follows the count of actions the page does not carry. + actEarlierWord = "earlier actions" +) + +// actNear is how far apart in time a line from the calls and the program's own +// line about the same thing — a compaction, a switch of model — may be and +// still be the one event. The program reports a compaction the moment it is +// decided, between the summary call and the call after it; a switch, when the +// call it was made for has come back. +const actNear = 10 * time.Second + +// actLine is one line of the actions, before it is laid out: when it happened, +// the step's word it belongs under ("" for whatever step is under way), its +// words and how it came out, and whether it is the program steering its own +// model or a line from the calls, which are drawn quieter. +type actLine struct { + at time.Time + step string + text string + outcome string + steer bool + quiet bool +} + // taskConversation is a program's page where an ordinary page draws its steps: -// the brief the program was handed, then every call it made, each as the -// program's side and the side that answered it. +// the brief the program was handed, then every action it took, merged by time +// with what only its calls know, each under the step of its process it served. // -// THE NAMES STAND IN A COLUMN OF THEIR OWN while the frame has the room, so the -// eye reads down the speakers and across to what each said; under -// [convTextLeast] cells of words each name stands on its own line instead. The -// column is as wide as the widest name on the page, so it does not move as the -// conversation grows by a call from the same model. +// THE STEP'S WORD STANDS IN A COLUMN OF ITS OWN while the frame has the room, +// printed on the first action of each run of actions in one step and blank for +// the rest, so the eye reads down the steps and across to what was done in +// each; under [convTextLeast] cells of words each step's word stands on its own +// line instead, and its actions hang under it. The column is as wide as the +// widest word on the page, so it does not move as the run goes on. func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull bool) []string { if width < 1 { return nil } program := convProgramOf(page) pal := a.pal - speaker := convProgramName(page) - running := planStateWord(page.Row) == "running" - column, text := convColumns(convNames(program, speaker), width) + lines := actLines(page) + column, text := actColumns(lines, width) var out []string - // THE BRIEF OPENS THE CONVERSATION. It is what the program was handed, in - // the person's own words, and it stands for the program's side of the first - // call — whose own words are the program's prompt around the same brief. - // Folded to the brief's own three lines, with the key that unfolds it, the - // way every other page folds a brief. - opening := convSide{name: speaker} - for _, line := range taskConversationBrief(page, text, briefFull) { - opening.lines = append(opening.lines, convLine{text: line, ink: pal.ink}) - } - if len(opening.lines) > 0 { - out = append(out, convDraw(opening, column, width, pal)...) - } - // THE CALLS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, never in - // a speaker's column, where the count read as something the program said. It - // is spelled the way every fold line on this surface is ([bandFoldWord]). - if program.Earlier > 0 { - out = append(out, pal.dim(fit(glyphMore+itoa(program.Earlier)+" "+convEarlierWord, width))) - } - briefHead := convBriefHead(page.Description) - for i, turn := range program.Turns { - first := i == 0 && program.Earlier == 0 - if said := a.convProgramSide(turn, speaker, briefHead, first); len(said.lines) > 0 { - out = append(out, convDraw(said, column, width, pal)...) - } - switch { - case convHead(turn.Refused) != "": - out = append(out, convDraw(convSide{name: convCodeaf, lines: []convLine{{ - text: taskPlanRefusedWord + railSep + convHead(turn.Refused), ink: pal.dim, - }}}, column, width, pal)...) - case convHead(turn.Failed) != "": - out = append(out, convDraw(convSide{name: convModelWord(turn), lines: []convLine{{ - text: convFailedWord + railSep + convHead(turn.Failed), ink: pal.dim, - }}}, column, width, pal)...) - case turn.InFlight(): - // THE CALL IN FLIGHT IS THE LIVE EDGE, and it is drawn only while the - // task can still be waiting on it. A call whose ending never reached the - // log before the run ended is not in flight on a page about work that - // is over: it draws no line at all rather than a clock that never stops. - if running { - if line := a.convInFlight(turn, width); line != "" { - out = append(out, line) - } - } - default: - if answer := a.convModelSide(turn); len(answer.lines) > 0 { - out = append(out, convDraw(answer, column, width, pal)...) + // THE BRIEF OPENS THE PAGE, under its own word: it is what the program was + // handed, in the person's own words, folded to the brief's own three lines + // with the key that unfolds it, the way every other page folds a brief. + for i, line := range taskConversationBrief(page, text, briefFull) { + word := "" + if i == 0 { + word = actBriefWord + } + out = append(out, actRow(pal, word, pal.ink(fit(line, text)), column, width)...) + } + // THE ACTIONS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, spelled + // the way every fold line on this surface is ([bandFoldWord]). + if program.EarlierActions > 0 { + out = append(out, pal.dim(fit(glyphMore+itoa(program.EarlierActions)+" "+actEarlierWord, width))) + } + current := "" + for _, line := range lines { + word := "" + if line.step != "" && line.step != current { + word, current = line.step, line.step + } + out = append(out, actRow(pal, word, a.actBody(line, text), column, width)...) + } + // THE CALL IN FLIGHT IS THE LIVE EDGE, drawn only while the task can still be + // waiting on it: a call whose ending never reached the log before the run + // ended is not in flight on a page about work that is over. + if planStateWord(page.Row) == "running" { + if n := len(program.Turns); n > 0 && program.Turns[n-1].InFlight() { + if line := a.actInFlight(program.Turns[n-1], text); line != "" { + out = append(out, actRow(pal, "", line, column, width)...) } } } return out } -// taskConversationBrief is the brief as the conversation opens with it: the -// description through the reader every page draws a brief with -// ([planBriefRows]), at the width the words get beside the names, folded to -// [briefFoldLines] with the line that says how many more and which key opens -// them. -func taskConversationBrief(page session.PlanTaskPage, text int, briefFull bool) []string { - lines := planBriefRows(page.Description, text) - if briefFull || len(lines) <= briefFoldLines { - return lines +// actRow lays one row out: the step's word in the column and the body beside +// it, or — where the frame is too narrow for a column — the word on a line of +// its own and the body hung [convIndent] cells under it. The word is the page's +// structure, not its signal: bold and upper-case in the muted ink, never the +// accent, which a screen spends on the one live thing ([DESIGN-LANGUAGE.md]'s +// accent budget). +func actRow(pal palette, word, body string, column, width int) []string { + label := "" + if word = strings.TrimSpace(word); word != "" { + room := column + if column == 0 { + room = width + } + trimmed, _ := rowTrim(strings.ToUpper(word), room, false) + label = pal.bold(pal.muted(trimmed)) } - return append(append([]string(nil), lines[:briefFoldLines]...), - bandFoldWord(len(lines)-briefFoldLines, briefFoldWhat, true)+railSep+briefFoldKey) -} - -// taskConversationFolds reports whether a program's brief is long enough to -// fold at the frame's own width, which is what `ctrl+o` asks before it opens -// or closes it ([app.taskPlanKey]). It measures the brief at the width the -// conversation draws it at, so the key and the fold line cannot disagree. -func (a *app) taskConversationFolds() bool { - width, _ := a.size() - return convBriefFolds(a.taskSheet.plan, width-2) -} - -// convBriefFolds is whether a program's brief folds when its conversation is -// drawn at this width — the one measure both of a program's pages ask before -// their `ctrl+o` opens or closes it. -func convBriefFolds(page session.PlanTaskPage, width int) bool { - _, text := convColumns(convNames(convProgramOf(page), convProgramName(page)), width) - return len(planBriefRows(page.Description, text)) > briefFoldLines -} - -// convProgramSide is what the program said on one call, in the lines a person -// reads for it: nothing new on the first call, whose words the brief above -// already stands for; one sentence on a call made after it summarized its own -// history; and otherwise its newest messages, each as its first line — a tool's -// result as `<tool>: <line>` and its own words as they were. -// -// NEITHER A PROMPT NOR AN ECHO IS A ROW. A `system` message is the program -// instructing its model, and an `assistant` one is the model's last answer -// handed back to it, which the model's own side has already drawn; and a -// message that is the brief again says nothing the opening has not. -func (a *app) convProgramSide(turn delegate.Turn, speaker, briefHead string, first bool) convSide { - pal := a.pal - side := convSide{name: speaker} - if turn.Restarted { - side.lines = append(side.lines, convLine{text: convRestartedWord, ink: pal.dim}) - return side - } - if first { - return side - } - var said []string - for _, message := range turn.Sent { - line := convHead(message.Text) - switch strings.TrimSpace(message.Role) { - case "system", "assistant": - continue - case "tool": - if tool := strings.TrimSpace(message.Tool); tool != "" { - line = tool + ": " + line - } - default: - if convRepeatsBrief(line, briefHead) { - continue - } - } - if strings.TrimSpace(line) != "" { - said = append(said, line) + if column == 0 { + var out []string + if label != "" { + out = append(out, label) } + return append(out, strings.Repeat(" ", convIndent)+body) } - shown := said - if len(shown) > convSaidMost { - shown = shown[:convSaidMost] - } - for _, line := range shown { - side.lines = append(side.lines, convLine{text: line, ink: pal.dim}) - } - if more := len(said) - len(shown); more > 0 { - side.lines = append(side.lines, convLine{text: "+" + itoa(more) + " more", ink: pal.dim}) + cell := strings.Repeat(" ", column) + if label != "" { + cell = padTo(label, column) } - return side + return []string{cell + strings.Repeat(" ", convGap) + body} } -// convModelSide is what the model answered on one call: the first line of its -// words, and every tool it asked the program to run, one dim row each behind -// that tool's action mark — the same family marks the conversation's own steps -// wear ([app.actionMarkFor]), so a person who has learned `✎` for an edit there -// reads it here. -func (a *app) convModelSide(turn delegate.Turn) convSide { +// actBody is one action's words at the room they get, painted: the program's +// own work in ink, its steering of its model in the note's ink, a line from the +// calls dim, and how it came out dim at the right edge. When the room will not +// hold both at the edge, the outcome follows the words after a separator and +// the row gives up its tail. +func (a *app) actBody(line actLine, width int) string { pal := a.pal - side := convSide{name: convModelWord(turn)} - if reply := convHead(turn.Reply); reply != "" { - side.lines = append(side.lines, convLine{text: reply, ink: pal.ink}) - } - for _, call := range turn.Calls { - name := convHead(call.Name) - if name == "" { - continue - } - words := name - if about := convHead(convCallAbout(call.Args)); about != "" { - words += " " + about - } - mark := a.actionMarkFor(session.ActionCategoryForTool(name)) - side.lines = append(side.lines, convLine{lead: pal.dim(mark) + " ", text: words, ink: pal.dim}) - } - return side + ink := pal.ink + switch { + case line.quiet: + ink = pal.dim + case line.steer: + ink = pal.narr + } + text, outcome := convHead(line.text), convHead(line.outcome) + if outcome == "" { + return ink(fit(text, width)) + } + cells := ansi.StringWidth(outcome) + if room := width - cells - convGap; room >= convTextLeast/2 { + words, measured := fitWidth(text, room) + return ink(words) + strings.Repeat(" ", width-measured-cells) + pal.dim(outcome) + } + return ink(fit(text+railSep+outcome, width)) } -// convInFlight is the call in flight: the running mark, the model it went to, -// and how long it has been out — one line, the last on the page, gone the moment -// its ending reaches the log. The mark comes off the vocabulary's own door, so -// the line gets this terminal's repertoire; the clock is the frame's and says -// nothing for the call's first second. -func (a *app) convInFlight(turn delegate.Turn, width int) string { +// actInFlight is the call in flight: the running mark and how long the program +// has been waiting on its model — one line, the last on the page, gone the +// moment the call's ending reaches the log. The mark comes off the vocabulary's +// own door, so the line gets this terminal's repertoire; the clock is the +// frame's and says nothing for the call's first second. +func (a *app) actInFlight(turn delegate.Turn, width int) string { mark := a.icon(tokens.GStepRunning) room := width - ansi.StringWidth(mark) - 1 if room < 1 { return "" } - var words []string - if model := convModelWordOf(turn.Model); model != "" { - words = append(words, model) - } + words := []string{actThinkingWord} if !turn.Started.IsZero() { if clock := countUpWord(a.now().Sub(turn.Started)); clock != "" { words = append(words, clock) @@ -467,94 +415,233 @@ func (a *app) convInFlight(turn delegate.Turn, width int) string { return a.pal.ink(mark) + " " + a.pal.dim(fit(strings.Join(words, railSep), room)) } -// convDraw lays one side out: its name in the column on its first line and its -// words beside it, or — where the frame is too narrow for a column — its name -// on a line of its own and its words hung under it. EVERY ROW IS FITTED TO THE -// WIDTH: the name is cut in the middle when it must be ([rowTrim]), a mark in -// front of the words is kept whole, and the words give up their tail. -func convDraw(side convSide, column, width int, pal palette) []string { - var out []string - if column == 0 { - if name := strings.TrimSpace(side.name); name != "" { - label, _ := rowTrim(name, width, false) - out = append(out, pal.muted(label)) - } - indent := strings.Repeat(" ", convIndent) - for _, line := range side.lines { - out = append(out, indent+convWords(line, width-convIndent)) +// actColumns decides the page's two widths from the step words on it: the +// column the words stand in and the room the actions get beside it. A column +// of zero is the narrow layout, where every word stands on its own line and +// the actions hang [convIndent] cells under it. +func actColumns(lines []actLine, width int) (int, int) { + widest := ansi.StringWidth(actBriefWord) + for _, line := range lines { + if cells := ansi.StringWidth(strings.TrimSpace(line.step)); cells > widest { + widest = cells } - return out } - gap := strings.Repeat(" ", convGap) - blank := strings.Repeat(" ", column) - for i, line := range side.lines { - cell := blank - if i == 0 && strings.TrimSpace(side.name) != "" { - label, _ := rowTrim(side.name, column, false) - cell = padTo(pal.muted(label), column) - } - out = append(out, cell+gap+convWords(line, width-column-convGap)) + column := min(min(widest, convLabelMost), width/3) + if column < 1 || width-column-convGap < convTextLeast { + return 0, width - convIndent } - return out + return column, width - column - convGap } -// convWords is one line's words at their width, behind its mark when it has -// one. A width too small to hold the mark draws the words alone. -func convWords(line convLine, width int) string { - if width < 1 { - return "" +// actLines is everything the page draws under the brief, in the order it +// happened: the program's actions as its own vocabulary read them, and what +// only its calls know — a compaction the program did not report, a change of +// the model answering, a call refused or failed. +// +// A RUN FROM BEFORE THE ACTION LOG has only its calls, and its page is drawn +// from them in the same shape: each tool its model asked for as an action, and +// no step's word, because nothing recorded which step it served. A run whose +// calls were never logged either draws the steps its program reported. +func actLines(page session.PlanTaskPage) []actLine { + program := convProgramOf(page) + var lines []actLine + for _, shown := range program.Actions { + if strings.TrimSpace(shown.Model) != "" { + // A SWITCH IS READ WITH THE CALLS BELOW, which say the same move from + // the model's side; the program's line gives it its reason. + continue + } + lines = append(lines, actLine{at: shown.At, step: shown.Step, text: shown.Text, outcome: shown.Outcome, steer: shown.Steer}) } - ink := line.ink - if ink == nil { - ink = func(s string) string { return s } + lines = append(lines, actFromCalls(program)...) + if len(program.Actions) == 0 && len(program.Turns) == 0 { + for _, step := range page.Steps { + if command := planDisplayCommand(step.Command, step.Parts); command != "" && !step.NotRun { + lines = append(lines, actLine{text: command}) + } + } } - lead := line.lead - if lead != "" { - if cells := ansi.StringWidth(ansi.Strip(lead)); cells < width { - return lead + ink(fit(line.text, width-cells)) + // THE ACTIONS THE PAGE CUT ARE CUT FROM THE CALLS TOO: a line from a call + // older than the first action the page carries would stand above the count + // of the ones it left out. + if program.EarlierActions > 0 && len(program.Actions) > 0 { + first := program.Actions[0].At + kept := lines[:0] + for _, line := range lines { + if !line.at.Before(first) { + kept = append(kept, line) + } } + lines = kept } - return ink(fit(line.text, width)) + sort.SliceStable(lines, func(i, j int) bool { return lines[i].at.Before(lines[j].at) }) + return lines } -// convColumns decides the page's two widths from the names on it: the column -// the names stand in, and the room their words get beside it. A column of zero -// is the narrow layout, where every name stands on its own line and the words -// hang [convIndent] cells under it. -func convColumns(names []string, width int) (int, int) { - widest := 0 - for _, name := range names { - if cells := ansi.StringWidth(strings.TrimSpace(name)); cells > widest { - widest = cells +// actFromCalls is what the page draws from the program's calls: the compactions +// the program did not report itself, every change of the model answering its +// work — with the program's reason when it gave one, and the program's own line +// for a move the calls do not show — and every call refused or failed; and for +// a run with no action log at all, every tool its model asked for. +func actFromCalls(program *session.PlanProgram) []actLine { + turns := program.Turns + var lines []actLine + memory := func(from, to time.Time) bool { + for _, shown := range program.Actions { + if shown.Memory && !shown.At.Before(from.Add(-actNear)) && !shown.At.After(to.Add(actNear)) { + return true + } } + return false } - column := widest - if column > convLabelMost { - column = convLabelMost - } - if third := width / 3; column > third { - column = third + switches := map[int]bool{} + switchFor := func(model string, from, to time.Time) (string, bool) { + for i, shown := range program.Actions { + if switches[i] || strings.TrimSpace(shown.Model) == "" || !actSameModel(convModelWordOf(shown.Model), model) { + continue + } + if !shown.At.Before(from.Add(-actNear)) && !shown.At.After(to.Add(actNear)) { + switches[i] = true + return shown.Reason, true + } + } + return "", false } - if column < 1 || width-column-convGap < convTextLeast { - return 0, width - convIndent + previous, previousAt := "", time.Time{} + compacted := -1 + for i := 0; i < len(turns); i++ { + turn := turns[i] + switch { + case convHead(turn.Refused) != "": + lines = append(lines, actLine{at: turn.Started, text: actRefusedWord + railSep + convHead(turn.Refused), quiet: true}) + continue + case convHead(turn.Failed) != "": + lines = append(lines, actLine{at: turn.Started, text: actFailedWord + railSep + convHead(turn.Failed), quiet: true}) + continue + } + if turn.Restarted && i > compacted { + // ONE COMPACTION IS A RUN OF RESTARTED CALLS: the summary itself and + // the call after it both rewrite the history, and a person reads one + // line for them. + last := i + for last+1 < len(turns) && turns[last+1].Restarted { + last++ + } + compacted = last + end := turns[last].Ended + if end.IsZero() { + end = turns[last].Started + } + if !memory(turn.Started, end) { + lines = append(lines, actLine{at: turn.Started, text: actCompactedWord}) + } + } + if turn.InFlight() { + continue + } + // THE SUMMARY CALL IS NOT THE WORK. A restarted call that asked for no + // tool, followed by another restarted call, is the history being + // summarized — on a cheaper model, often — and its model is not a change + // of the model doing the work. + if turn.Restarted && len(turn.Calls) == 0 && i+1 < len(turns) && turns[i+1].Restarted { + continue + } + model := convModelWord(turn) + if previous != "" && model != "" && !actSameModel(model, previous) { + reason, _ := switchFor(model, previousAt, turn.Ended) + text := actSwitchedWord + " " + model + if reason != "" { + text += railSep + reason + } + lines = append(lines, actLine{at: turn.Started, text: text}) + } + if model != "" { + previous, previousAt = model, turn.Started + } + if len(program.Actions) == 0 { + for _, call := range turn.Calls { + if text := actCallText(call); text != "" { + lines = append(lines, actLine{at: turn.Ended, text: text}) + } + } + } } - return column, width - column - convGap -} - -// convNames is every name the page will draw in its column: the program's, -// codeaf's when a call was refused, and the model of every call on the page. -func convNames(program *session.PlanProgram, speaker string) []string { - names := []string{speaker} - for _, turn := range program.Turns { - if strings.TrimSpace(turn.Refused) != "" { - names = append(names, convCodeaf) + // A MOVE THE CALLS DO NOT SHOW is still the program's to say: the router + // moved the work and the model API answered on the model it moved to. + for i, shown := range program.Actions { + if strings.TrimSpace(shown.Model) == "" || switches[i] { continue } - if !turn.InFlight() { - names = append(names, convModelWord(turn)) + text := actSwitchedWord + " " + convModelWordOf(shown.Model) + if reason := strings.TrimSpace(shown.Reason); reason != "" { + text += railSep + reason } + lines = append(lines, actLine{at: shown.At, step: shown.Step, text: text}) } - return names + return lines +} + +// actSameModel reports whether two short model words name one model: the same +// word, or one a dated build or variant of the other (`deepseek-v4-flash` and +// `deepseek-v4-flash-0731`), which is how the model a program asked for and the +// one the service answered with are often spelled. +func actSameModel(a, b string) bool { + return a == b || strings.HasPrefix(a, b+"-") || strings.HasPrefix(b, a+"-") +} + +// actCallText is one tool a model asked for, as an action a person reads, for a +// run whose program kept no action log: the verb and what it was about. +func actCallText(call delegate.ToolUse) string { + name := convHead(call.Name) + about := convHead(convCallAbout(call.Args)) + if name == "" { + return "" + } + verb := map[string]string{ + "read": "read", "edit": "edited", "write": "wrote", "apply_patch": "patched", + "bash": "ran", "grep": "searched", "glob": "listed", "webfetch": "fetched", + "websearch": "searched the web for", + }[name] + switch { + case name == "submit": + return "handed in its work" + case verb == "" || about == "": + return strings.TrimSpace(name + " " + about) + } + return verb + " " + about +} + +// taskConversationBrief is the brief as the page opens with it: the description +// through the reader every page draws a brief with ([planBriefRows]), at the +// width the words get beside the step words, folded to [briefFoldLines] with +// the line that says how many more and which key opens them. +func taskConversationBrief(page session.PlanTaskPage, text int, briefFull bool) []string { + lines := planBriefRows(page.Description, text) + if briefFull || len(lines) <= briefFoldLines { + return lines + } + return append(append([]string(nil), lines[:briefFoldLines]...), + bandFoldWord(len(lines)-briefFoldLines, briefFoldWhat, true)+railSep+briefFoldKey) +} + +// taskConversationFolds reports whether a program's brief is long enough to +// fold at the frame's own width, which is what `ctrl+o` asks before it opens +// or closes it ([app.taskPlanKey]). It measures the brief at the width the +// page draws it at, so the key and the fold line cannot disagree. +func (a *app) taskConversationFolds() bool { + width, _ := a.size() + return convBriefFolds(a.taskSheet.plan, width-2, a.taskSheet.planCalls) +} + +// convBriefFolds is whether a program's brief folds when its page is drawn at +// this width — as its actions, or as its calls — the one measure both of a +// program's pages ask before their `ctrl+o` opens or closes it. +func convBriefFolds(page session.PlanTaskPage, width int, calls bool) bool { + _, text := actColumns(actLines(page), width) + if calls { + _, text = convColumns(convNames(convProgramOf(page), convProgramName(page)), width) + } + return len(planBriefRows(page.Description, text)) > briefFoldLines } // convProgramName is the name the program's side wears: the program record's, @@ -623,25 +710,6 @@ func convClean(line string) string { }, ansi.Strip(line)) } -// convBriefHead is the brief's first line as a program's own message would -// carry it, so a message that is the brief again can be told from one that -// says something new. -func convBriefHead(description string) string { return convHead(description) } - -// convRepeatsBrief reports whether a message's first line is the brief's again. -// The page carries a message's head cut at a couple of hundred bytes with the -// cut marked, so a long brief repeated is the brief's own line up to that mark. -func convRepeatsBrief(line, briefHead string) bool { - if line == "" || briefHead == "" { - return false - } - if line == briefHead { - return true - } - cut := strings.TrimSuffix(line, glyphMore) - return cut != line && cut != "" && strings.HasPrefix(briefHead, cut) -} - // convAboutKeys are the arguments that say what a call was about, most telling // first: the command a shell ran, the pattern a search looked for (a search // names where it looked too, and the pattern is the part a person reads it diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index 03ed79e9a..929f70a9c 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -1,10 +1,11 @@ package tui3 // A program's task page, drawn from a scripted page the way the store answers -// one ([session.PlanTaskPage.Program]): the pinned line, the conversation in -// place of the steps, the call in flight, and the foot with no box. Nothing -// here seeds a store or runs a program; the page is the fake's, and every -// reading the surface makes of it is the one a real window makes. +// one ([session.PlanTaskPage.Program]): the pinned line, the program's actions +// under the steps of its process in place of the steps, the call in flight, the +// raw calls one key away, and the foot with no box. Nothing here seeds a store +// or runs a program; the page is the fake's, and every reading the surface +// makes of it is the one a real window makes. import ( "strings" @@ -26,13 +27,13 @@ import ( var programRunBegan = taskFixtureNow.Add(-(14*time.Minute + 3*time.Second)) // programRow is the run's root as the store answers it mid-way: running, -// handed to senior-dev, in its implement stage, with a dollar and a quarter of -// spend rows banked by the model API. +// handed to senior-dev, implementing, with a dollar and a quarter of spend rows +// banked by the model API. func programRow() session.PlanTaskRow { return session.PlanTaskRow{ ID: "t-7", Title: "rewrite the auth middleware", Status: "running", Program: "senior-dev", Stage: "implement", USD: 1.24, Started: programRunBegan, - Live: plandb.LiveStep{Step: 3, Command: "senior-dev: implement · running", Since: programRunBegan}, + Live: plandb.LiveStep{Step: 3, Command: "senior-dev: implement", Since: programRunBegan}, } } @@ -60,6 +61,20 @@ func programTurns() []delegate.Turn { } } +// programActions is what senior-dev's own words make of the same run's action +// log (internal/seniordev's actions.go): set up, the brief written down, the +// reading, the test that fails before the change, and the change. +func programActions() []delegate.Shown { + return []delegate.Shown{ + {At: programRunBegan.Add(-2 * time.Second), Step: "setup", Text: "set up its workspace", Outcome: "git"}, + {At: programRunBegan.Add(-time.Second), Step: "spec", Text: "wrote your brief down as its spec"}, + {At: programRunBegan.Add(4 * time.Second), Step: "explore", Text: "read internal/auth/middleware.go"}, + {At: programRunBegan.Add(4 * time.Second), Step: "explore", Text: "searched internal"}, + {At: programRunBegan.Add(4 * time.Second), Step: "explore", Text: "ran go test ./internal/auth/...", Outcome: "fails · exit 1"}, + {At: programRunBegan.Add(9 * time.Second), Step: "implement", Text: "edited internal/auth/middleware.go"}, + } +} + // programPage is the page the fixture's row opens. func programPage(row session.PlanTaskRow, turns []delegate.Turn) session.PlanTaskPage { return session.PlanTaskPage{ @@ -68,7 +83,7 @@ func programPage(row session.PlanTaskRow, turns []delegate.Turn) session.PlanTas Live: row.Live, Program: &session.PlanProgram{ Name: "senior-dev", Stages: []string{"intake", "implement", "verification"}, - Turns: turns, Calls: len(turns), + Turns: turns, Calls: len(turns), Actions: programActions(), }, } } @@ -98,7 +113,8 @@ func programPageLines(a *app) []string { } // saidBy reports whether a row of the page has a speaker's name in the names' -// column and these words beside it, whatever width the column came to. +// column and these words beside it, whatever width the column came to — the +// raw calls' reading, and the actions' too, where the name is a step's word. func saidBy(lines []string, name, words string) bool { for _, line := range lines { rest, ok := strings.CutPrefix(strings.TrimSpace(line), name) @@ -109,13 +125,36 @@ func saidBy(lines []string, name, words string) bool { return false } -// A PROGRAM'S PAGE IS ITS CONVERSATION WITH CODEAF. The brief opens it under the -// program's name; each call is the program's side and the model's, the model -// named by its short name; every tool the model asked for is a dim row behind -// its family's mark; the call still out is the last line, with its model and its -// clock; and the line under the title is pinned with the stage, the spend, the -// calls and the age. -func TestAProgramsPageDrawsItsConversationWithCodeaf(t *testing.T) { +// underStep reports whether the page draws an action under a step: the step's +// word leads the row whose words begin so, or leads an earlier row of the same +// run of the step with nothing but blank column between them. +func underStep(lines []string, step, words string) bool { + current := "" + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // A STEP'S WORD IS UPPER CASE AND HAS THE COLUMN'S AIR AFTER IT (or the + // row to itself, at a narrow width); an action never starts so. + head, rest, _ := strings.Cut(trimmed, " ") + if head != "" && head == strings.ToUpper(head) && strings.ToLower(head) != head { + current, trimmed = head, strings.TrimSpace(rest) + } + if current == strings.ToUpper(step) && strings.HasPrefix(trimmed, words) { + return true + } + } + return false +} + +// A PROGRAM'S PAGE IS THE ACTIONS IT TOOK, EACH UNDER THE STEP OF ITS PROCESS. +// The brief opens it under its own word; each step's word leads its first +// action and stands blank for the rest; how an action came out is at the right +// edge; the call still out is the last line, `thinking` with its clock; no model +// is named anywhere; and the line under the title is pinned with the step, the +// spend, the calls and the age. +func TestAProgramsPageDrawsItsActionsUnderItsSteps(t *testing.T) { a, _ := programPageApp(t, programPage(programRow(), programTurns()), 80, 30) lines := programPageLines(a) page := strings.Join(lines, "\n") @@ -125,49 +164,46 @@ func TestAProgramsPageDrawsItsConversationWithCodeaf(t *testing.T) { t.Fatalf("the head's first row is %q, want the task's title", lines[0]) } if lines[1] != "implement · $1.24 · 3 calls · 14m 3s" { - t.Fatalf("the pinned line is %q, want the stage, the spend, the calls and the age", lines[1]) - } - for _, said := range []struct{ name, words string }{ - {"senior-dev", "rewrite the auth middleware to use the new session store"}, - {"deepseek-v4-flash", "I'll read the middleware and the store first."}, - {"senior-dev", "read: package auth"}, - {"deepseek-v4-flash", "The store interface is small; I'll change the handler."}, - {"senior-dev", "edit: applied 1 edit"}, + t.Fatalf("the pinned line is %q, want the step, the spend, the calls and the age", lines[1]) + } + for _, said := range []struct{ step, words string }{ + {actBriefWord, "rewrite the auth middleware to use the new session store"}, + {"setup", "set up its workspace"}, + {"spec", "wrote your brief down as its spec"}, + {"explore", "read internal/auth/middleware.go"}, + {"explore", "ran go test ./internal/auth/..."}, + {"implement", "edited internal/auth/middleware.go"}, } { - if !saidBy(lines, said.name, said.words) { - t.Fatalf("the page does not have %s saying %q:\n%s", said.name, said.words, page) + if !underStep(lines, said.step, said.words) { + t.Fatalf("the page does not draw %q under %s:\n%s", said.words, strings.ToUpper(said.step), page) } } - read := a.actionMarkFor(session.ActionCategoryForTool("read")) - grep := a.actionMarkFor(session.ActionCategoryForTool("grep")) - edit := a.actionMarkFor(session.ActionCategoryForTool("edit")) - for _, want := range []string{ - read + " read internal/auth/middleware.go", - grep + " grep SessionStore", - // A result longer than the room beside the names gives up its tail. - "grep: internal/auth/store.go:12: type SessionStore interfa" + glyphMore, - edit + " edit internal/auth/middleware.go", - a.icon(tokens.GStepRunning) + " deepseek-v4-flash · 12s", - } { - if !strings.Contains(page, want) { - t.Fatalf("the page is missing %q:\n%s", want, page) + // A STEP'S WORD IS PRINTED ONCE PER RUN OF ITS ACTIONS. + if n := strings.Count(page, "EXPLORE"); n != 1 { + t.Fatalf("EXPLORE is printed %d times for one run of three actions:\n%s", n, page) + } + // HOW IT CAME OUT IS AT THE RIGHT EDGE. + for _, line := range lines { + if strings.Contains(line, "ran go test ./internal/auth/...") && !strings.HasSuffix(line, "fails · exit 1") { + t.Fatalf("the failing command's row is %q, want its outcome at the right edge", line) } } - // THE CALL IN FLIGHT IS THE CONVERSATION'S LAST LINE. + // THE CALL IN FLIGHT IS THE PAGE'S LAST LINE. last := "" for _, line := range lines { if strings.TrimSpace(line) != "" && !strings.Contains(line, "─") && !strings.Contains(line, taskCardBackWord) { last = line } } - if !strings.Contains(last, a.icon(tokens.GStepRunning)+" deepseek-v4-flash") { - t.Fatalf("the last line of the conversation is %q, want the call in flight", last) + if !strings.Contains(last, a.icon(tokens.GStepRunning)+" "+actThinkingWord+" · 12s") { + t.Fatalf("the last line of the page is %q, want the call in flight", last) } - // NEITHER A PROMPT NOR AN ECHO IS A ROW: the program's system prompt and the - // model's own answer handed back to it are nowhere on the page, and the brief - // is said once. - if strings.Contains(page, "you are senior-dev") || strings.Count(page, "I'll read the middleware and the store first.") != 1 { - t.Fatalf("the page drew a prompt or an echo:\n%s", page) + // NO MODEL IS NAMED, AND NEITHER A PROMPT NOR A REPLY IS A ROW: the actions + // are the program's, and what its model said is one key away. + for _, never := range []string{"deepseek", "you are senior-dev", "I'll read the middleware"} { + if strings.Contains(page, never) { + t.Fatalf("the actions draw %q:\n%s", never, page) + } } if strings.Count(page, "rewrite the auth middleware to use the new session store") != 1 { t.Fatalf("the brief is drawn more than once:\n%s", page) @@ -211,18 +247,18 @@ func TestAProgramsPageHasNoNoteBoxAndTakesNoNote(t *testing.T) { } // THE PINNED LINE IS PINNED. The page opens stuck to its bottom edge and follows -// the conversation down, and a conversation longer than the frame scrolls — the -// pinned line stays under the title at the bottom, part way up, and back at the -// bottom again, while the newest call is what the bottom shows. +// the actions down, and a page longer than the frame scrolls — the pinned line +// stays under the title at the bottom, part way up, and back at the bottom +// again, while the newest action is what the bottom shows. func TestAProgramsPinnedLineSurvivesScrollingToTheBottom(t *testing.T) { row := programRow() - var turns []delegate.Turn + var actions []delegate.Shown for i := 1; i <= 40; i++ { - at := programRunBegan.Add(time.Duration(i) * 10 * time.Second) - turns = append(turns, delegate.Turn{Seq: i, Started: at, Ended: at.Add(5 * time.Second), Model: "deepseek/deepseek-v4-flash", - Sent: []delegate.Said{{Role: "tool", Tool: "bash", Text: "result " + itoa(i)}}, Reply: "answer " + itoa(i)}) + actions = append(actions, delegate.Shown{At: programRunBegan.Add(time.Duration(i) * 10 * time.Second), Step: "implement", Text: "ran step " + itoa(i), Outcome: "passes"}) } - a, _ := programPageApp(t, programPage(row, turns), 80, 20) + page := programPage(row, nil) + page.Program.Actions, page.Program.Calls = actions, 40 + a, _ := programPageApp(t, page, 80, 20) pinned := "implement · $1.24 · 40 calls · 14m 3s" check := func(when string) []string { t.Helper() @@ -233,8 +269,8 @@ func TestAProgramsPinnedLineSurvivesScrollingToTheBottom(t *testing.T) { return lines } lines := check("opened") - if !strings.Contains(strings.Join(lines, "\n"), "answer 40") { - t.Fatalf("a page opened at its bottom edge does not show the newest call:\n%s", strings.Join(lines, "\n")) + if !strings.Contains(strings.Join(lines, "\n"), "ran step 40") { + t.Fatalf("a page opened at its bottom edge does not show the newest action:\n%s", strings.Join(lines, "\n")) } for i := 0; i < 12; i++ { drive(t, a, key("up")) @@ -249,57 +285,59 @@ func TestAProgramsPinnedLineSurvivesScrollingToTheBottom(t *testing.T) { t.Fatal("scrolling back to the bottom did not take the follow up again") } lines = check("back at the bottom") - if !strings.Contains(strings.Join(lines, "\n"), "answer 40") { - t.Fatalf("back at the bottom, the newest call is not on screen:\n%s", strings.Join(lines, "\n")) + if !strings.Contains(strings.Join(lines, "\n"), "ran step 40") { + t.Fatalf("back at the bottom, the newest action is not on screen:\n%s", strings.Join(lines, "\n")) } } // THE CALL IN FLIGHT IS GONE WHEN IT RETURNS. The page follows the task on its -// beat; the read after the call's ending reached the log draws the model's -// answer in its place and no running mark anywhere. +// beat; the read after the call's ending reached the log draws no running mark +// anywhere, and the action the call led to in its place. func TestTheCallInFlightLeavesWhenItReturns(t *testing.T) { row := programRow() a, fake := programPageApp(t, programPage(row, programTurns()), 80, 30) - flying := a.icon(tokens.GStepRunning) + " deepseek-v4-flash" + flying := a.icon(tokens.GStepRunning) + " " + actThinkingWord if page := strings.Join(programPageLines(a), "\n"); !strings.Contains(page, flying) { t.Fatalf("the call in flight is not drawn:\n%s", page) } - back := programTurns() - back[2].Ended = taskFixtureNow - back[2].Reply = "The handler now reads the session store." - fake.pages[row.ID] = programPage(row, back) + back := programPage(row, programTurns()) + back.Program.Turns[2].Ended = taskFixtureNow + back.Program.Actions = append(back.Program.Actions, delegate.Shown{At: taskFixtureNow, Step: "implement", Text: "ran go test ./internal/auth/...", Outcome: "passes"}) + fake.pages[row.ID] = back planBeat(t, a) - page := strings.Join(programPageLines(a), "\n") + lines := programPageLines(a) + page := strings.Join(lines, "\n") if strings.Contains(page, flying) { t.Fatalf("the call that returned is still drawn in flight:\n%s", page) } - if !saidBy(programPageLines(a), "deepseek-v4-flash", "The handler now reads the session store.") { - t.Fatalf("the call that returned did not draw its answer:\n%s", page) + if !strings.Contains(page, "ran go test ./internal/auth/...") || !strings.Contains(page, "passes") { + t.Fatalf("the action after the call is not drawn:\n%s", page) } } // NOTHING IS DRAWN FOR NOTHING. A program that has spent nothing, made no call -// and named no stage wears its state word alone on the pinned line — no -// `$0.00`, no `0 calls` — and a call left open in the log of a run that has -// ended is not in flight on a page about work that is over. +// and named no step wears its state word alone on the pinned line — no +// `$0.00`, no `0 calls` — its page opens on its brief, and a call left open in +// the log of a run that has ended is not in flight on a page about work that is +// over. func TestAProgramsPageDrawsNothingForZeroOrUnknown(t *testing.T) { row := programRow() row.USD, row.Stage, row.Started, row.Live = 0, "", time.Time{}, plandb.LiveStep{} page := programPage(row, nil) - page.Program.Calls = 0 + page.Program.Calls, page.Program.Actions = 0, nil a, _ := programPageApp(t, page, 80, 20) lines := programPageLines(a) if lines[1] != "running" { t.Fatalf("the pinned line of a program that has said nothing is %q, want its state word alone", lines[1]) } text := strings.Join(lines, "\n") - for _, never := range []string{"$0.00", "0 calls", "0s", "earlier calls"} { + for _, never := range []string{"$0.00", "0 calls", "0s", actEarlierWord} { if strings.Contains(text, never) { t.Fatalf("the page drew %q for a figure nobody has:\n%s", never, text) } } - if !saidBy(lines, "senior-dev", "rewrite the auth middleware to use the new session store") { - t.Fatalf("a program that has made no call yet does not open on its brief:\n%s", text) + if !underStep(lines, actBriefWord, "rewrite the auth middleware to use the new session store") { + t.Fatalf("a program that has done nothing yet does not open on its brief:\n%s", text) } // AN ENDED RUN'S OPEN CALL IS NO CALL IN FLIGHT. @@ -315,53 +353,109 @@ func TestAProgramsPageDrawsNothingForZeroOrUnknown(t *testing.T) { } } -// A REFUSED OR FAILED CALL IS ONE PLAIN LINE. codeaf's own refusal is codeaf's -// line, because no model saw the call; a model's failure is that model's line. -// And a program that summarized its own history says so in one line, rather than -// replaying it. -func TestARefusedFailedOrRestartedCallIsOnePlainLine(t *testing.T) { - turns := programTurns()[:2] +// WHAT ONLY THE CALLS KNOW IS MERGED IN BY TIME, ONE PLAIN LINE EACH. A call +// codeaf refused and a call the model's side failed are each one quiet line; a +// history rewritten as a summary — the summary call and the call after it — is +// `compacted its memory` once, and not at all when the program said so itself; +// and every line stands where it happened among the program's own actions. +func TestWhatOnlyTheCallsKnowIsMergedInByTime(t *testing.T) { + model := "deepseek/deepseek-v4-flash" at := programRunBegan.Add(time.Minute) - turns = append(turns, - delegate.Turn{Seq: 3, Started: at, Ended: at, Model: "deepseek/deepseek-v4-flash", Failed: "upstream 503\nretry later"}, - delegate.Turn{Seq: 4, Started: at, Ended: at.Add(time.Second), Model: "deepseek/deepseek-v4-flash", Restarted: true, - Sent: []delegate.Said{{Role: "user", Text: "the whole history, summarized"}}, Reply: "Carrying on from the summary."}, - delegate.Turn{Seq: 5, Started: at, Model: "deepseek/deepseek-v4-flash", Refused: "the run's dollar ceiling is reached"}, + turns := append(programTurns()[:2], + delegate.Turn{Seq: 3, Started: at, Ended: at, Model: model, Failed: "upstream 503\nretry later"}, + delegate.Turn{Seq: 4, Started: at.Add(time.Second), Ended: at.Add(2 * time.Second), Model: model, Restarted: true, + Sent: []delegate.Said{{Role: "user", Text: "<conversation>"}}, Reply: "## Working State"}, + delegate.Turn{Seq: 5, Started: at.Add(3 * time.Second), Ended: at.Add(4 * time.Second), Model: model, Restarted: true, + Reply: "Carrying on from the summary.", Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"a.go"}`}}}, + delegate.Turn{Seq: 6, Started: at.Add(5 * time.Second), Model: model, Refused: "the run's dollar ceiling is reached"}, ) - a, _ := programPageApp(t, programPage(programRow(), turns), 80, 40) + page := programPage(programRow(), turns) + page.Program.Actions = append(page.Program.Actions, delegate.Shown{At: at.Add(4 * time.Second), Step: "implement", Text: "read a.go"}) + a, _ := programPageApp(t, page, 80, 40) lines := programPageLines(a) - page := strings.Join(lines, "\n") - for _, said := range []struct{ name, words string }{ - {"deepseek-v4-flash", convFailedWord + " · upstream 503"}, - {"senior-dev", convRestartedWord}, - {"codeaf", taskPlanRefusedWord + " · the run's dollar ceiling is reached"}, + text := strings.Join(lines, "\n") + order := []string{ + "edited internal/auth/middleware.go", + actFailedWord + " · upstream 503", + actCompactedWord, + "read a.go", + actRefusedWord + " · the run's dollar ceiling is reached", + } + from := 0 + for _, want := range order { + at := strings.Index(text[from:], want) + if at < 0 { + t.Fatalf("the page does not have %q after the lines before it:\n%s", want, text) + } + from += at + len(want) + } + if strings.Count(text, actCompactedWord) != 1 || strings.Contains(text, "retry later") || strings.Contains(text, "Working State") { + t.Fatalf("a compaction or a failure drew more than its one line:\n%s", text) + } + // THE PROGRAM SAID IT COMPACTED, SO THE CALLS DO NOT SAY IT AGAIN. + said := programPage(programRow(), turns) + said.Program.Actions = append(said.Program.Actions, delegate.Shown{At: at.Add(2 * time.Second), Text: actCompactedWord, Outcome: "kept its own record", Memory: true}) + b, _ := programPageApp(t, said, 80, 40) + if got := strings.Join(programPageLines(b), "\n"); strings.Count(got, actCompactedWord) != 1 || !strings.Contains(got, "kept its own record") { + t.Fatalf("a compaction the program reported is drawn %d times:\n%s", strings.Count(got, actCompactedWord), got) + } +} + +// A CHANGE OF THE MODEL ANSWERING IS ITS OWN LINE, WITH THE PROGRAM'S REASON. A +// model is named nowhere else on the page: the first model is not said at all, +// a later call answered by another is `switched to <model>`, the program's own +// line gives it its reason, and a move the program reported that the calls do +// not show is still drawn. A summary on a cheaper model is not a switch. +func TestAChangeOfModelIsItsOwnLineWithItsReason(t *testing.T) { + one, two := "deepseek/deepseek-v4-flash", "moonshotai/kimi-k3" + at := programRunBegan.Add(time.Minute) + turns := []delegate.Turn{ + {Seq: 1, Started: programRunBegan, Ended: programRunBegan.Add(time.Second), Model: one, Calls: []delegate.ToolUse{{Name: "read"}}}, + {Seq: 2, Started: at, Ended: at.Add(time.Second), Model: two, Calls: []delegate.ToolUse{{Name: "read"}}}, + {Seq: 3, Started: at.Add(2 * time.Second), Ended: at.Add(3 * time.Second), Model: "vendor/cheap-summary", Restarted: true}, + {Seq: 4, Started: at.Add(4 * time.Second), Ended: at.Add(5 * time.Second), Model: two, Restarted: true, Calls: []delegate.ToolUse{{Name: "read"}}}, + } + page := programPage(programRow(), turns) + page.Program.Actions = append(page.Program.Actions, + delegate.Shown{At: at.Add(time.Second), Text: "switched to kimi-k3", Model: "openrouter/moonshotai/kimi-k3", Reason: "the last one was rate-limited"}, + delegate.Shown{At: at.Add(10 * time.Minute), Text: "switched to glm-5.3-flash", Model: "openrouter/z-ai/glm-5.3-flash", Reason: "the last one was busy"}, + ) + page.Row.Status = "done" + a, _ := programPageApp(t, page, 100, 40) + text := strings.Join(programPageLines(a), "\n") + for _, want := range []string{ + actSwitchedWord + " kimi-k3 · the last one was rate-limited", + actSwitchedWord + " glm-5.3-flash · the last one was busy", } { - if !saidBy(lines, said.name, said.words) { - t.Fatalf("the page does not have %s saying %q:\n%s", said.name, said.words, page) + if strings.Count(text, want) != 1 { + t.Fatalf("the page draws %q %d times, want once:\n%s", want, strings.Count(text, want), text) } } - if strings.Contains(page, "retry later") || strings.Contains(page, "the whole history, summarized") { - t.Fatalf("a failure or a summarized history drew more than its one line:\n%s", page) + for _, never := range []string{"deepseek", "cheap-summary", actSwitchedWord + " deepseek"} { + if strings.Contains(text, never) { + t.Fatalf("the page names %q:\n%s", never, text) + } } } -// A LONG RUN'S PAGE SAYS HOW MANY CALLS IT LEAVES OUT, the ceiling beside the -// spend when the page knows it, and at forty columns every name stands on a line -// of its own with its words hung under it — every row still inside the frame. -func TestAProgramsPageAtNarrowWidthAndWithEarlierCalls(t *testing.T) { +// A LONG RUN'S PAGE SAYS HOW MANY ACTIONS IT LEAVES OUT, the ceiling beside the +// spend when the page knows it, and at forty columns every step's word stands +// on a line of its own with its actions hung under it — every row still inside +// the frame. +func TestAProgramsPageAtNarrowWidthAndWithEarlierActions(t *testing.T) { page := programPage(programRow(), programTurns()) - page.Program.Earlier, page.Program.Calls, page.Program.CeilingUSD = 142, 145, 5 + page.Program.EarlierActions, page.Program.Calls, page.Program.CeilingUSD = 142, 145, 5 a, _ := programPageApp(t, page, 40, 40) lines := programPageLines(a) text := strings.Join(lines, "\n") - if !strings.Contains(text, "142 "+convEarlierWord) { - t.Fatalf("the page does not say how many earlier calls it leaves out:\n%s", text) + if !strings.Contains(text, "142 "+actEarlierWord) { + t.Fatalf("the page does not say how many earlier actions it leaves out:\n%s", text) } if !strings.HasPrefix(lines[1], "implement · $1.24 of $5.00") { t.Fatalf("the pinned line is %q, want the ceiling beside the spend", lines[1]) } - if !strings.Contains(text, "\n senior-dev\n") || !strings.Contains(text, "\n rewrite the auth") { - t.Fatalf("at forty columns the names do not stand on lines of their own:\n%s", text) + if !strings.Contains(text, "\n EXPLORE\n") || !strings.Contains(text, "\n read internal/auth/") { + t.Fatalf("at forty columns the step words do not stand on lines of their own:\n%s", text) } for i, line := range lines { if cells := ansi.StringWidth(line); cells > 40 { @@ -370,6 +464,71 @@ func TestAProgramsPageAtNarrowWidthAndWithEarlierCalls(t *testing.T) { } } +// A RUN FROM BEFORE THE ACTION LOG IS DRAWN IN THE SAME SHAPE FROM WHAT IT HAS: +// each tool its model asked for as an action, from its calls, and never an empty +// page for a run that has calls; and a run whose calls were never logged either +// draws the steps its program reported. +func TestARunFromBeforeTheActionLogIsDrawnFromItsCalls(t *testing.T) { + page := programPage(programRow(), programTurns()) + page.Program.Actions = nil + a, _ := programPageApp(t, page, 80, 30) + text := strings.Join(programPageLines(a), "\n") + for _, want := range []string{"read internal/auth/middleware.go", "searched SessionStore", "edited internal/auth/middleware.go"} { + if !strings.Contains(text, want) { + t.Fatalf("a run with no action log does not draw %q from its calls:\n%s", want, text) + } + } + older := programPage(programRow(), nil) + older.Program.Actions, older.Program.Calls = nil, 0 + older.Steps = []session.PlanStep{{Step: 1, Command: "bash: go test ./..."}} + b, _ := programPageApp(t, older, 80, 30) + if text := strings.Join(programPageLines(b), "\n"); !strings.Contains(text, "bash: go test ./...") || strings.Contains(text, "steps") { + t.Fatalf("a run with neither log does not draw its steps as actions:\n%s", text) + } +} + +// THE RAW CALLS ARE ONE KEY AWAY. The key row names the key; the key turns the +// page to the dialogue between the program and its model — the model named, its +// words and its calls — and the key row then names the way back; the same key +// turns it back to the actions. +func TestTheRawCallsAreOneKeyAway(t *testing.T) { + a, _ := programPageApp(t, programPage(programRow(), programTurns()), 80, 30) + page := strings.Join(programPageLines(a), "\n") + if !strings.Contains(page, programCallsWord) || strings.Contains(page, "I'll read the middleware") { + t.Fatalf("the actions do not offer the calls, or draw them:\n%s", page) + } + drive(t, a, key(programCallsKey)) + lines := programPageLines(a) + calls := strings.Join(lines, "\n") + if !saidBy(lines, "deepseek-v4-flash", "I'll read the middleware and the store first.") || !strings.Contains(calls, programActionsWord) { + t.Fatalf("the key did not turn the page to its calls:\n%s", calls) + } + drive(t, a, key(programCallsKey)) + if back := strings.Join(programPageLines(a), "\n"); strings.Contains(back, "I'll read the middleware") || !strings.Contains(back, "EXPLORE") { + t.Fatalf("the key did not turn the page back to its actions:\n%s", back) + } +} + +// DRAWING THE PAGE READS NOTHING. However many frames are drawn, as actions or +// as calls, the surface asks the store for no page and no row it did not ask for +// on its beat. +func TestDrawingAProgramsPageReadsNothing(t *testing.T) { + row := programRow() + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) + counted := &railPlanCounter{planFake: fake} + a.agent = counted + a.width, a.height = 80, 30 + openPlanPage(t, a) + rows, pages := counted.rows, counted.pages + for i := 0; i < 3; i++ { + programPageLines(a) + a.taskSheet.planCalls = !a.taskSheet.planCalls + } + if counted.rows != rows || counted.pages != pages { + t.Fatalf("drawing the page read the agent: rows %d→%d, pages %d→%d", rows, counted.rows, pages, counted.pages) + } +} + // THE RAIL ROW OF A PROGRAM'S RUN SAYS ITS STAGE AND WHAT IT HAS SPENT SO FAR, // where it used to say only its clock. Both come off the run's plan row the // surface already holds, never off a read the frame makes. @@ -472,16 +631,16 @@ func TestACallsArgumentsAreReadForWhatTheCallWasAbout(t *testing.T) { } } -// EVERY DOOR INTO A PROGRAM'S TASK OPENS ITS CONVERSATION, AS A ROOM. The card +// EVERY DOOR INTO A PROGRAM'S TASK OPENS ITS ACTIONS, AS A ROOM. The card // in the conversation, a transcript link, the task strip and the home panel all // come through [app.openRoomFor], which used to open an ordinary room — a blank // page, because a program has no worker transcript — and then a full-frame page // over the conversation with no tab strip. A held row that names its program // opens the program's room at once, inside the conversation's tab, with the -// program's conversation as its body — whichever way the row's id is spelled: +// program's actions as its body — whichever way the row's id is spelled: // the store answers `t-7`, and a comparison against the bare number missed // every real row. -func TestEveryDoorIntoAProgramsTaskOpensItsConversation(t *testing.T) { +func TestEveryDoorIntoAProgramsTaskOpensItsActions(t *testing.T) { for _, id := range []string{"7", "t-7"} { t.Run(id, func(t *testing.T) { row := programRow() @@ -500,8 +659,8 @@ func TestEveryDoorIntoAProgramsTaskOpensItsConversation(t *testing.T) { if len(fake.noted) != 0 { t.Fatalf("opening the room wrote notes %v", fake.noted) } - if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") { - t.Fatalf("the room does not show the program's conversation:\n%s", text) + if text := roomText(a); !strings.Contains(text, "wrote your brief down as its spec") { + t.Fatalf("the room does not show the program's actions:\n%s", text) } }) } @@ -567,8 +726,8 @@ func TestARoomOpenedOnAProgramsTaskBecomesItsRoom(t *testing.T) { if a.railTaskPlanOn || a.taskSheet.planOn { t.Fatal("the program's task was drawn as a page over the conversation") } - if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") { - t.Fatalf("the room does not show the program's conversation:\n%s", text) + if text := roomText(a); !strings.Contains(text, "wrote your brief down as its spec") { + t.Fatalf("the room does not show the program's actions:\n%s", text) } // AND AN ORDINARY TASK KEEPS ITS ROOM. plain := row diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 8a43da8e5..b4b130b4b 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -864,7 +864,7 @@ func (a *app) taskSheetPlanAsk(id string, from *session.PlanTaskPage, opened fun } a.taskSheet.plan, a.taskSheet.planOn, a.taskSheet.detailOn = page, true, true a.taskSheet.planPageAt = a.now() - a.taskSheet.planBriefFull = false + a.taskSheet.planBriefFull, a.taskSheet.planCalls = false, false a.taskSheet.planAt = -1 a.taskSheet.detailTop = 0 // A PAGE OPENS AT THE LIVE EDGE. The newest step is the reason the page @@ -889,7 +889,7 @@ func (a *app) taskSheetPlanAsk(id string, from *session.PlanTaskPage, opened fun // closeTaskPlan backs out one layer to the list, which is the card's own `esc`. func (a *app) closeTaskPlan() { a.taskSheet.plan, a.taskSheet.planOn, a.taskSheet.detailOn = session.PlanTaskPage{}, false, false - a.taskSheet.planBriefFull = false + a.taskSheet.planBriefFull, a.taskSheet.planCalls = false, false a.taskSheet.detailTop, a.taskSheet.planStick = 0, false // A half-typed note does not survive the page it was typed on, which is the // box's own law everywhere here ([app.placeHomeGesture] resets the box it @@ -1208,6 +1208,12 @@ func (a *app) taskPlanKey(msg tea.KeyPressMsg) tea.Cmd { return a.taskSheetPlanFrom(old.Children[a.taskSheet.planAt].ID, &old) } return nil + case programCallsKey: + // THE RAW CALLS, ONE KEY AWAY, and the same key back to the actions + // (programcalls.go). + a.taskSheet.planCalls = !a.taskSheet.planCalls + a.touch() + return nil case "esc", "left", taskSheetKey, "up", "ctrl+p", "down", "ctrl+n", "pgup", "pgdown", "ctrl+o": default: return nil @@ -1512,9 +1518,12 @@ func (a *app) taskPlanFrame(width, height int) ([]string, int, int) { // first is the scroll. func (a *app) taskPlanKeys() string { parts := []string{"↑↓ scroll"} - // A PROGRAM'S PAGE SENDS NOTHING, so its key line offers no send. + // A PROGRAM'S PAGE SENDS NOTHING, so its key line offers no send; it offers + // the key that turns it between the program's actions and its raw calls. if !a.taskPlanIsProgram() { parts = append(parts, "enter send") + } else { + parts = append(parts, programCallsHint(a.taskSheet.planCalls)) } parts = append(parts, a.tasksPlanKeyWords(a.taskSheet.plan.Row)...) parts = append(parts, taskCardBackWord) From f94355feb9eb67b544c1b746f4e7304bf1079ed2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:14:39 -0400 Subject: [PATCH 112/195] seniordev: the scripted run proves a model's shell command reports its exit code The end-to-end run through the model API wrote files and submitted but never ran a command, so nothing held that a real shell call's step record carries its exit code and its explore step. The scripted model now runs the tests first, and the test holds every command step to an exit code and the run to naming explore among its steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/seniordev/seniordev_test.go | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/internal/seniordev/seniordev_test.go b/internal/seniordev/seniordev_test.go index e56464664..f90d914b8 100644 --- a/internal/seniordev/seniordev_test.go +++ b/internal/seniordev/seniordev_test.go @@ -83,8 +83,8 @@ const ( // modelAPIServer answers like codeaf's model API: OpenRouter's streamed // chat-completions shape, a keepalive comment before the first chunk, and -// usage.cost in the last. It plays one scripted conversation — write the -// feature, write the checklist, submit, and stop — and keeps every request it +// usage.cost in the last. It plays one scripted conversation — run the tests, +// write the feature, write the checklist, submit, and stop — and keeps every request it // was sent. type modelAPIServer struct { mu sync.Mutex @@ -146,14 +146,17 @@ func (s *modelAPIServer) seen() []seenRequest { return append([]seenRequest(nil), s.requests...) } -// scriptedReply is the model's side of the conversation, one reply per call. +// scriptedReply is the model's side of the conversation, one reply per call: +// run the tests first, write the feature, write the checklist, submit, stop. func scriptedReply(call int) string { switch call { case 1: - return toolCall(call, "write", map[string]any{"filePath": "feature.txt", "content": "implemented\n"}) + return toolCall(call, "bash", map[string]any{"command": "make test"}) case 2: - return toolCall(call, "write", map[string]any{"filePath": ".senior-dev/checklist.md", "content": "- [x] the feature is implemented\n"}) + return toolCall(call, "write", map[string]any{"filePath": "feature.txt", "content": "implemented\n"}) case 3: + return toolCall(call, "write", map[string]any{"filePath": ".senior-dev/checklist.md", "content": "- [x] the feature is implemented\n"}) + case 4: return toolCall(call, "submit", map[string]any{ "reason": "feature.txt now holds the feature", "evidence": "make test exits 0", "checklist_satisfied": true, @@ -298,6 +301,10 @@ func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { t.Fatalf("step %q says tool %q and step %q, want a tool and one of %v", record.step, record.record.Tool, record.record.Step, app.Steps) } named[record.record.Step] = true + // A COMMAND SAYS HOW IT EXITED, from the shell tool's own record of it. + if record.record.Tool == "bash" && record.record.Exit == nil { + t.Fatalf("the command step %q carries no exit code", record.step) + } case "terminal": terminals++ if at != len(records)-1 { @@ -308,9 +315,9 @@ func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { if steps < 1 { t.Fatalf("no step records: %+v", records) } - // The scripted model writes the feature, then its checklist, then submits; - // senior-dev then runs the project's own build and tests itself. - for _, want := range []string{app.StepImplement, app.StepChecklist, app.StepSubmit, app.StepVerify} { + // The scripted model runs the tests, writes the feature, then its checklist, + // then submits; senior-dev then runs the project's own build and tests itself. + for _, want := range []string{app.StepExplore, app.StepImplement, app.StepChecklist, app.StepSubmit, app.StepVerify} { if !named[want] { t.Errorf("no step was named %q: %v", want, named) } @@ -358,8 +365,8 @@ func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { } requests := server.seen() - if len(requests) < 4 { - t.Fatalf("%d model requests, want the scripted four", len(requests)) + if len(requests) < 5 { + t.Fatalf("%d model requests, want the scripted five", len(requests)) } route, err := url.Parse(modelapi.ChatURL(host.api.BaseURL)) if err != nil { From 362dd41d3c64692361b87a18db26dd143f142b46 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:15:13 -0400 Subject: [PATCH 113/195] run: a program's live step is written when its step changes, not on every action The live step followed the step each record named, and wrote the store on every step record even when the word had not moved. It now writes only when the step's word changes, so a run of forty reads is one write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/run/delegateworker.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index d1439d7a7..e4a96f8f2 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -178,9 +178,12 @@ type delegateSink struct { // 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. + // 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 @@ -220,7 +223,10 @@ func (s *delegateSink) live(action delegate.Action) { } if shown, ok := s.reader(action); ok && strings.TrimSpace(shown.Step) != "" { s.stepped = true - _ = s.worker.store.SetLive(s.taskID, s.steps+1, s.name+": "+strings.TrimSpace(shown.Step)) + 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 { From b033c9c033d4232a139d5f0eeea3511000037f10 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:08:09 -0400 Subject: [PATCH 114/195] session, remote: a program's work says which program has it, from the card to the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task handed to a program codeaf carries was indistinguishable from an ordinary task everywhere but the run's plan rows: the notice a surface draws a row from had no program on it, so the card a person approved could not say where the work was going, and a window learned the program only when it next read the plan store — and forgot it again on every conversation switch. The checkpoint and the project's index dropped it too, so a reopened conversation, the @ list, home and another conversation's tasks tool could not tell a senior-dev run from a /task at all. Now TaskNotice carries Program. The proposal's notice names it (from via), the run's first publish names it for both doors (a typed /<name> and an approved via), and publishRunRow carries it forward the way it carries the copy, so a stop, a landing or a carry-on never takes it off the row. The checkpoint's run record and the index row keep it, so it crosses --host and survives a reopen. The proposal asks `wants to start a [<name>] task: <title>` through one exported builder the surface shares, and the tasks tool ends a program's row with `via <name>`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 3 +- internal/manual/chat/senior-dev.md | 4 +- internal/remote/places_test.go | 6 + internal/remote/tasklane_test.go | 26 ++++ internal/session/question.go | 41 +++++- internal/session/task.go | 4 + internal/session/task_contract.go | 19 +++ internal/session/task_index.go | 11 ++ internal/session/task_run_belt.go | 32 ++++- internal/session/task_run_clock_test.go | 4 +- internal/session/task_run_index.go | 3 + internal/session/task_store.go | 8 ++ internal/session/taskprogram_test.go | 177 ++++++++++++++++++++++++ internal/session/tools_tasks.go | 33 ++++- internal/tui3/task.go | 2 +- 15 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 internal/session/taskprogram_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 0b2931108..c7aaca66c 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -38,7 +38,8 @@ That is `/task` with the worker chosen. A run starts at once in a copy of your f turn goes on, and the row appears on the rail. The model can choose one as well. `propose_task` takes `via` naming the program, and the -card you answer says which program the work is going to. The model is told the programs +card you answer says which program the work is going to: it asks `wants to start a +[<name>] task: <title>`. 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. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index b1c8993e3..1c5c4c1c1 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -390,8 +390,8 @@ Everything that shows the run's time shows that one span: the line under its pag (counting up from the hand-off while it runs, and stopped at senior-dev's exit once it has ended, even before the work has landed), its row and card once it has landed, the note the conversation is handed when it lands (`done · ran 22m 51s · …`), and the chat's `tasks` -tool (`#3 · <title> · done · ran 22m 51s`, 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`, +tool (`#3 · <title> · 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` diff --git a/internal/remote/places_test.go b/internal/remote/places_test.go index 5703d7a8b..f7acbb380 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/tasklane_test.go b/internal/remote/tasklane_test.go index 7c2bc9cb0..a701fb58e 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/session/question.go b/internal/session/question.go index 073183c80..bd737ae53 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -2801,7 +2801,7 @@ func (a *Agent) proposalQuestion(id uint64, notice TaskNotice) Question { Ask: AskPermission, Form: FormCard, Asker: Asker{Kind: AskerModel}, - Head: TaskProposalLead + strings.TrimSpace(notice.Title), + Head: TaskProposalHead(notice), Reason: strings.TrimSpace(notice.Summary), Subject: SubjectRef{Kind: SubjectNode, ID: id, Name: strings.TrimSpace(notice.Title)}, Options: AnswerOptions(QuestionTask), @@ -2875,6 +2875,45 @@ func TaskModelShape(notice TaskNotice) InputShape { // would put two questions on screen about one proposal. const TaskProposalLead = "wants to start a task: " +// TaskProposalHead is the whole sentence a proposal asks with: [TaskProposalLead] +// and the title, and — for work going to a program codeaf carries — the +// program's badge where the word `task` is, so the question reads +// `wants to start a [senior-dev] task: <title>`. +// +// THE PROGRAM IS IN THE SENTENCE AND NOT ONLY ON THE CARD, because the sentence +// is what every reader of a proposal gets: the block above the box, home's +// needs panel, a surface over `--host`, and a plain-text or screen-reader +// reader that draws no card at all. A person approving work is owed who it is +// going to wherever they approve it. The badge stands before the title rather +// than after it because a narrow reader cuts a head from its end. +// +// IT IS EXPORTED FOR [TaskProposalLead]'s REASON: the surface builds the same +// question from the same notice, and two builders that drifted would be two +// questions about one proposal. +func TaskProposalHead(notice TaskNotice) string { + title := strings.TrimSpace(notice.Title) + if program := strings.TrimSpace(notice.Program); program != "" { + return "wants to start a " + ProgramBadge(program) + " task: " + title + } + return TaskProposalLead + title +} + +// ProgramBadge is a program's name as the badge its work wears everywhere a +// task is named — `[senior-dev]` — and it is the ONE spelling of the brackets +// (internal/tui3's programbadge.go draws its full spelling from this). +// +// THE BRACKETS ARE THE BADGE, NOT DECORATION. A surface paints the badge in its +// own ink, and a terminal with no colour, a selected row whose ground swallows a +// tint and a sentence read aloud have only the brackets left to say that the +// word inside them is a program's name rather than part of the title. +func ProgramBadge(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return "[" + name + "]" +} + // TaskProposalPickReason is why the clock recommends starting it, in the words // the recommendation is made in. It is exported for [TaskProposalLead]'s // reason. diff --git a/internal/session/task.go b/internal/session/task.go index c5f0f6486..b82d4a3d6 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -1437,6 +1437,10 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T Model: firstTaskModel(spec.modelOptions, spec.model), ModelOptions: append([]string(nil), spec.modelOptions...), Elsewhere: elsewhere, + // AND WHICH PROGRAM THE WORK IS GOING TO, so the card names it before + // anybody is asked to say yes ([TaskNotice.Program]). The name was + // resolved at staging, so it is always one this build carries. + Program: spec.via, }, } } diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 8fd6cc5dc..ec7691d35 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -464,6 +464,25 @@ type TaskNotice struct { // worktree. It is on the proposal AND on every update, because it is the one // fact about a node that is true before it starts and after it lands. Kind TaskKind + // Program is the program codeaf carries that this work is handed to — + // senior-dev — by the one name that program answers to (the Name of its + // [delegate.Delegate], the word its command row says), and "" for every task + // a worker of this conversation's own does, which is almost all of them. + // + // IT IS ON THE PROPOSAL AND ON EVERY ROW A PROGRAM'S RUN PUBLISHES, and that + // is the whole of why it is here. A surface used to learn a program's name + // only from the run's plan rows, which it reads on a beat of its own and + // drops on a conversation switch — so the card a person answered could not + // say which program the work was going to, and a program's row on the side + // list looked exactly like an ordinary task's for its first seconds and again + // after every switch. Carried here, the badge a program's work wears + // (internal/tui3's programbadge.go) is there from the first frame. + // + // IT IS A FACT FOR THE ROW'S WHOLE LIFE, like Kind above it: settled before the + // work starts and moved by nothing that happens to the work afterwards, so a + // publisher that forgets it has not changed it ([Agent.publishRunRow] carries + // it forward). + Program string // ── proposal fields (EventTaskProposal) ───────────────────────────── diff --git a/internal/session/task_index.go b/internal/session/task_index.go index d158be415..cea78d1eb 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -157,6 +157,17 @@ type TaskIndexEntry struct { // spelled in [TaskKindWord] so that a live row merged in from a graph reads // the same way as a landed one, not because the file holds any. Kind TaskKind `json:"kind,omitempty"` + // Program is the program codeaf carries that this work was handed to — + // senior-dev — and empty for every task a conversation's own worker did + // ([TaskNotice.Program]). It is what lets a surface drawing this file — the + // `@` list, home, the tasks place, another conversation's tasks tool — tell + // a program's work from an ordinary task's, which it otherwise could not + // do from anything a row carries. + // + // IT IS ADDITIVE AND ABSENCE IS ORDINARY, on [TaskIndexEntry.Kind]'s terms: + // rows written before the field existed decode with none, and a blank program + // and a plain task are drawn the same way on purpose. + Program string `json:"program,omitempty"` // Where is the worker's resolved directory, or the explicit placement from a // restored proposal that has not started yet. Where string `json:"where,omitempty"` diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 454b24331..45a04f238 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -455,9 +455,14 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie // the branch it names exists only in this variable until it is: the road that // cut it minted the name at random and wrote it nowhere ([runCopyOf] says the // whole of why). A run published without it is a run nobody can carry on. + // + // AND THE ROW SAYS WHICH PROGRAM HAS THE WORK from this first publish on, which + // is the one place both doors meet — a typed `/<name>` and an approved + // `via` — so every later publish carries it forward from here + // ([TaskNotice.Program], [Agent.publishRunRow]). a.publishRunRow(g, TaskNotice{ ID: id, Title: title, State: TaskRunning, StartedAt: born, - Copy: runCopyOf(tree), + Copy: runCopyOf(tree), Program: programName(via), }) go a.driveBeltRun(runCtx, engine, run, a.beltRunSpec(run, brief)) @@ -506,6 +511,16 @@ func aloneName(via, running *delegate.Delegate) string { return "it" } +// programName is the name a run's rows carry for the program its worker is +// ([TaskNotice.Program]), and "" for the conversation's own bash worker, which +// is no program at all. +func programName(via *delegate.Delegate) string { + if via == nil { + return "" + } + return strings.TrimSpace(via.Name) +} + // delegateStartSha is the commit a tree delegate's copy stands on before the // program has written a byte — the point its commits are squashed back to at // landing (delegate_door.go). It is read NOW, off the copy itself: whatever the @@ -1015,6 +1030,13 @@ func (a *Agent) installBeltRun(g *TaskGraph, run *beltRun) { // keeps that from depending on each of them remembering. A notice that names a // copy of its own wins, because it is the more recent reading. // +// THE PROGRAM IS CARRIED ACROSS HERE TOO, for the same reason and on its own +// test: only the first publish knows which program has the work +// ([TaskNotice.Program]), and a stop, a landing or a carry-on that published +// without it would take the program's badge off its row halfway through its +// life. A row that never had one — the conversation's own worker's — has +// nothing to carry. +// // AND A ROW THAT HAS ENDED CARRIES HOW LONG IT RAN, worked out here from the // one pair it carries ([runSpan]) so that no publisher can put a different // figure beside the same two instants: the rail's clock, the card's span and @@ -1031,6 +1053,14 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { } } } + if notice.Program == "" { + for _, kept := range g.runRows(notice.ID) { + if kept.ID == notice.ID && kept.Program != "" { + notice.Program = kept.Program + break + } + } + } if notice.Elapsed == 0 { notice.Elapsed = runSpan(notice.StartedAt, notice.EndedAt) } diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go index 3d3a985c6..9572b2b6e 100644 --- a/internal/session/task_run_clock_test.go +++ b/internal/session/task_run_clock_test.go @@ -386,8 +386,8 @@ func TestTheTasksToolSeesAProgramsRunAndSaysHowLongItTook(t *testing.T) { t.Fatalf("the listing answered %q (failed %v), want the run done with its time", listing, failed) } read, failed = runTool(t, agent, "tasks", `{"id":"`+name+`"}`) - if failed || !strings.HasPrefix(read, name+" · "+title+" · done · ran 22m 51s\n") { - t.Fatalf("reading the ended run answered %q (failed %v), want its time on its first line", read, failed) + if failed || !strings.HasPrefix(read, name+" · "+title+" · done · ran 22m 51s · via fake\n") { + t.Fatalf("reading the ended run answered %q (failed %v), want its time and its program on its first line", read, failed) } if conversationNotes(agent, "done · ran 22m 51s · submitted and verified") == 0 { t.Fatal("the note the conversation was handed at the landing does not say how long the run took") diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go index 4f9fea388..ff6f361ac 100644 --- a/internal/session/task_run_index.go +++ b/internal/session/task_run_index.go @@ -55,6 +55,9 @@ func (a *Agent) indexRunRow(notice TaskNotice) { // THE KEPT BRANCH ONLY, from the row's own word for how its work came // home ([keptBranchOf]): a run whose work was merged names none. Branch: keptBranchOf(notice.Branch, notice.Merge), + // AND WHICH PROGRAM HAS IT, so every surface drawing this file can put the + // program's badge on the row ([TaskIndexEntry.Program]). + Program: notice.Program, } // A PERSON'S STOP IS THE ROW'S ENDING, as it is on a node's row // ([TaskNode.endingLocked]): the stop road publishes the flag and no word. diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 6f774f218..55c2354c3 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -727,6 +727,12 @@ type runRecord struct { // log where a run's shows how its branch came home, and it refuses the ✕ that // a run's row offers (session's TaskKindJob, internal/tui3's task.go). Kind TaskKind `json:"kind,omitempty"` + // Program is the program the run was handed to ([TaskNotice.Program]). It + // survives for Kind's reason: it decides how the row is DRAWN — a program's + // row wears its badge — and a conversation reopened tomorrow redraws its + // runs from these records long before any plan row is read. Absent is every + // run no program had, and every record written before the field existed. + Program string `json:"program,omitempty"` State TaskState `json:"state"` Stopped bool `json:"stopped,omitempty"` @@ -1083,6 +1089,7 @@ func runRowRecord(notice TaskNotice) runRecord { Parent: notice.Parent, Title: notice.Title, Kind: notice.Kind, + Program: notice.Program, State: state, Stopped: notice.Stopped, Report: notice.Report, @@ -1125,6 +1132,7 @@ func runRowNotice(record runRecord) TaskNotice { Parent: record.Parent, Title: record.Title, Kind: record.Kind, + Program: record.Program, State: record.State, Stopped: record.Stopped, Report: record.Report, diff --git a/internal/session/taskprogram_test.go b/internal/session/taskprogram_test.go new file mode 100644 index 000000000..93b6fc38d --- /dev/null +++ b/internal/session/taskprogram_test.go @@ -0,0 +1,177 @@ +package session + +// A program's work says which program has it (task_contract.go's +// [TaskNotice.Program]): on the proposal a person answers, on the first row its +// run publishes and every row after it, in the checkpoint a reopened +// conversation redraws from, in the project's index every other reader draws +// from, and in the tasks tool's own words. The surfaces draw a badge from it +// (internal/tui3's programbadge.go); these tests hold the engine to carrying it. + +import ( + "context" + "encoding/json" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// THE PROPOSAL NAMES THE PROGRAM, so the card a person approves can say where the +// work is going; the question every reader of a proposal gets says it in words; +// and a task no program is handed asks exactly the sentence it always asked. +func TestAProposalNamesTheProgramItIsGoingTo(t *testing.T) { + spec := taskSpec{title: "rewrite the auth middleware", summary: "swap the session store", via: "senior-dev"} + question := newTaskQuestion(7, spec, "", time.Time{}, Config{}) + if question.notice.Program != "senior-dev" { + t.Fatalf("the proposal's notice names program %q, want senior-dev", question.notice.Program) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + asked := agent.proposalQuestion(7, question.notice) + if want := "wants to start a [senior-dev] task: rewrite the auth middleware"; asked.Head != want { + t.Fatalf("the proposal asks %q, want %q", asked.Head, want) + } + if asked.Subject.Name != "rewrite the auth middleware" { + t.Fatalf("the proposal's subject is %q, want the task's title alone", asked.Subject.Name) + } + + spec.via = "" + ordinary := newTaskQuestion(8, spec, "", time.Time{}, Config{}) + if ordinary.notice.Program != "" { + t.Fatalf("an ordinary proposal names program %q", ordinary.notice.Program) + } + if got := agent.proposalQuestion(8, ordinary.notice).Head; got != TaskProposalLead+"rewrite the auth middleware" { + t.Fatalf("an ordinary proposal asks %q", got) + } + if ProgramBadge(" ") != "" || ProgramBadge("doc-writer") != "[doc-writer]" { + t.Fatalf("the badge's spelling is %q / %q", ProgramBadge(" "), ProgramBadge("doc-writer")) + } +} + +// A TYPED `/<name>` RUN NAMES ITS PROGRAM FROM ITS FIRST ROW TO ITS LAST, and so +// does its row in the project's index: the hand-off's running row, and the row +// that settles it — which the landing publishes without restating the program, +// so it reaches the settled row only because [Agent.publishRunRow] carries it. +// Another conversation's tasks tool says it in words. +func TestAProgramsRunNamesItsProgramFromTheHandOffToTheIndex(t *testing.T) { + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + bucket := t.TempDir() + workspace := newTestRepo(t) + conversation := func(name string) *Agent { + dir := filepath.Join(bucket, name) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + runner, other := conversation("runner"), conversation("other") + + id, _, _, err := runner.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + key := strconv.FormatUint(id, 10) + row, ok := runRowOf(runner.graph(), id) + if !ok || row.Program != "fake" || row.State != TaskRunning { + t.Fatalf("the run's first row = %+v, want it running and naming fake", row) + } + if entry := indexRowFor(t, runner, key); entry.Program != "fake" { + t.Fatalf("the index's running row = %+v, want it naming fake", entry) + } + found, failed := runTool(t, other, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, "via fake") { + t.Fatalf("the other conversation's tasks tool answered %q (failed %v), want the program named", found, failed) + } + + endBeltRun(t, runner, double) + row, ok = runRowOf(runner.graph(), id) + if !ok || !row.State.settled() || row.Program != "fake" { + t.Fatalf("the run's settled row = %+v, want it still naming fake", row) + } + if entry := indexRowFor(t, runner, key); !TaskState(entry.Status).settled() || entry.Program != "fake" { + t.Fatalf("the index's closing row = %+v, want it settled and naming fake", entry) + } +} + +// EVERY PUBLISH AFTER THE FIRST CARRIES THE PROGRAM FORWARD, the way it carries +// the copy: a stop, a landing and a carry-on each publish a row that knows +// nothing about which program had the work, and the row they replace is the only +// place that fact was. A row that names a program of its own is not overruled, +// and a run no program had gains none. +func TestAPublishCarriesTheProgramForward(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + g := agent.graph() + agent.publishRunRow(g, TaskNotice{ID: 11, Title: "the program's run", State: TaskRunning, Program: "fake"}) + agent.publishRunRow(g, TaskNotice{ID: 11, Title: "the program's run", State: TaskDone}) + if row, _ := runRowOf(g, 11); row.Program != "fake" { + t.Fatalf("the settled row = %+v, want the program carried forward", row) + } + agent.publishRunRow(g, TaskNotice{ID: 12, Title: "a bash worker's run", State: TaskRunning}) + agent.publishRunRow(g, TaskNotice{ID: 12, Title: "a bash worker's run", State: TaskDone}) + if row, _ := runRowOf(g, 12); row.Program != "" { + t.Fatalf("a run no program had names %q", row.Program) + } +} + +// THE CHECKPOINT KEEPS THE PROGRAM, so a conversation reopened tomorrow redraws +// its program's run wearing its badge before any plan row is read; and a record +// written before the field existed reads back as no program at all. +func TestARunsRecordKeepsItsProgram(t *testing.T) { + notice := TaskNotice{ID: 7, Title: "rewrite the auth middleware", State: TaskDone, Program: "senior-dev"} + record := runRowRecord(notice) + encoded, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(encoded), `"program":"senior-dev"`) { + t.Fatalf("the record is %s, want the program written down", encoded) + } + var read runRecord + if err := json.Unmarshal(encoded, &read); err != nil { + t.Fatal(err) + } + if back := runRowNotice(read); back.Program != "senior-dev" { + t.Fatalf("the restored row = %+v, want it naming senior-dev", back) + } + var older runRecord + if err := json.Unmarshal([]byte(`{"id":7,"title":"x","state":"done"}`), &older); err != nil { + t.Fatal(err) + } + if runRowNotice(older).Program != "" { + t.Fatal("a record with no program read back as one") + } + if ordinary, _ := json.Marshal(runRowRecord(TaskNotice{ID: 8, State: TaskDone})); strings.Contains(string(ordinary), "program") { + t.Fatalf("an ordinary run's record is %s, which writes an empty program", ordinary) + } +} + +// THE TASKS TOOL SAYS WHICH PROGRAM HAS THE WORK, as the last fact on a row's +// first line, in the word propose_task hands work to one with — and says +// nothing of the kind about a task no program had. +func TestTheTasksToolSaysWhichProgramHasTheWork(t *testing.T) { + entry := TaskIndexEntry{ID: "7", Name: "rewrite-the-auth", Title: "rewrite the auth middleware", Status: string(TaskDone), Program: "senior-dev"} + first, _, _ := strings.Cut(taskRowText(entry), "\n") + if !strings.HasSuffix(first, " · via senior-dev") || !strings.HasPrefix(first, "7 · rewrite-the-auth · ") { + t.Fatalf("a program's row reads %q, want it ending in the program", first) + } + entry.Program = "" + if text := taskRowText(entry); strings.Contains(text, "via") { + t.Fatalf("an ordinary row reads %q", text) + } + + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + listing := agent.planTasksText([]PlanTaskRow{ + {ID: "t-7", Title: "rewrite the auth middleware", Status: "running", Program: "senior-dev"}, + {ID: "t-8", Title: "an ordinary task", Status: "done"}, + }, "") + lines := strings.Split(strings.TrimSpace(listing), "\n") + if len(lines) != 2 || !strings.HasSuffix(lines[0], "running · via senior-dev") || strings.Contains(lines[1], "via") { + t.Fatalf("the run's listing reads:\n%s", listing) + } +} diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index 45fdd8283..1584e0070 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -1263,6 +1263,15 @@ func taskChildRowText(entry TaskIndexEntry, withURI bool) string { // Every clause that has nothing to say is DROPPED rather than written empty. A // row reading "· 0 files · · $0.00" is three facts this build does not have, // stated as though it did. +// +// A PROGRAM'S WORK SAYS WHICH PROGRAM HAS IT, as the last fact on its first +// line (`7 · rewrite-the-auth · working · running for 3m · via senior-dev`), in +// the word `propose_task` hands work to one with. The person's side list wears +// the program's badge on the same row, and a model that could not tell a +// program's work from its own worker's would answer "what is running?" wrongly +// about exactly the work the person can see is different. It trails the figures +// rather than parting the name from its state, which is the pair a reader of +// this line reads first. func taskRowText(entry TaskIndexEntry) string { parts := []string{entry.ID, entry.Name, taskEntryWord(entry)} if word := taskWhenWord(entry); word != "" { @@ -1277,6 +1286,9 @@ func taskRowText(entry TaskIndexEntry) string { if entry.Cost > 0 { parts = append(parts, "$"+strconv.FormatFloat(entry.Cost, 'f', 2, 64)) } + if via := taskViaWord(entry.Program); via != "" { + parts = append(parts, via) + } out := strings.Join(parts, " · ") + "\n " + entry.Title if entry.Outcome != "" { out += "\n " + entry.Outcome @@ -1295,6 +1307,17 @@ func taskRowText(entry TaskIndexEntry) string { return out + "\n" } +// taskViaWord is the clause a program's work carries in this tool's text — +// `via senior-dev` — and "" for every task no program was handed, which says +// nothing rather than `via` and a blank. It is one word for the index's rows and +// the run's store rows alike, so the two listings name a program the same way. +func taskViaWord(program string) string { + if program = strings.TrimSpace(program); program == "" { + return "" + } + return "via " + program +} + // taskWhereClauses is the trailing line a row may carry: where the work IS, the // record's own verdict when the work did not settle whole, and where the STORY // is. It is ONE builder for a root row and a queried child, so the two can never @@ -1464,7 +1487,9 @@ func planTaskLabels(rows []PlanTaskRow) map[string]string { // // A PROGRAM'S RUN SAYS HOW LONG IT TOOK, off the run's one pair // ([planRowSpanWord], task_run_clock.go): the tool said no time at all, and a -// model asked how long senior-dev took could only guess. +// model asked how long senior-dev took could only guess. AND IT SAYS WHICH +// PROGRAM HAS IT, after the clock, in [taskViaWord]'s one spelling — the same +// place on the line [taskRowText] puts it. func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { query = strings.ToLower(strings.TrimSpace(query)) labels := planTaskLabels(rows) @@ -1479,6 +1504,9 @@ func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { if span := planRowSpanWord(row, now); span != "" { fmt.Fprintf(&b, " · %s", span) } + if via := taskViaWord(row.Program); via != "" { + fmt.Fprintf(&b, " · %s", via) + } if line := summaryFirstLine(page.Result, runAskLineChars); line != "" { fmt.Fprintf(&b, " · %s", line) } @@ -1513,6 +1541,9 @@ func (a *Agent) planTaskText(rows []PlanTaskRow, token string) (string, bool) { if span := planRowSpanWord(page.Row, a.taskClockNow()); span != "" { head += " · " + span } + if via := taskViaWord(page.Row.Program); via != "" { + head += " · " + via + } fmt.Fprintf(&b, "%s\n\nbrief:\n%s\n", head, cutChars(page.Description, runAskBodyChars)) if page.Result != "" { fmt.Fprintf(&b, "\nresult:\n%s\n", cutChars(page.Result, runAskBodyChars)) diff --git a/internal/tui3/task.go b/internal/tui3/task.go index b8f83b444..2c4382aea 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -1374,7 +1374,7 @@ func (a *app) taskQuestion(notice *session.TaskNotice) session.Question { Ask: session.AskPermission, Form: session.FormCard, Asker: session.Asker{Kind: session.AskerModel}, - Head: session.TaskProposalLead + strings.TrimSpace(notice.Title), + Head: session.TaskProposalHead(*notice), Reason: strings.TrimSpace(notice.Summary), Subject: session.SubjectRef{Kind: session.SubjectNode, ID: notice.ID, Name: strings.TrimSpace(notice.Title)}, Options: session.AnswerOptions(session.QuestionTask), From b4d860c79161b742ee888c05c135e3d508386735 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:10:25 -0400 Subject: [PATCH 115/195] tui3, manual: a program's tasks wear its badge wherever a task is named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A senior-dev run and an ordinary /task drew the same first line on the side list — a state glyph, a title, a handle — and the same card, strip chip, page head, @ row and tasks-place row, so the one thing that decides what the work will do and how long it will take was on none of them. The owner asked for a prominent `[senior-dev]` on a program's task and nothing on an ordinary one, with any later program getting a badge of its own. Now programbadge.go derives a program's badge from its name alone — the name in brackets, and its hyphen parts' initials (`[sd]`) where a list is narrow — and paints it bold in the accent with one painter; the brackets are always drawn, so no colour, the selected row and a screen reader still show it. The side list fits it in the trailing slot after the title, never the lead: the handle goes first as the column narrows, then the badge falls to its short spelling, then the title is cut to its floor. It is not a press target. It is also on the approval card's head, the room's title row, the stored page's head, the strip (short), the @ list and its pointer block, the tasks place and home. A node takes its program from the notice (or the card, or for an older engine the held plan row) and keeps it like its kind, so the badge is there before any plan row is read and after a conversation switch. An ordinary task's rows are unchanged to the byte. The manual has a section a person finds by asking how to tell a senior-dev task from a normal one, and delegates.md says every program's tasks wear its name as a badge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 11 +- internal/manual/chat/senior-dev.md | 32 +- internal/manual/chat_test.go | 7 + internal/tui3/homeband_work.go | 11 +- internal/tui3/homepanel_left.go | 4 + internal/tui3/homepanel_needs.go | 4 + internal/tui3/place_sessions.go | 3 + internal/tui3/programbadge.go | 190 +++++++++++ internal/tui3/programbadge_test.go | 421 +++++++++++++++++++++++++ internal/tui3/roompanel.go | 20 +- internal/tui3/task.go | 62 +++- internal/tui3/taskconversation_test.go | 4 +- internal/tui3/taskmention.go | 17 +- internal/tui3/taskmodel_test.go | 3 +- internal/tui3/taskplan.go | 33 +- internal/tui3/tasksplace.go | 9 +- internal/tui3/taskstable.go | 15 +- internal/tui3/taskstrip.go | 12 +- 18 files changed, 826 insertions(+), 32 deletions(-) create mode 100644 internal/tui3/programbadge.go create mode 100644 internal/tui3/programbadge_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index c7aaca66c..8464b65a5 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -23,6 +23,11 @@ task's, shows the actions the program took, each under the step of its own proce 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**: `[<name>]` 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. @@ -39,9 +44,9 @@ turn goes on, and the row appears on the rail. The model can choose one as well. `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 -[<name>] task: <title>`. 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. +[<name>] task: <title>`, 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 <name> <brief>` 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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 1c5c4c1c1..333669cc6 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -21,10 +21,11 @@ a feature with its tests. A change you would make in a few steps is not worth it ## 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 with the step it is in and what it has spent so far, 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 `Home` beside it. senior-dev gets no tab of its own. +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 `Home` 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 — its brief, the workspace it set up, what it read and ran and changed, @@ -70,6 +71,29 @@ under way, drawn quieter: `told its model what it found, and to finish and hand tool call written as text corrected — and `compacted its memory` and `switched to <model>` 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: <title>`, and the + card's top line wears the badge beside the task's name. +- **The task's own page** wears it beside the title. +- **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's list of work** wear `[senior-dev]`. +- **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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 1af8f0ddc..b9da95372 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -940,6 +940,13 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"which folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/tui3/homeband_work.go b/internal/tui3/homeband_work.go index d8c717c97..4260622a9 100644 --- a/internal/tui3/homeband_work.go +++ b/internal/tui3/homeband_work.go @@ -172,12 +172,21 @@ func homeWorkNodeRows(node homeWorkNode, row session.SessionRow, width int, now // homeWorkName is a task's first line: what it is CALLED, and how long ago it // landed, hard against the right edge. +// +// A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME (programbadge.go), fitted +// with the name before the row lays the two sides out, so the age can move to +// a line of its own and the badge is still never what gets cut. An ordinary +// task's name is handed over exactly as it always was. func homeWorkName(entry session.TaskIndexEntry, width int, now time.Time, pal palette) []string { label := strings.TrimSpace(entry.Label) if label == "" { label = strings.TrimSpace(entry.Title) } - return bandSides(width, homeWorkIndent, 8, label, sinceAt(entry.EndedAt, now), pal.muted, pal.dim) + ink := pal.muted + if strings.TrimSpace(entry.Program) != "" && label != "" { + label, ink = pal.programLabel(label, entry.Program, width, pal.muted) + } + return bandSides(width, homeWorkIndent, 8, label, sinceAt(entry.EndedAt, now), ink, pal.dim) } // homeWorkUnder is a task's outcome rows, and nil when there is nothing true to diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 25d009946..5966eefac 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -131,6 +131,10 @@ func ledgerTaskLine(entry session.TaskIndexEntry, row session.SessionRow) string if label == "" { label = strings.TrimSpace(entry.Title) } + // A PROGRAM'S WORK SAYS WHOSE IT IS, with its badge after its name + // (programbadge.go) — the brackets alone, because this line is measured and + // painted whole by the cell that draws it. + label = programText(label, entry.Program) if label == "" { label = homeName(row) } diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 02cd64921..4b65e4ae3 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -326,6 +326,10 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta if title == "" { title = strings.TrimSpace(entry.Title) } + // A PROGRAM'S WORK SAYS WHOSE IT IS, with its badge after its name + // (programbadge.go) — the brackets alone, because the cell measures and + // paints its title whole. + title = programText(title, entry.Program) asked := needsCallAt(entry) // A LANDING WEARS NO MARK (law 8). The amber `?` means a thing has stopped // and will not move until somebody answers it; a landing has already diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index c9b4b9fc4..ce4fa96ba 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -615,6 +615,9 @@ func (a *app) taskSheetOwnRows() []session.TaskIndexEntry { SessionID: self, StartedAt: node.started, EndedAt: taskNodeEnded(node), + // The program the node's work was handed to, so the row drawn off it + // wears the badge the node's own row does (programbadge.go). + Program: a.nodeProgram(node), }) } return rows diff --git a/internal/tui3/programbadge.go b/internal/tui3/programbadge.go new file mode 100644 index 000000000..f08c5a1d7 --- /dev/null +++ b/internal/tui3/programbadge.go @@ -0,0 +1,190 @@ +package tui3 + +// THE PROGRAM'S BADGE: how a person tells work handed to a program codeaf +// carries — senior-dev — from a task this conversation's own worker does. +// +// The two looked the same everywhere a task is named. A senior-dev run and an +// ordinary `/task` drew the same first line on the side list — a state glyph, a +// title, a handle — and the card a person approved said `wants to start a +// task:` for both, so the one thing about the work that decides what it will do +// and how long it will take was not on the screen at all. The owner asked for a +// prominent badge like `[senior-dev]` on the task's row, none on an ordinary +// task's, and a badge of its own for every program that comes after it. +// +// SO THE BADGE IS THE PROGRAM'S NAME IN BRACKETS, AND NOTHING ELSE MAKES IT. A +// program's [session.TaskNotice.Program] is the name its own command row says +// out loud, and the delegate package keeps that name to one plain ASCII shape +// (internal/delegate's nameShape), so a name can be bracketed as it stands. A +// program added next year gets its badge by existing: there is no table here to +// forget to add it to. Its short spelling is the initials of the name's +// hyphen-separated parts — `[sd]`, `[dw]` for a doc-writer — which is what a +// twenty-four-cell column can afford. +// +// THE BRACKETS ARE ALWAYS DRAWN. The badge is painted in the accent, bold, +// because the owner asked for it to be prominent and the accent is this +// surface's one loud voice (docs/DESIGN-LANGUAGE.md's accent budget: it marks +// the live or chosen thing, and a program's work is the one row on a column that +// is a different kind of thing) — but ink is not always there to be read. A +// terminal with no colour draws none; a row somebody is standing in is laid on +// the selected ground, which swallows a chip's; and a screen reader reads words. +// The brackets are what is left in all three, so they are part of the badge and +// never decoration around it ([session.ProgramBadge] spells them once). +// +// IT IS NEVER A LEAD AND NEVER A TARGET. The badge stands after the title, in +// the row's trailing slot — never in front of it, where the column's law keeps +// one state glyph and nothing else (railclick_test.go's +// TestTheColumnLeadsWithStateAndSpendsNoCellOnIdentity) — and it is not a press +// target of its own: a press anywhere on a task's row opens that task. + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// programBadge is a program's badge in its spellings, longest first: the whole +// name in brackets and its initials in brackets. A name with nothing to say +// is the unknown field ([rowSay] with no spellings), which every caller draws +// as nothing — the emptiness law, and the whole of what an ordinary task gets. +func programBadge(name string) rowField { + name = strings.TrimSpace(name) + full := session.ProgramBadge(name) + if full == "" { + return rowField{} + } + short := session.ProgramBadge(programInitials(name)) + if short == full { + short = "" + } + return rowSay(full, short) +} + +// programInitials is the first character of each hyphen-separated part of a +// program's name: `senior-dev` is `sd`. The name's shape is the delegate +// package's guarantee — lowercase ASCII words joined by single hyphens — so a +// byte is a character here. +func programInitials(name string) string { + var out strings.Builder + for _, part := range strings.Split(name, "-") { + if part != "" { + out.WriteByte(part[0]) + } + } + return out.String() +} + +// programSpelling is the longest spelling of a badge that still leaves a title +// the floor of cells beside it — or the whole title, when that is shorter than +// the floor — in room cells, with one cell of air between them. It answers "" +// when no spelling does, which only a row indented deeper than any program's +// row ever sits can meet: the title is the row's identity, and a badge that cost +// it below the floor would be a mark that erased the thing it marks. +func programSpelling(badge rowField, title string, room, floor int) string { + need := min(ansi.StringWidth(title), floor) + for _, spelling := range [...]string{badge.full, badge.short} { + if spelling != "" && room-ansi.StringWidth(spelling)-1 >= need { + return spelling + } + } + return "" +} + +// programCells is what a chosen spelling costs a row: its own cells and the one +// cell of air in front of it, and nothing for no badge. +func programCells(spelling string) int { + if spelling == "" { + return 0 + } + return ansi.StringWidth(spelling) + 1 +} + +// programInk paints a badge spelling. It is the ONE painter every surface +// draws a program's badge with, so the badge reads the same on the side list, +// the strip, the card, a page's head and the tasks place. +func (p palette) programInk(spelling string) string { + return p.bold(p.accent(spelling)) +} + +// programAfter is a badge as it follows a title: one cell of air and the badge, +// painted, or nothing for no badge. +func (p palette) programAfter(spelling string) string { + if spelling == "" { + return "" + } + return " " + p.programInk(spelling) +} + +// programTitled is a title fitted into room cells with its program's badge +// after it, painted — the title in the row's own ink and the badge in its own. +// The badge keeps its long spelling while the title keeps [railTitleFloor] +// cells beside it, falls to its short one after that, and the title is cut into +// whatever is left; an ordinary task's title is simply fitted as it always was. +func (p palette) programTitled(title, program string, room int, ink func(string) string) string { + spelling := programSpelling(programBadge(program), title, room, railTitleFloor) + return ink(fit(title, room-programCells(spelling))) + p.programAfter(spelling) +} + +// programLabel is [palette.programTitled] for a row that paints its label in +// ONE call it does not own (home's [bandSides] is the one caller): the title +// and the badge as one string fitted into room cells, and an ink that paints +// the title with the row's own and the badge with [palette.programInk]. The +// label is fitted here, before the row sees it, so the row never has to cut it +// and the badge on its end is never what a cut takes. +func (p palette) programLabel(title, program string, room int, ink func(string) string) (string, func(string) string) { + spelling := programSpelling(programBadge(program), title, room, railTitleFloor) + if spelling == "" { + return title, ink + } + label := fit(title, room-programCells(spelling)) + " " + spelling + return label, func(s string) string { + if head, ok := strings.CutSuffix(s, " "+spelling); ok { + return ink(head) + p.programAfter(spelling) + } + return ink(s) + } +} + +// programText is a title with its program's badge after it, as plain words, for +// a row whose title is measured and painted as one piece of text by a renderer +// of its own (home's cells). The brackets carry the badge there on their own, +// which is the reason they are always drawn. +func programText(title, program string) string { + badge := session.ProgramBadge(program) + if badge == "" || strings.TrimSpace(title) == "" { + return title + } + return title + " " + badge +} + +// pageProgram is the program a stored page's task was handed to: the name the +// program's own record gives it, and the name its row carries while that record +// has not reached the disk — "" for every page no program was handed. +func pageProgram(page session.PlanTaskPage) string { + if page.Program != nil { + if name := strings.TrimSpace(page.Program.Name); name != "" { + return name + } + } + return strings.TrimSpace(page.Row.Program) +} + +// nodeProgram is the program a node's work was handed to, and "" for every +// ordinary task. It is the node's own fact, taken from the engine's notices +// ([taskNode.program]) — and, for a node whose notices named none because the +// engine that sent them predates the field, the name the run's own plan row +// carries ([app.railProgramRow]), which is a row the surface already holds and +// never a read made while drawing. +func (a *app) nodeProgram(node *taskNode) string { + if node == nil { + return "" + } + if node.program != "" { + return node.program + } + if row, ok := a.railProgramRow(node); ok { + return strings.TrimSpace(row.Program) + } + return "" +} diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go new file mode 100644 index 000000000..3c1291fc2 --- /dev/null +++ b/internal/tui3/programbadge_test.go @@ -0,0 +1,421 @@ +package tui3 + +// A program's work wears its badge everywhere a task is named, and an ordinary +// task wears nothing. These tests drive the badge the way a window meets it — +// off the engine's own notices, before any plan row has been read — and hold it +// to the column's laws: it never leads, it never costs the title its floor +// before the handle has gone, it is never a press target, and it is drawn +// without asking the agent anything. + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// programTitle is the task the fixtures hand to a program. +const programTitle = "rewrite the auth middleware" + +// programNotice is the first row a program's run publishes: running, from the +// hand-off, and naming the program that has the work. +func programNotice(program string) session.TaskNotice { + return session.TaskNotice{Program: program, StartedAt: taskFixtureNow.Add(-3 * time.Minute)} +} + +// programRailApp is a window holding one program's run, known from its notice +// alone — no plan row has been read, which is the first second of every run and +// the first frame after every conversation switch. +func programRailApp(t *testing.T, program string) *app { + t.Helper() + a, _, _ := taskApp(t) + a.height = 30 + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, programTitle, session.TaskRunning, programNotice(program))}) + if node := a.tasks[7]; node == nil || node.program != program { + t.Fatalf("the node did not keep its program: %+v", a.tasks[7]) + } + return a +} + +// programRailRow is the program's head line on the column at a frame width, +// widened or not, painted. +func programRailRow(t *testing.T, a *app, width int, wide bool) string { + t.Helper() + a.width, a.railWide = width, wide + for _, row := range a.railRows(a.viewHeight()) { + if strings.Contains(plain(row), "rewrite") { + return row + } + } + t.Fatalf("the program's row is not on the column at %d columns:\n%s", width, + strings.Join(railText(a, a.viewHeight()), "\n")) + return "" +} + +// THE BADGE AT EVERY WIDTH THE COLUMN STANDS AT, in the order the column gives +// its cells up: at thirty columns the whole badge and no handle, at twenty-four +// the short badge and the handle back, and at forty-six both whole. Every row +// fits its column, and the title still starts two cells past the seam. +func TestAProgramsRowWearsItsBadgeAtEveryWidthOfTheColumn(t *testing.T) { + a := programRailApp(t, "senior-dev") + for _, tier := range []struct { + name string + width int + wide bool + cols int + want, never []string + }{ + {"railCols", 120, false, railCols, []string{"[senior-dev]"}, []string{"#7", "[sd]"}}, + {"railSlimCols", 110, false, railSlimCols, []string{"[sd]", "#7"}, []string{"[senior-dev]"}}, + {"railWideCols", 120, true, railWideCols, []string{"[senior-dev]", "#7"}, []string{"[sd]"}}, + } { + row := plain(programRailRow(t, a, tier.width, tier.wide)) + t.Logf("%s: %q", tier.name, row) + for _, word := range tier.want { + if !strings.Contains(row, word) { + t.Fatalf("at %s the program's row is %q, want %q on it", tier.name, row, word) + } + } + for _, word := range tier.never { + if strings.Contains(row, word) { + t.Fatalf("at %s the program's row is %q, which should not carry %q", tier.name, row, word) + } + } + if cells := ansi.StringWidth(row); cells > tier.cols { + t.Fatalf("at %s the row is %d cells in a %d-cell column: %q", tier.name, cells, tier.cols, row) + } + if got, want := leadCells(t, row, "rewrit"), ansi.StringWidth(railSeam)+2; got != want { + t.Fatalf("at %s the title starts at cell %d, want %d — the badge must never lead:\n%q", tier.name, got, want, row) + } + // AND THE BADGE COMES AFTER THE TITLE, never before it. + if strings.Index(row, "[") < strings.Index(row, "rewrit") { + t.Fatalf("at %s the badge stands in front of the title: %q", tier.name, row) + } + } +} + +// AN ORDINARY TASK WEARS NO BADGE, at any width — the goldens in +// railtree_test.go and railwork_test.go hold its row to the byte; this holds the +// one fact they cannot, that nothing bracketed appears for work no program had. +func TestAnOrdinaryTasksRowWearsNoBadge(t *testing.T) { + a := programRailApp(t, "senior-dev") + a.taskUpdate(update(8, "Fix the loader nil-map", session.TaskRunning, session.TaskNotice{})) + for _, width := range []int{110, 120} { + a.width = width + row, ok := railRowFor(a, a.viewHeight(), "Fix the loader") + if !ok { + t.Fatalf("the ordinary task is not on the column at %d", width) + } + if strings.Contains(row, "[") || !strings.Contains(row, "#8") { + t.Fatalf("an ordinary task's row at %d columns is %q, want its handle and no badge", width, row) + } + } + if programBadge("").known() { + t.Fatal("no program answered a badge") + } +} + +// ANY PROGRAM GETS ITS OWN BADGE FROM ITS NAME, and nothing here names +// senior-dev: a program that ships next year needs no change to this surface. +func TestASecondProgramDrawsItsOwnBadge(t *testing.T) { + a := programRailApp(t, "doc-writer") + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[doc-writer]") || strings.Contains(row, "senior-dev") { + t.Fatalf("a doc-writer's row is %q, want its own badge", row) + } + if row := plain(programRailRow(t, a, 110, false)); !strings.Contains(row, "[dw]") { + t.Fatalf("a doc-writer's narrow row is %q, want its initials", row) + } + for name, want := range map[string]rowField{ + "senior-dev": {full: "[senior-dev]", short: "[sd]"}, + "doc-writer": {full: "[doc-writer]", short: "[dw]"}, + "gpt-5-writer": {full: "[gpt-5-writer]", short: "[g5w]"}, + "fake": {full: "[fake]", short: "[f]"}, + } { + if got := programBadge(name); got != want { + t.Fatalf("programBadge(%q) = %+v, want %+v", name, got, want) + } + } +} + +// THE BADGE IS NOT A TARGET. A press on it opens the program's task, the way a +// press anywhere else on the row does, and folds nothing: the only span a row +// reports beside its fold cell is a folded family's count. +func TestAPressOnTheBadgeOpensTheTaskAndFoldsNothing(t *testing.T) { + a, _, _ := roomApp(t) + a.width, a.height = 120, 30 + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskRunning, programNotice("senior-dev"))}) + if a.tasks[7].program != "senior-dev" { + t.Fatalf("a same-state row naming the program was thrown away by the de-dup: %+v", a.tasks[7]) + } + _, cell, count := a.railEntryRows(railEntry{node: a.tasks[7]}, a.railRoom()) + if cell.pressable() || count.pressable() { + t.Fatalf("the program's row reported a pressable span: cell %+v, count %+v", cell, count) + } + y := railRowY(t, a, 7) + row := railText(a, a.viewHeight())[y-a.bodyTop()] + at := strings.Index(row, "[senior-dev]") + if at < 0 { + t.Fatalf("the program's row wears no badge: %q", row) + } + opened := map[uint64]bool{} + for id, open := range a.railOpen { + opened[id] = open + } + railClick(t, a, a.bodyWidth()+ansi.StringWidth(row[:at])+1, y) + if !a.roomOpen() || roomID(a) != 7 { + t.Fatalf("a press on the badge did not open the task: room %d", roomID(a)) + } + for id, open := range a.railOpen { + if opened[id] != open { + t.Fatalf("a press on the badge folded row %d", id) + } + } +} + +// DRAWING THE BADGE READS NOTHING. It comes off the node the notice made, and +// for a node an older engine never named, off the plan rows the surface already +// holds — never a call to the agent while a frame is being drawn. +func TestDrawingAProgramsBadgeReadsNothingFromTheAgent(t *testing.T) { + row := programRow() + row.ID = "7" + a, fake := planAppWith(t, []session.PlanTaskRow{row}, nil) + counted := &railPlanCounter{planFake: fake} + a.agent = counted + a.width, a.height = 120, 30 + // AN OLDER ENGINE'S ROW, naming no program: the badge is read off the held + // plan row. + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, session.TaskRunning, session.TaskNotice{StartedAt: programRunBegan})}) + rows, pages := counted.rows, counted.pages + if drawn := plain(strings.Join(a.railRows(a.viewHeight()), "\n")); !strings.Contains(drawn, "[senior-dev]") { + t.Fatalf("a node named only by its held plan row wears no badge:\n%s", drawn) + } + a.stripRow(90) + a.taskSheetOwnRows() + if counted.rows != rows || counted.pages != pages { + t.Fatalf("drawing the badge read the agent: rows %d→%d, pages %d→%d", rows, counted.rows, pages, counted.pages) + } +} + +// THE BADGE IS THERE BEFORE ANY PLAN ROW IS READ AND AFTER A CONVERSATION +// SWITCH. It used to be learned only off the run's plan rows, which a window +// reads on a beat of its own and stops holding the moment the conversation in +// front changes; the notice carries it now, so neither gap takes it away. +func TestTheBadgeOutlivesTheGapsInThePlanRows(t *testing.T) { + a := programRailApp(t, "senior-dev") + if _, held := a.heldPlanRows(); held { + t.Fatal("the fixture holds plan rows, so this would prove nothing about the notice") + } + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("before any plan row was read the program's row is %q", row) + } + // A SWITCH AWAY AND BACK: the held rows belong to another front, and the + // roster is replayed from the run's kept rows, which carry the program. + a.frontGen++ + a.tasks, a.taskOrder, a.taskSeen = nil, nil, nil + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, programTitle, session.TaskRunning, programNotice("senior-dev"))}) + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("after a conversation switch the program's row is %q", row) + } +} + +// THE BRACKETS SURVIVE EVERY WAY OF DRAWING THE ROW: a terminal with no colour, +// the screen-reader tier, and the row a person is standing in, whose ground +// would swallow a chip. The painted badge is accent and bold where there is ink +// to paint with. +func TestTheBadgesBracketsSurviveEveryPalette(t *testing.T) { + a := programRailApp(t, "senior-dev") + a.width = 120 + painted := programRailRow(t, a, 120, false) + if want := a.pal.programInk("[senior-dev]"); !strings.Contains(painted, want) { + t.Fatalf("the badge is not painted by the one painter:\n%q\nwant it to carry %q", painted, want) + } + a.pal = newPalette(tokens.NoColor, false) + if row := programRailRow(t, a, 120, false); row != plain(row) || !strings.Contains(row, "[senior-dev]") { + t.Fatalf("with no colour the row is %q, want plain text carrying the brackets", row) + } + a.pal = newPalette(tokens.ANSI256, true) + a.linear = true + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("on the screen-reader tier the row is %q", row) + } + a.linear = false + a.pal = newPalette(tokens.ANSI256, false) + a.openRoom(7, programTitle) + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("the row a person is standing in is %q, want the brackets on the selected ground", row) + } +} + +// THE ROOM'S OWN PANEL: the tree beside an open room is the same rows, and the +// title row over the room names the program too. +func TestTheRoomPanelAndItsTitleWearTheBadge(t *testing.T) { + a, _, _ := roomApp(t) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskRunning, programNotice("senior-dev"))}) + a.width, a.height = 120, 48 + a.openRoom(7, "Fix the nil-map crash") + a.railHold = false + if !a.roomPanelShowing(a.viewHeight()) { + t.Fatal("the room's panel is not showing, so this would prove nothing") + } + lines, _ := a.railView(a.viewHeight()) + var tree []string + for _, line := range lines { + if line.roomSection == roomPanelTree { + tree = append(tree, plain(line.text)) + } + } + if !strings.Contains(strings.Join(tree, "\n"), "[senior-dev]") { + t.Fatalf("the room panel's tree does not wear the badge:\n%s", strings.Join(tree, "\n")) + } + title := plain(a.roomTitleRow(a.width)) + if !strings.Contains(title, "Fix the nil-map crash [senior-dev]") { + t.Fatalf("the room's title row is %q, want the badge beside the title", title) + } + if cells := ansi.StringWidth(title); cells > a.width { + t.Fatalf("the room's title row is %d cells in a %d-cell row", cells, a.width) + } +} + +// THE CARD A PERSON APPROVES NAMES THE PROGRAM: the block in the transcript +// wears the badge beside the name, and the question above the box says it in +// words — the same sentence the engine's own question object says. +func TestTheApprovalCardNamesTheProgram(t *testing.T) { + a, _, _ := taskApp(t) + ev := proposal(a, 7, 4*time.Second) + ev.Task.Program = "senior-dev" + drive(t, a, streamEventMsg{gen: a.gen, ev: ev}) + settleAsk(a) + text := taskText(a) + var drawn string + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), taskHeadCorner) { + drawn = line + } + } + if !strings.Contains(drawn, "Fix the nil-map") || !strings.Contains(drawn, "[senior-dev]") { + t.Fatalf("the card's head is %q, want its name and the program's badge:\n%s", drawn, text) + } + head := plain(a.taskHead(a.task, 80, false)) + if !strings.Contains(head, "[senior-dev]") || ansi.StringWidth(head) > 80 { + t.Fatalf("the card's head is %q, want the badge inside the frame", head) + } + ask := taskAsk(a) + if !strings.Contains(ask, "wants to start a [senior-dev] task: Fix the nil-map crash") { + t.Fatalf("the question does not name the program:\n%s", ask) + } + if got, want := a.taskQuestion(ev.Task).Head, session.TaskProposalHead(*ev.Task); got != want { + t.Fatalf("the surface asks %q and the engine %q — two questions about one proposal", got, want) + } + // AND THE RUN'S ROW, WHEN IT ARRIVES QUIET ABOUT ITS PROGRAM, KEEPS THE CARD'S. + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskRunning, session.TaskNotice{})}) + if node := a.tasks[7]; node == nil || node.program != "senior-dev" { + t.Fatalf("the approved node did not take the card's program: %+v", node) + } + // An ordinary proposal asks the sentence it always asked. + plainAsk := session.TaskProposalHead(session.TaskNotice{Title: "Fix the nil-map crash"}) + if plainAsk != session.TaskProposalLead+"Fix the nil-map crash" { + t.Fatalf("an ordinary proposal asks %q", plainAsk) + } +} + +// THE OTHER LISTS OF THE WORK: the strip that stands in for the column under a +// hundred columns wears the short badge, the `@` list and the tasks place wear +// the whole one, and home's work band too — and none of them moves a column or +// draws anything for an ordinary task. +func TestTheOtherListsOfTheWorkWearTheBadge(t *testing.T) { + a := programRailApp(t, "senior-dev") + chip, cols := a.stripLabel(a.tasks[7], 90) + if !strings.Contains(plain(chip), "[sd]") || ansi.StringWidth(chip) != cols { + t.Fatalf("the strip's chip is %q in %d cells, want the short badge and an honest width", plain(chip), cols) + } + a.taskUpdate(update(8, "Fix the loader nil-map", session.TaskRunning, session.TaskNotice{})) + if chip, _ := a.stripLabel(a.tasks[8], 90); strings.Contains(plain(chip), "[") { + t.Fatalf("an ordinary task's chip is %q", plain(chip)) + } + + entry := session.TaskIndexEntry{ID: "7", Label: programTitle, Title: programTitle, Status: "running", Program: "senior-dev"} + if label := plain(taskRowLabel(entry, a.pal)); !strings.HasSuffix(label, programTitle+" [senior-dev]") { + t.Fatalf("the @ list's row is %q", label) + } + if block := taskPointerBlock(entry); !strings.Contains(block, "· via senior-dev") { + t.Fatalf("the mention's block does not say which program had the work:\n%s", block) + } + ordinary := entry + ordinary.Program = "" + if label := plain(taskRowLabel(ordinary, a.pal)); strings.Contains(label, "[") { + t.Fatalf("an ordinary @ row is %q", label) + } + + withBadge := plain(tasksTableRow(" ", 2, programTitle, "senior-dev", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, "", "")) + without := plain(tasksTableRow(" ", 2, programTitle, "", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, "", "")) + if !strings.Contains(withBadge, programTitle+" [senior-dev]") { + t.Fatalf("the tasks place's row is %q", withBadge) + } + if ansi.StringWidth(withBadge) != ansi.StringWidth(without) || strings.Index(withBadge, "running") != strings.Index(without, "running") { + t.Fatalf("the badge moved the table's columns:\n%q\n%q", withBadge, without) + } + if head := plain(tasksCardHead(tasksItem{entry: entry}, 40, a.pal, false)); !strings.Contains(head, "[senior-dev]") || ansi.StringWidth(head) > 40 { + t.Fatalf("the tasks place's phone card head is %q", head) + } + + band := plain(strings.Join(homeWorkName(entry, 44, taskFixtureNow, a.pal), "\n")) + if !strings.Contains(band, programTitle+" [senior-dev]") { + t.Fatalf("home's work band reads %q", band) + } + if band := plain(strings.Join(homeWorkName(ordinary, 44, taskFixtureNow, a.pal), "\n")); strings.Contains(band, "[") { + t.Fatalf("home's work band reads %q for an ordinary task", band) + } +} + +// THE BADGE NEVER CUTS A TITLE BELOW ITS FLOOR. It falls to its short spelling +// first, and where even that would leave the title less than the floor the row +// draws no badge at all rather than a badge standing for a name nobody can read. +func TestTheBadgeYieldsBeforeTheTitleFloor(t *testing.T) { + badge := programBadge("senior-dev") + for _, probe := range []struct { + room int + want string + }{ + {26, "[senior-dev]"}, + {25, "[senior-dev]"}, + {24, "[sd]"}, + {17, "[sd]"}, + {16, ""}, + } { + if got := programSpelling(badge, programTitle, probe.room, railTitleFloor); got != probe.want { + t.Fatalf("in %d cells the badge is %q, want %q", probe.room, got, probe.want) + } + } + // A title shorter than the floor asks only for its own cells. + if got := programSpelling(badge, "fix it", 19, railTitleFloor); got != "[senior-dev]" { + t.Fatalf("a short title left the badge %q, want the whole of it", got) + } +} + +// THE RUN'S OWN PLAN ROW WEARS THE BADGE TOO. A program's store root is the same +// work as its node's row and is normally left out beside it, so when it is the +// row the column draws — the node's row was dropped for it — it is the only +// place the program's work is named, and it has to say whose it is. An ordinary +// plan row is drawn as it always was. +func TestAProgramsPlanRowOnTheRailWearsTheBadge(t *testing.T) { + row := programRow() + pal := newPalette(tokens.ANSI256, false) + item := planItem(row, "chat-1", planKinOf([]session.PlanTaskRow{row})) + drawn := plain(planRailRow(tasksLine{item: item}, railCols-ansi.StringWidth(railSeam), pal, taskFixtureNow)) + t.Logf("the plan row: %q", drawn) + if !strings.Contains(drawn, "rewrite the") || !strings.Contains(drawn, "[senior-dev]") { + t.Fatalf("a program's plan row on the rail reads %q, want the program's badge", drawn) + } + if cells := ansi.StringWidth(drawn); cells > railCols-ansi.StringWidth(railSeam) { + t.Fatalf("the plan row is %d cells in a %d-cell column: %q", cells, railCols-ansi.StringWidth(railSeam), drawn) + } + row.Program, row.Stage = "", "" + ordinary := planItem(row, "chat-1", planKinOf([]session.PlanTaskRow{row})) + if drawn := plain(planRailRow(tasksLine{item: ordinary}, railCols-ansi.StringWidth(railSeam), pal, taskFixtureNow)); strings.Contains(drawn, "[") { + t.Fatalf("an ordinary plan row reads %q", drawn) + } +} diff --git a/internal/tui3/roompanel.go b/internal/tui3/roompanel.go index f97d0146e..a0ed6c2e2 100644 --- a/internal/tui3/roompanel.go +++ b/internal/tui3/roompanel.go @@ -310,8 +310,24 @@ func (a *app) roomTitleRow(width int) string { } } room := max(width-headLabelAt-2-ansi.StringWidth(right)-3, 1) - left = fit(left, room) - return strings.Repeat(" ", headLabelAt) + a.pal.bold(a.pal.ink(left)) + strings.Repeat(" ", max(width-headLabelAt-2-ansi.StringWidth(left)-ansi.StringWidth(right), 1)) + painted + " " + // A PROGRAM'S TASK WEARS ITS PROGRAM'S BADGE BESIDE ITS TITLE, the one its row + // wears on the side list (programbadge.go), paid for out of the title's half + // of the row and never the facts'. An ordinary task spends nothing on it. + wears := programSpelling(programBadge(a.roomProgram()), left, room, railTitleFloor) + left = fit(left, room-programCells(wears)) + return strings.Repeat(" ", headLabelAt) + a.pal.bold(a.pal.ink(left)) + a.pal.programAfter(wears) + strings.Repeat(" ", max(width-headLabelAt-2-ansi.StringWidth(left)-programCells(wears)-ansi.StringWidth(right), 1)) + painted + " " +} + +// roomProgram is the program the open room's task was handed to: the name its +// stored page gives it on a program's room, and the node's own otherwise — "" +// for every ordinary task. +func (a *app) roomProgram() string { + if p := a.programOf(); p != nil { + if name := pageProgram(p.page); name != "" { + return name + } + } + return a.nodeProgram(a.roomNode()) } // The expanded layout is a height decision independent of the body's measured diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 2c4382aea..71475a6ff 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -78,6 +78,13 @@ type taskCard struct { // OWED, and until it lands the way to ask for another is to say so in the // words `c change` takes. model string + // program is the program this work is going to — senior-dev — and "" for a + // task this conversation's own worker will do (session's + // TaskNotice.Program). The head wears its badge beside the name + // ([app.taskHead]), because who the work is being handed to is the one thing + // about it a person approving it cannot find out afterwards and do anything + // about. + program string // elsewhere is the one dim line saying which of this brief's files another // window's work is already in, as the engine wrote it (session's // TaskNotice.Elsewhere), and "" when there was nothing to say. @@ -308,6 +315,15 @@ type taskNode struct { // that is true before it starts and after it lands, and it is what keeps a // card from promising a branch to a node that could never have one. kind session.TaskKind + // program is the program this node's work was handed to — senior-dev — as + // the engine names it (session's TaskNotice.Program), and "" for an ordinary + // task. It is what the node's badge is drawn from (programbadge.go), and it + // is kept on [taskNode.kind]'s rule: written once, from the proposal's card or + // the first notice that names it, and never cleared, because which program has + // the work is settled before the work starts and nothing afterwards moves it. + // Read it through [app.nodeProgram], which also answers for a node an older + // engine never named. + program string // doing is the phase this node is in, in its own kind's plain words — // "designing", "awaiting your look" — and empty for an ordinary task, which // has no phases (session's TaskNotice.Doing). @@ -1258,6 +1274,7 @@ func (a *app) proposeTask(ev session.Event) { ident: identFor(notice.ID), dependsOn: notice.DependsOn, model: strings.TrimSpace(notice.Model), + program: strings.TrimSpace(notice.Program), elsewhere: strings.TrimSpace(notice.Elsewhere), deadline: notice.Deadline, born: a.now(), @@ -2001,14 +2018,21 @@ func (a *app) taskHead(card *taskCard, width int, sel bool) string { // rail in four seconds and on the card that lands in eleven minutes. head := corner + " " + a.icon(tokens.GNeedsHuman) + " " mark := a.taskMarkSel(card.ident, sel) + " " - title := fit(card.name, width-ansi.StringWidth(head)-3) + // AND WORK GOING TO A PROGRAM WEARS THAT PROGRAM'S BADGE BESIDE ITS NAME, the + // same badge its row will wear on the side list in four seconds + // (programbadge.go), so the card a person approves says who the work is being + // handed to. It is paid for out of the title's cells, never the frame's. + room := width - ansi.StringWidth(head) - 3 + badge := programSpelling(programBadge(card.program), card.name, room, railTitleFloor) + title := fit(card.name, room-programCells(badge)) line := paint(head) + mark if card.settled() { line += a.pal.muted(title) } else { line += a.pal.askBold(title) } - if fill := width - ansi.StringWidth(head) - ansi.StringWidth(title) - 3; fill > 0 { + line += a.pal.programAfter(badge) + if fill := width - ansi.StringWidth(head) - ansi.StringWidth(title) - programCells(badge) - 3; fill > 0 { line += paint(" " + strings.Repeat(rule, fill)) } return line @@ -4601,6 +4625,7 @@ func railPack(segs []string, width, rooms int) []string { // │ └─ ◌ Cut the goldens #4 // └─ ◌ Wire the seam #5 // ⠙ Port the parser ▸ +7 the same family, folded +// ⠙ rewrite the… [sd] #8 work handed to a program, wearing its badge // // EVERY ROW OPENS WITH ONE GLYPH AND IT IS THE STATE. A flat row used to lead // with two — the state and the node's own ◆ — and the second bought nothing @@ -4653,6 +4678,15 @@ func (a *app) railEntryRows(e railEntry, width int) ([]string, hudSpan, hudSpan) } glyph, lead, folds := a.railLead(e) room := width - at - ansi.StringWidth(lead) + // A PROGRAM'S WORK WEARS ITS BADGE FIRST IN THE TRAILING SLOT, straight after + // the title (programbadge.go). It is spoken for before the handle is, because + // it is the one fact on this line that tells two rows apart by what they ARE + // while the handle only tells them apart by number — so as the column narrows + // the handle goes first, then the badge falls to its short spelling, and only + // then is the title cut, down to [railTitleFloor]. An ordinary task has no + // badge, spends nothing here, and draws exactly the row it always drew. + wears := programSpelling(programBadge(a.nodeProgram(node)), node.title, room, railTitleFloor) + room -= programCells(wears) // The trailing slot: a folded root says how much it is standing for, every // other row says its handle, and both stand down when the title cannot afford // them. @@ -4674,7 +4708,10 @@ func (a *app) railEntryRows(e railEntry, width int) ([]string, hudSpan, hudSpan) if at > 0 && whole > room && whole <= room+railWideGain { a.railCramped = true } - line := prefix + lead + a.railTitle(node, title) + // A PROGRAM'S BADGE IS DRAWN AND NEVER RECORDED AS A TARGET. The badge span + // returned below is the folded count's, which a press reads as "expand"; a + // program's badge is part of the row, and the row is the node's door. + line := prefix + lead + a.railTitle(node, title) + a.pal.programAfter(wears) badge := hudSpan{} if meta != "" { if pad := room - ansi.StringWidth(title) + 1; pad > 0 { @@ -5836,6 +5873,12 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { // [session.TaskNotice.Decider] — so a guard that only ever looked at the // state would throw away the event that puts the chips back on the card // somebody is waiting in front of (taskdone.go's [app.handedBackCard]). + // + // AND A PROGRAM THE NODE HAS NOT BEEN TOLD OF IS NEWS, on the brief's + // terms: an empty one says nothing, and one the node already carries is the + // second copy. A row first drawn from an older record that named none and + // then published again, in the same state, by the run that knows its + // program must not lose the badge to this guard. node := a.tasks[notice.ID] if node == nil || (notice.CostUSD <= node.cost && taskLiveLines(notice) == node.liveLines() && !taskRenames(notice, node) && @@ -5843,7 +5886,8 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { !taskPauses(notice, node) && !taskReasks(notice, node) && notice.Decider == node.decider && notice.NextModel == node.nextModel && notice.Thinking == node.thinking && (notice.Brief == "" || notice.Brief == node.brief) && - (notice.Acceptance == "" || notice.Acceptance == node.acceptance)) { + (notice.Acceptance == "" || notice.Acceptance == node.acceptance) && + (notice.Program == "" || notice.Program == node.program)) { return nil } } @@ -5882,6 +5926,9 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { node.label = card.title node.assignment = firstNonEmpty(card.summary, card.brief) node.brief, node.acceptance, node.where = card.brief, card.acceptance, card.where + // And which program it went to, so the row wears the badge the card + // wore even when the notice that made it said nothing about it. + node.program = card.program } a.tasks[notice.ID] = node a.taskOrder = append(a.taskOrder, notice.ID) @@ -6044,6 +6091,13 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { if notice.Kind != "" { node.kind = notice.Kind } + // AND SO IS THE PROGRAM THE WORK WAS HANDED TO, on the kind's own rule: it is + // on every row a program's run publishes, so a row drawn for the first time + // after a conversation switch wears its badge from that first frame, and an + // update quiet about it has not taken the work off the program. + if program := strings.TrimSpace(notice.Program); program != "" { + node.program = program + } // AND SO IS THE WORKING CONTEXT, on the same rule and for the same reason: a // node that named one is in it for the rest of its life, and an update quiet // about it has not taken the person out of it. A better name replaces the one diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index 929f70a9c..6ad48df28 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -160,8 +160,8 @@ func TestAProgramsPageDrawsItsActionsUnderItsSteps(t *testing.T) { page := strings.Join(lines, "\n") t.Logf("a program's page, mid-way:\n%s", page) - if lines[0] != "rewrite the auth middleware" { - t.Fatalf("the head's first row is %q, want the task's title", lines[0]) + if lines[0] != "rewrite the auth middleware [senior-dev]" { + t.Fatalf("the head's first row is %q, want the task's title and its program's badge", lines[0]) } if lines[1] != "implement · $1.24 · 3 calls · 14m 3s" { t.Fatalf("the pinned line is %q, want the step, the spend, the calls and the age", lines[1]) diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index 791e2fc88..706a3b1f9 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -408,6 +408,14 @@ const ( // // › ✓ ⧉ Fix the nil-map crash 3h // ◐ ⧉ Sweep the deprecated call sites 4m +// ◐ ⧉ Rewrite the auth middleware [senior-dev] 9m +// +// A PROGRAM'S WORK SAYS WHOSE IT IS, with the badge its row wears on the side +// list (programbadge.go) after the words. It is the brackets alone here and not +// the badge's ink, for this list's own reason: the overlay paints a row by what +// it IS — under the cursor, hovered, dim — and a word carrying colour of its own +// would fight that paint, so the badge says itself in the row's ink like every +// other word on it. func taskRowLabel(entry session.TaskIndexEntry, pal palette) string { // Label is the title already cut to a row's width (session.taskLabel), and // the uncut title stands in for a row written before that field existed. @@ -415,7 +423,7 @@ func taskRowLabel(entry session.TaskIndexEntry, pal palette) string { if words == "" { words = entry.Title } - return taskStatusGlyph(entry, pal) + " " + mentionMark(pal.ascii) + " " + words + return taskStatusGlyph(entry, pal) + " " + mentionMark(pal.ascii) + " " + programText(words, entry.Program) } func mentionMark(ascii bool) string { @@ -576,6 +584,13 @@ func mentionTokens(text string) []string { // offer nobody can take, because there is nothing left running to send words to. func taskPointerBlock(entry session.TaskIndexEntry) string { head := "[Task reference: " + entry.Title + " — id " + entry.ID + " · " + entry.Status + // A PROGRAM'S WORK SAYS WHICH PROGRAM HAD IT, in the word the model hands + // work to one with (`propose_task`'s `via`), because the model reading this + // block is deciding what to say about work the person can see was not its + // own worker's. + if program := strings.TrimSpace(entry.Program); program != "" { + head += " · via " + program + } if when := mentionWhenWord(entry); when != "" { head += " · " + when } diff --git a/internal/tui3/taskmodel_test.go b/internal/tui3/taskmodel_test.go index ab4d71bc3..0eb692876 100644 --- a/internal/tui3/taskmodel_test.go +++ b/internal/tui3/taskmodel_test.go @@ -131,7 +131,8 @@ func TestTheModelFollowsTheNodeOntoTheRailAndTheLandedCard(t *testing.T) { t.Fatalf("the node did not keep its model: %+v", node) } // THE MODEL NEVER BUYS ITS CELLS FROM THE NAME. The first line is the state - // glyph, the title and the handle — nothing else — and the model rides the + // glyph, the title and the handle — and, for work handed to a program, that + // program's badge (programbadge.go); nothing else — and the model rides the // telemetry row under it (task.go's [app.railTelemetry]), which is a row that // gives up its own tail rather than the title's cells. full := plain(strings.Join(a.railNodeRows(node, railCols), "\n")) diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index b4b130b4b..dda8a695d 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -309,6 +309,10 @@ func planItem(row session.PlanTaskRow, chat string, kin planKin) tasksItem { Cost: row.USD, StartedAt: row.Started, EndedAt: row.Ended, + // The program the store's root was handed to, so every row drawn off + // this item — the rail's, the tasks place's — wears the badge the + // node's own row does (programbadge.go). + Program: row.Program, }, runs: planRunning(row.Status), live: &status, @@ -589,11 +593,20 @@ func planRailRow(line tasksLine, width int, pal palette, now time.Time) string { if room < 1 { room = 1 } + label := planRailLabel(item) + // A PROGRAM'S RUN WEARS ITS BADGE AFTER ITS TITLE HERE TOO. The store's root is + // the same work as the node's row and is normally left out beside it + // ([planRowShown]), but when it is this row that is drawn, it is the only row + // the program's work has on the column — so it is the one that has to say + // whose work it is (programbadge.go). The badge is spoken for before the tail + // is, on the node row's own terms, and an ordinary row spends nothing on it. + wears := programSpelling(programBadge(item.entry.Program), label, room, railTitleFloor) + room -= programCells(wears) + subject := func(title string) string { return placeSubject(title, false, pal) + pal.programAfter(wears) } tail := planRailTail(item, width, pal, now) if tail == "" { - return lead + placeSubject(fit(planRailLabel(item), room), false, pal) + return lead + subject(fit(label, room)) } - label := planRailLabel(item) tailWidth := ansi.StringWidth(tail) titleRoom := room - tailWidth - planRailGap // THE RUN'S ROW KEEPS ITS PROGRESS AND EVERY OTHER ROW KEEPS ITS NAME. The @@ -611,7 +624,7 @@ func planRailRow(line tasksLine, width int, pal palette, now time.Time) string { if want := ansi.StringWidth(label); titleRoom < want && titleRoom < planRailKeepTitle { left := room - want - planRailGap if left < planRailMinTail { - return lead + placeSubject(fit(label, room), false, pal) + return lead + subject(fit(label, room)) } tail = fit(tail, left) tailWidth = ansi.StringWidth(tail) @@ -619,10 +632,10 @@ func planRailRow(line tasksLine, width int, pal palette, now time.Time) string { } } if tailWidth < 1 || titleRoom < planRailMinTitle { - return lead + placeSubject(fit(label, room), false, pal) + return lead + subject(fit(label, room)) } title, titleWidth := fitWidth(label, titleRoom) - return lead + placeSubject(title, false, pal) + + return lead + subject(title) + strings.Repeat(" ", room-titleWidth-tailWidth) + pal.dim(tail) } @@ -1333,13 +1346,21 @@ func (a *app) taskPlanBriefFolds() bool { // every other page draws its telemetry — is a figure that scrolls away the // moment there is more than a screen of it. On every other page, and on a // program's page with nothing yet to say, that line is the air it always was. +// +// AND A PROGRAM'S PAGE WEARS THE PROGRAM'S BADGE BESIDE ITS TITLE, the one its +// row wears on the side list (programbadge.go), so the page says whose work it +// is before a line of the conversation under it has been read. func (a *app) taskPlanHeadRows(width int) []string { pal := a.pal under := "" if pinned := a.taskPlanPinned(a.taskSheet.plan, width); pinned != "" { under = pal.dim(pinned) } - return []string{fit(pal.bold(pal.ink(a.taskSheet.plan.Row.Title)), width), under, pal.dim(rule(width))} + title := fit(pal.bold(pal.ink(a.taskSheet.plan.Row.Title)), width) + if program := pageProgram(a.taskSheet.plan); program != "" { + title = pal.programTitled(a.taskSheet.plan.Row.Title, program, width, func(s string) string { return pal.bold(pal.ink(s)) }) + } + return []string{title, under, pal.dim(rule(width))} } // taskPlanFoot is what the page spends under its body: the closing rule, the diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index e64bd7dd1..250d8728b 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -2072,7 +2072,7 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string } bullet := conversationBullet(pal, chat.working, chat.unread, chat.question, pal.glyph(tokens.GWorking)) lead := tasksBareLead + pal.dim(tasksTreeLead(line, width, pal)) + bullet + " " - return tasksTableRow(lead, ansi.StringWidth(lead), name, + return tasksTableRow(lead, ansi.StringWidth(lead), name, "", tasksChatStateField(chat), tasksKeyField(by.key, line.rank, now), tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, project, tasksFoldMark(line, pal)) } @@ -2111,7 +2111,7 @@ func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palett if item.plan != nil && item.plan.Total > 0 { label += " " + planProgress(*item.plan, width, pal) } - return tasksTableRow(lead, cells, label, + return tasksTableRow(lead, cells, label, item.entry.Program, state, second, tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, "", tasksFoldMark(line, pal)) } @@ -2145,6 +2145,11 @@ func tasksCardHead(item tasksItem, width int, pal palette, lit bool) string { if item.plan != nil && item.plan.Total > 0 { label += " " + planProgress(*item.plan, width, pal) } + // A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME, as its wide row does + // ([tasksTableRow]); an ordinary row is fitted exactly as it always was. + if program := strings.TrimSpace(item.entry.Program); program != "" { + return lead + pal.programTitled(label, program, room, func(s string) string { return placeSubject(s, lit, pal) }) + } return lead + placeSubject(fit(label, room), lit, pal) } diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index a56236fa5..802f75277 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -178,7 +178,12 @@ func tasksChatStateField(chat tasksChat) rowField { // would put them in a different place on every line of the page — a table whose // columns move is a tail with extra steps. Only the NAME flexes (rowfit.go law // 1), and the lead eats into the name. -func tasksTableRow(lead string, leadCells int, name string, state, second rowField, +// +// A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME, inside the name's own column +// (programbadge.go): program is the name of the program the row's work was +// handed to, "" for every other row, and the badge is paid for out of the name +// the way the lead is, so no column moves for it. +func tasksTableRow(lead string, leadCells int, name, program string, state, second rowField, secondInk func(string) string, width int, key tasksSortKey, pal palette, lit bool, project, fold string) string { stateCells, secondCells, nameCells := tasksColumns(width, key) nameCells = max(nameCells-leadCells, 1) @@ -186,12 +191,14 @@ func tasksTableRow(lead string, leadCells int, name string, state, second rowFie if fold != "" { foldCells = 1 + ansi.StringWidth(fold) } - said := fit(name, max(nameCells-foldCells-tasksColumnAir, 1)) - out := lead + placeSubject(said, lit, pal) + room := max(nameCells-foldCells-tasksColumnAir, 1) + wears := programSpelling(programBadge(program), name, room, railTitleFloor) + said := fit(name, max(room-programCells(wears), 1)) + out := lead + placeSubject(said, lit, pal) + pal.programAfter(wears) if fold != "" { out += " " + pal.dim(fold) } - out += pad(nameCells - ansi.StringWidth(said) - foldCells) + out += pad(nameCells - ansi.StringWidth(said) - programCells(wears) - foldCells) if cells := tasksProjectCells(width); cells > 0 { word := fit(project, cells-1) out += placeFactInk(lit, pal)(word) + pad(cells-ansi.StringWidth(word)) diff --git a/internal/tui3/taskstrip.go b/internal/tui3/taskstrip.go index edb0bf0de..798e43e27 100644 --- a/internal/tui3/taskstrip.go +++ b/internal/tui3/taskstrip.go @@ -423,9 +423,17 @@ func (a *app) stripLabel(node *taskNode, width int) (string, int) { // page under the row — the band on the room a person is standing in — because // this is a tab bar, and a tab bar that did not say which tab you are on would // be a row of identical doors. +// +// A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME, in the short spelling — `[sd]` +// — because this row stands in for the side list under a hundred columns and a +// chip is a name cut to [stripTitleCap] cells, where the whole program's name +// would outweigh the work's (programbadge.go). An ordinary task's chip is +// unchanged to the cell. func (a *app) stripChip(node *taskNode, glyph, title string) (string, int) { - cols := ansi.StringWidth(glyph) + 1 + ansi.StringWidth(title) + stripPadCols - chip := stripPad + glyph + " " + a.stripTitle(node, title) + stripPad + badge := programBadge(a.nodeProgram(node)) + wears := firstNonEmpty(badge.short, badge.full) + cols := ansi.StringWidth(glyph) + 1 + ansi.StringWidth(title) + programCells(wears) + stripPadCols + chip := stripPad + glyph + " " + a.stripTitle(node, title) + a.pal.programAfter(wears) + stripPad // WHICH ROW IS THE PAGE YOU ARE ON IS ASKED IN ONE PLACE (room.go's // [app.roomStandingOn]), because the roster marks the same fact with the same // band and a tab bar that disagreed with the column beside it would be two From 2249cc7ba83d22e72b0ece7efc85dbe59f664a2a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:52:38 -0400 Subject: [PATCH 116/195] session, seniordev: the chat hands complex coding work to senior-dev by itself, and uses it when asked The programs paragraph said a large task "can" go to a program, and senior-dev's guide priced it at "one large code change worth an hour", so the chat reached for it almost never: an issue in a mature project was worked inline or given to codeaf's own worker. And a person who named senior-dev got it only if the model remembered to: a proposal without `via` went to a worker, and "fix this file with senior-dev" was refused by the trivial-ask floor before `via` was read. Now the paragraph says work a program is for goes to it whole, rather than to the chat or its worker whatever its critical path, and so does work the person asks one for by name or as `/name`; senior-dev's guide claims complex, multi-part coding work (fixing an issue in a mature codebase whose cause spans files, a feature with its tests, a rewrite, a migration) with the issue in full in its brief; and `via` says when it is set. In code, a proposal with no `via` after the person's message named a carried program is turned back once per message, naming the program and both ways to answer, and a proposal whose `via` is the program the person named is never refused as too small. Both prompt caps rise only by what this costs past the room they had, by the owner's call of 2026-09-24 (fixed 56,447, lean 47,859). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 28 ++- internal/manual/chat/senior-dev.md | 30 ++- internal/manual/chat/tasks.md | 10 +- internal/manual/chat_test.go | 9 + internal/seniordev/guide_test.go | 41 ++++ internal/seniordev/seniordev.go | 19 +- internal/session/delegate_asked.go | 117 ++++++++++++ internal/session/delegate_asked_test.go | 244 ++++++++++++++++++++++++ internal/session/delegate_door.go | 23 ++- internal/session/delegate_door_test.go | 32 ++++ internal/session/lawregistry_test.go | 6 + internal/session/prefixbudget_test.go | 17 +- internal/session/session.go | 5 + internal/session/spawnfloor.go | 24 ++- internal/session/task.go | 22 ++- 15 files changed, 601 insertions(+), 26 deletions(-) create mode 100644 internal/seniordev/guide_test.go create mode 100644 internal/session/delegate_asked.go create mode 100644 internal/session/delegate_asked_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 8464b65a5..2554133ca 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -42,7 +42,8 @@ Type its name as a command, then the brief: That is `/task` with the worker chosen. A run starts at once in a copy of your folder, the turn goes on, and the row appears on the rail. -The model can choose one as well. `propose_task` takes `via` naming the program, and the +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 [<name>] task: <title>`, 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 @@ -54,6 +55,31 @@ records instead of readable lines. `codeaf <name> --help` lists its own commands 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: +``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``. +The next proposal for the same message passes as it is, so "don't use senior-dev for +this" is kept too. + +**An ask for a program is never too small.** A one-file fix or a single command otherwise +stays in the conversation, but "fix this file with senior-dev" goes to senior-dev. + +**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. `/<name> <brief>` starts the program at once, with no card. + ## Which folder a program works in — a repository I have not cloned, it edited files outside its copy, a folder with no git A program that edits code works in a copy of one folder: the one the task names as its diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 333669cc6..9f53f820a 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -14,9 +14,33 @@ the tree is frozen at that moment, so nothing it does afterwards can change what 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. -Use it for one change big enough to want an agent of its own for an hour, and specified -well enough that nobody will be asked anything: a rewrite across a package, a migration, -a feature with its tests. A change you would make in a few steps is not worth it. +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 and tells it you named senior-dev. That holds even for a one-file fix, which +otherwise stays in the conversation. + +**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 proposes it again as it was, and the +second proposal for the same message passes. + +**Typing `/senior-dev <brief>`** 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 diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index c94874681..ffc8ea588 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -1200,7 +1200,9 @@ 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. Naming 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). ## 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 @@ -5543,8 +5545,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. With `CODEAF_TASK_BELT=bash` set 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. With `CODEAF_TASK_BELT=bash` set +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. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index b9da95372..c44cf1a07 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -947,6 +947,15 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"which folder does a delegate work in", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, diff --git a/internal/seniordev/guide_test.go b/internal/seniordev/guide_test.go new file mode 100644 index 000000000..3cf4a826b --- /dev/null +++ b/internal/seniordev/guide_test.go @@ -0,0 +1,41 @@ +//go:build !windows + +package seniordev + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// SENIOR-DEV'S GUIDE CLAIMS THE HARD CODING WORK, IN FEWER BYTES THAN ITS CAP. +// The guide is what the chat's model reads before it reaches for senior-dev, +// and the paragraph it is printed under tells that model to prefer a program +// for whatever its guide claims (internal/session/delegate_door.go). So the +// claim is pinned here: an issue in a mature codebase, the work senior-dev +// was going unused on while the guide priced it at "an hour", and the brief +// carrying the issue in full. And it rides every request of every turn, so it +// is held under delegate.GuideMax. +func TestTheGuideClaimsComplexCodingWorkWithinItsBytes(t *testing.T) { + guide := strings.TrimSpace(Program.Guide) + if len(guide) > delegate.GuideMax { + t.Fatalf("the guide is %d bytes, over delegate.GuideMax's %d: %q", len(guide), delegate.GuideMax, guide) + } + for _, want := range []string{ + "complex, multi-part coding work", + "fixing an issue in a mature codebase whose cause spans files", + "the issue or ask in full", + "what done means and how to check it", + "what must not change", + } { + if !strings.Contains(guide, want) { + t.Errorf("the guide does not say %q: %q", want, guide) + } + } + // AND IT SETS NO PRICE THAT KEEPS IT ON THE SHELF. "worth an hour" read as + // a bar the chat's model almost never judged a piece of work to clear. + if strings.Contains(guide, "worth an hour") { + t.Errorf("the guide still prices senior-dev at an hour: %q", guide) + } +} diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 3d75b0ce7..ed7fc655c 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -67,9 +67,22 @@ var Program = delegate.Delegate{ // knows of the work, so the guide says what that brief must settle. Its // folder is codeaf's to choose and to read: a plain one is handed over // with PlainFolder on the line, so the guide says nothing about git. - Guide: "For one large code change worth an hour: a rewrite across a package, a migration, " + - "a feature with its tests. Its brief names the files and commands, what done means and " + - "how to check it, and what must not change.", + // + // IT CLAIMS THE HARD CODING WORK, BECAUSE THAT IS WHAT IT IS FOR. It said + // "one large code change worth an hour", a price no piece of work seems to + // clear before it is opened: an issue in a mature project whose cause runs + // through several files reads, from its report, as a few edits the chat + // could make itself. The owner asked on 2026-09-24 that codeaf reach for + // senior-dev by itself on complicated, many-sided coding work, the kind a + // hard software-engineering benchmark is made of, so the guide names that + // work, and the paragraph it is printed under says codeaf prefers a program + // for whatever its guide claims (internal/session/delegate_door.go). THE + // BRIEF CARRIES THE ISSUE IN FULL: a summary of a bug report is the one + // thing senior-dev cannot check against, since there is nobody it can ask + // what the report said. + Guide: "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. Its brief " + + "carries the issue or ask in full, what done means and how to check it, and what must not change.", Lands: delegate.LandsTree, // Its recorder is git unless it is told --in-place, which keeps its // checkpoints outside the folder and commits nothing; a folder with no git diff --git a/internal/session/delegate_asked.go b/internal/session/delegate_asked.go new file mode 100644 index 000000000..95b37eaeb --- /dev/null +++ b/internal/session/delegate_asked.go @@ -0,0 +1,117 @@ +package session + +// THE PERSON NAMED A PROGRAM: what a proposal meets when the person's own +// message said which program codeaf carries should do the work. +// +// A PROMPT IS REMEMBERED EXACTLY AS OFTEN AS THE MODEL REMEMBERS IT. The +// hand-off page says a program the person asks for is used (delegate_door.go's +// [delegateFact]), and a model that has just read a long issue and decided it +// is a task writes the proposal it always writes, with no `via`. So the +// person's words are read here, in code, for the names of the programs this +// build carries, and two things follow from them that cost no prompt bytes: +// +// - A proposal with no `via` is turned back ONCE for the message that named +// a program, with a sentence saying which program was named and both ways +// to answer it. The next proposal for that message passes as it is, which +// is how "fix it with senior-dev" and "don't use senior-dev for this" both +// come out right: which of the two the person said is read by the model, +// from words code cannot weigh. +// - A proposal whose `via` is the program the person named is never too +// small. The spawn floor (spawnfloor.go) keeps a one-file fix in the +// conversation, and a person who typed "fix this file with senior-dev" has +// overruled it already, exactly as a person who typed `/task` has. +// +// ONLY WHERE A `via` COULD BE HONOURED. Inside a task, with no run road, or +// with no program carried, a `via` is refused anyway ([Agent.stageTask]), and a +// bounce there would be a round trip that ends where it began. + +import ( + "slices" + "strings" +) + +// programNamedSentence is what a proposal with no `via` reads back when the +// person's message named a program. It is a result the turn goes on from, and +// it offers both answers, because only the model can tell "use it" from +// "don't use it" and from a word that was never meant as the program's name. +func programNamedSentence(name string) string { + return "the person named " + name + ": if they want it to do this work, propose this again with `via: \"" + name + "\"`; " + + "if they asked for it not to be used, or did not mean the program, propose it again unchanged" +} + +// mayHandToProgram says a `via` on a proposal from this agent could be +// honoured: it is a conversation rather than a task, and the run road a +// program rides is linked. It is the one reading of that, asked by the +// refusal in [Agent.stageTask] and by the bounce below, so the two cannot +// disagree about where a program may be named. +func (a *Agent) mayHandToProgram() bool { + return !a.config.InTask && chatRunEngine != nil +} + +// programAskBounce is the once-per-message refusal of a proposal that left +// out the program the person named, or "" when this proposal is not turned +// back. +// +// ONCE IS COUNTED PER MESSAGE OF THE PERSON'S. [Agent.personSeq] numbers what +// they have typed, steering included, so a new message of theirs that names +// the program again earns one more bounce, and a woken turn, which types +// nothing, inherits the count of the message it is still answering. The check +// and the mark are made under one lock, so a batch of proposals staged side by +// side cannot both be the first. +func (a *Agent) programAskBounce(spec taskSpec) string { + if spec.via != "" || !a.mayHandToProgram() || len(a.config.Delegates) == 0 { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + if a.personSeq == 0 || a.programBounced == a.personSeq { + return "" + } + name := a.config.programNamedIn(a.personAsk) + if name == "" { + return "" + } + a.programBounced = a.personSeq + return programNamedSentence(name) +} + +// askedForProgram says `via` names a program this build carries and the +// person's words named that same program, which is the one thing that lifts +// the spawn floor for a proposal ([Agent.refuseProposedTask]). +func (c Config) askedForProgram(asked, via string) bool { + if via == "" || !slices.Contains(c.delegateNames(), via) { + return false + } + return namesProgram(normalizedWords(asked), via) +} + +// programNamedIn is the first program, by name, that the person's words name, +// or "" when they name none. +func (c Config) programNamedIn(asked string) string { + words := normalizedWords(asked) + for _, name := range c.delegateNames() { + if namesProgram(words, name) { + return name + } + } + return "" +} + +// namesProgram says the words hold a program's name the ways a person types +// it: in any case, as `/name`, and with spaces or with nothing where the name +// has hyphens ("senior dev", "seniordev"). [normalizedWords] has already +// lowered the case and split on every mark that is not a letter or a digit, so +// the name is a run of whole words, or those words written as one. +func namesProgram(words []string, name string) bool { + parts := normalizedWords(name) + if len(parts) == 0 { + return false + } + joined := strings.Join(parts, "") + for at := range words { + if words[at] == joined || slices.Equal(words[at:min(at+len(parts), len(words))], parts) { + return true + } + } + return false +} diff --git a/internal/session/delegate_asked_test.go b/internal/session/delegate_asked_test.go new file mode 100644 index 000000000..01bee1893 --- /dev/null +++ b/internal/session/delegate_asked_test.go @@ -0,0 +1,244 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/manual" +) + +// heard records text as the person's newest message, where a turn they open +// or steer records it ([Agent.rememberAskLocked]). +func heard(agent *Agent, text string) { + agent.mu.Lock() + defer agent.mu.Unlock() + agent.rememberAskLocked(userText(text)) +} + +// programConversation is a conversation that carries a program called +// senior-dev, with the run road linked, which is where a `via` can be +// honoured and so where the person naming it is read. The engine is a double +// nothing here starts. +func programConversation(t *testing.T, mutate func(*Config)) *Agent { + t.Helper() + registerBeltRunEngine(t, newBeltRunDouble("unused")) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Delegates = testPrograms("senior-dev") + if mutate != nil { + mutate(config) + } + }) + return agent +} + +// proposeOutputs is every propose_task result a turn showed, in order, clean +// or refused. +func proposeOutputs(events []Event) []string { + var outputs []string + for _, event := range events { + if event.Tool == "propose_task" && (event.Kind == EventToolEnd || event.Kind == EventToolFailed) { + outputs = append(outputs, event.Output) + } + } + return outputs +} + +// A PROPOSAL THAT LEAVES OUT THE PROGRAM THE PERSON NAMED IS TURNED BACK ONCE. +// The person asked for senior-dev and the model proposed the work for its own +// worker, which is the proposal a model writes by habit: it is told who was +// named and both ways to answer. The next proposal for the same message +// passes as it is, because "don't use senior-dev" is an ask too, and only the +// model can read which one it was. +func TestAProposalLeavingOutTheProgramThePersonNamedIsTurnedBackOnce(t *testing.T) { + registerBeltRunEngine(t, newBeltRunDouble("unused")) + completer := &routedCompleter{parent: []step{ + proposeCall("Fix the dropped retries", "the scheduler drops retries under load"), + proposeCall("Fix the dropped retries", "the scheduler drops retries under load"), + finalText("started"), + }} + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.Delegates = testPrograms("senior-dev") + }) + nodes := make(ranNodes, 4) + graph := stubbedGraph(agent, func(node *TaskNode) { nodes <- node }) + + collected := collect(t, mustSubmit(t, agent, "the scheduler drops retries under load; fix it with senior-dev")) + + outputs := proposeOutputs(collected) + if len(outputs) != 2 { + t.Fatalf("want two proposal results, got %d: %q", len(outputs), outputs) + } + if want := programNamedSentence("senior-dev"); outputs[0] != want { + t.Fatalf("the first proposal read %q, want %q", outputs[0], want) + } + for _, want := range []string{"the person named senior-dev", "`via: \"senior-dev\"`", "propose it again unchanged"} { + if !strings.Contains(outputs[0], want) { + t.Fatalf("the bounce does not say %q: %q", want, outputs[0]) + } + } + if !strings.HasPrefix(outputs[1], "task ") { + t.Fatalf("the second proposal for the same message was not let through: %q", outputs[1]) + } + nodes.await(t) + if admitted(graph) != 1 { + t.Fatalf("the graph admitted %d nodes, want the one second proposal", admitted(graph)) + } +} + +// THE NAME IS HEARD HOWEVER A PERSON TYPES IT: as its command, in any case, +// with a space or nothing where it has a hyphen. And a word that only shares +// part of it is not the name. +func TestTheProgramIsHeardHoweverThePersonSpellsIt(t *testing.T) { + config := Config{Delegates: testPrograms("senior-dev")} + for _, asked := range []string{ + "/senior-dev fix the flaky retry", + "fix the flaky retry with senior dev", + "have Senior-Dev fix the flaky retry", + "SENIORDEV should take this one", + "use senior_dev for it", + "senior-dev", + } { + if got := config.programNamedIn(asked); got != "senior-dev" { + t.Errorf("%q named %q, want senior-dev", asked, got) + } + } + for _, asked := range []string{ + "fix the flaky retry", + "a senior developer wrote this", + "ask a senior about the dev branch", + "dev senior", + "", + } { + if got := config.programNamedIn(asked); got != "" { + t.Errorf("%q named %q, want nothing", asked, got) + } + } + for _, asked := range []string{"/senior-dev fix the retry", "fix the retry with senior dev", "Senior-Dev, fix the retry"} { + agent := programConversation(t, nil) + heard(agent, asked) + if bounce := agent.programAskBounce(taskSpec{title: "t"}); bounce != programNamedSentence("senior-dev") { + t.Errorf("%q: the proposal without via read %q, want the bounce", asked, bounce) + } + } +} + +// NO BOUNCE WHERE NOTHING WAS NAMED, OR WHERE A `via` COULD NOT BE HONOURED. +// A message that names no program is proposed as it always was; a task node, +// a build with no run road and a build carrying no program would refuse the +// `via` the bounce asks for, so a bounce there is a round trip to nowhere. +// And a proposal that already names a program is never turned back. +func TestNoBounceWhereNothingWasNamedOrNoProgramCouldBe(t *testing.T) { + plain := programConversation(t, nil) + heard(plain, "the scheduler drops retries under load; fix it") + if bounce := plain.programAskBounce(taskSpec{}); bounce != "" { + t.Fatalf("a message naming no program was bounced: %q", bounce) + } + + named := "the scheduler drops retries under load; fix it with senior-dev" + inTask := programConversation(t, func(config *Config) { config.InTask = true }) + heard(inTask, named) + if bounce := inTask.programAskBounce(taskSpec{}); bounce != "" { + t.Fatalf("a task node was bounced toward a program it cannot name: %q", bounce) + } + + noPrograms := programConversation(t, func(config *Config) { config.Delegates = nil }) + heard(noPrograms, named) + if bounce := noPrograms.programAskBounce(taskSpec{}); bounce != "" { + t.Fatalf("a build carrying no program was bounced: %q", bounce) + } + + withVia := programConversation(t, nil) + heard(withVia, named) + if bounce := withVia.programAskBounce(taskSpec{via: "senior-dev"}); bounce != "" { + t.Fatalf("a proposal naming the program was bounced: %q", bounce) + } + + registerBeltRunEngine(t, nil) + noRoad, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.Delegates = testPrograms("senior-dev") }) + heard(noRoad, named) + if bounce := noRoad.programAskBounce(taskSpec{}); bounce != "" { + t.Fatalf("a build with no run road was bounced: %q", bounce) + } +} + +// ONCE IS PER MESSAGE. A second message of the person's that names the program +// again is a new ask and earns its own bounce; a second proposal for the same +// message does not. +func TestTheBounceIsOncePerMessageOfThePersons(t *testing.T) { + agent := programConversation(t, nil) + heard(agent, "fix the flaky retry with senior-dev") + if agent.programAskBounce(taskSpec{}) == "" { + t.Fatal("the first proposal for the message was not bounced") + } + if bounce := agent.programAskBounce(taskSpec{}); bounce != "" { + t.Fatalf("the second proposal for the same message was bounced again: %q", bounce) + } + heard(agent, "no really, give it to senior-dev") + if agent.programAskBounce(taskSpec{}) == "" { + t.Fatal("a new message naming the program again was not bounced") + } +} + +// AN ASK FOR A PROGRAM IS NEVER TOO SMALL. "fix this file with senior-dev" is +// on the spawn floor as a one-file fix, and the floor ran before `via` was +// read, so the person who asked for senior-dev by name was refused with "do it +// here". The proposal that names the program they asked for passes the floor; +// one that leaves it out is bounced once and then meets the floor as any +// proposal does; and a program the person did not ask for lifts nothing. +func TestAnAskForAProgramIsNeverTooSmall(t *testing.T) { + asked := "fix this file with senior-dev" + if !trivialAsk(asked) { + t.Fatalf("%q is off the floor, so this test would prove nothing", asked) + } + agent := programConversation(t, nil) + heard(agent, asked) + if refusal := agent.refuseProposedTask(taskSpec{via: "senior-dev"}); refusal != "" { + t.Fatalf("the proposal naming the program the person asked for was refused: %q", refusal) + } + if refusal := agent.refuseProposedTask(taskSpec{}); refusal != programNamedSentence("senior-dev") { + t.Fatalf("the proposal leaving the program out read %q, want the bounce", refusal) + } + if refusal := agent.refuseProposedTask(taskSpec{}); refusal != spawnFloorRefusal { + t.Fatalf("the second proposal leaving the program out read %q, want the floor", refusal) + } + + unasked := programConversation(t, nil) + heard(unasked, "fix this file") + if refusal := unasked.refuseProposedTask(taskSpec{via: "senior-dev"}); refusal != spawnFloorRefusal { + t.Fatalf("a program nobody asked for lifted the floor: %q", refusal) + } + if refusal := unasked.refuseProposedTask(taskSpec{via: "nosuch"}); refusal != spawnFloorRefusal { + t.Fatalf("a program this build does not carry lifted the floor: %q", refusal) + } +} + +// THE PROGRAMS PAGE QUOTES THE BOUNCE AS THE MODEL READS IT, so a person +// asking why their proposal came back, and the chat answering from the page, +// both read the sentence that was actually sent. +func TestTheProgramsPageQuotesTheBounceWordForWord(t *testing.T) { + page, found := manual.Chat().Page("delegates") + if !found { + t.Fatal("there is no chat manual page called delegates") + } + if want := programNamedSentence("senior-dev"); !strings.Contains(page, want) { + t.Fatalf("the programs page does not quote the bounce %q", want) + } +} + +// AND THE WHOLE DOOR SAYS IT: a proposal called for a one-file fix the person +// gave to senior-dev is refused with the bounce, and never with the floor's +// "do it here", which is what the person was told before. +func TestTheDoorBouncesARequestedProgramBeforeTheFloor(t *testing.T) { + agent := programConversation(t, nil) + heard(agent, "fix this file with senior-dev") + arguments, _ := json.Marshal(taskArguments{Title: "t", Summary: "s", Brief: "b", Deliverable: "d", Acceptance: "a"}) + result, isError, err := agent.proposeTask(context.Background(), arguments) + if err != nil { + t.Fatalf("proposeTask errored the turn: %v", err) + } + if !isError || result != programNamedSentence("senior-dev") { + t.Fatalf("the proposal read %q (error %v), want the bounce", result, isError) + } +} diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index a232bceb5..d352973b3 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -95,11 +95,30 @@ func (c Config) mayDelegate() bool { // That nobody can be asked anything is `propose_task`'s own `brief` // description, and that small work is never handed off is this section's // own; neither is said a second time here. +// +// AND CODEAF PREFERS A PROGRAM FOR THE WORK IT IS FOR. The paragraph said a +// large task "can" go to one, which is a permission, and a permission loses +// to the two roads the rest of the section teaches: an issue in a mature +// project is worked through inline, or handed to codeaf's own worker, which +// has none of a program's machinery for that work. The owner's call of +// 2026-09-24 is that codeaf reach for a program by itself on complicated, +// many-sided coding work, and almost always when the person asks for one. So +// the first sentence is a preference over both of those roads, the program's +// guide under it says which work that is, and WHATEVER ITS CRITICAL PATH is +// said because the section's own test for a hand-off is the critical path: +// one hard fix has a single one, and a model holding that test alone would +// keep the fix. The second sentence is the person's ask, which outranks the +// section's floor on small work; code holds both halves of it, where a prompt +// would be forgotten (delegate_asked.go). +// +// THE TWO SENTENCES STAND APART FROM THE FOLDER RULE, which is spliced in +// after them, so either can be reworded without touching the other. var delegateFact = beltFact{ tools: []string{"propose_task"}, holds: Config.mayDelegate, - present: "AND ONE LARGE TASK CAN GO TO A PROGRAM BUILT INTO CODEAF, named in `propose_task`'s\n" + - "`via`, which does the whole of it alone.%s The programs here:\n%s", + present: "AND WORK A PROGRAM BUILT INTO CODEAF IS FOR GOES TO IT WHOLE, named in\n" + + "`propose_task`'s `via`, rather than to you or a worker, whatever its critical path;\n" + + "so does work the person asks one for, by name or as `/name`.%s The programs here:\n%s", fill: func(config Config, text string) string { rule := "" if config.carriesTreeProgram() { diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 4c8d94f39..7fb98e0cb 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -373,6 +373,38 @@ func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { } } +// CODEAF PREFERS A PROGRAM FOR THE WORK IT IS FOR, AND USES ONE WHEN ASKED. +// The paragraph said a large task "can" go to a program, and the model took a +// permission for no reason to: the owner's call of 2026-09-24 is that it +// reach for one by itself on the work the program's guide claims, over its +// own hands and its own worker, and whenever the person names one. Both +// sentences are said whatever the program lands, before the folder rule and +// apart from it, so either can be reworded without the other; and `via` +// says the same when the proposal is being written. +func TestThePagePrefersAProgramForItsWorkAndForTheAsk(t *testing.T) { + preference := []string{ + "AND WORK A PROGRAM BUILT INTO CODEAF IS FOR GOES TO IT WHOLE, named in\n`propose_task`'s `via`", + "rather than to you or a worker, whatever its critical path;", + "so does work the person asks one for, by name or as `/name`.", + } + textOnly := testPrograms("reader") + textOnly[0].Lands = delegate.LandsText + for _, programs := range [][]delegate.Delegate{testPrograms("fake"), textOnly} { + page := promptWithBeltFacts(Config{Workspace: t.TempDir(), Delegates: programs}) + for _, want := range preference { + if !strings.Contains(page, want) { + t.Fatalf("a build carrying %s is not told %q:\n%s", programs[0].Name, want, page) + } + } + if rule := strings.Index(page, "It works in a copy"); rule >= 0 && rule < strings.Index(page, preference[2]) { + t.Fatalf("the folder rule is said inside the preference rather than after it:\n%s", page) + } + } + if !strings.Contains(taskSchemaJSON, `"via":{"type":"string","description":"A program your instructions list, to do the whole task alone in ground (or this conversation's folder): set it for work one is for, and when the person names one"}`) { + t.Fatal("`via` does not say when it is set") + } +} + // THE FOLDER A PROGRAM IS HANDED IS CODEAF'S TO EXPLAIN, and it is explained // only where it is true. A program that edits files works in a copy of the // proposal's folder and lands only from there, so the page tells the model to diff --git a/internal/session/lawregistry_test.go b/internal/session/lawregistry_test.go index 2bc534cf5..c658493a2 100644 --- a/internal/session/lawregistry_test.go +++ b/internal/session/lawregistry_test.go @@ -184,6 +184,12 @@ var lawRegistry = []lawUnit{ {id: "tasks.look-inside", class: lawCore, key: "Look inside running or landed work with `tasks` and its id"}, {id: "tasks.continue-is-not-a-new-task", class: lawCore, key: "never a fresh `propose_task`"}, {id: "read.what-read-cannot-turn-into-text", class: lawCore, key: "What `read` cannot turn into text → `read_document`"}, + // ── and the two triggers for a program codeaf carries (delegate_door.go's + // [delegateFact], the owner's call of 2026-09-24): the work a program's own + // guide claims goes to it, and so does work the person asks one for. What + // each program is for is its guide's; these say only that codeaf prefers it. + {id: "program.work-it-is-for", class: lawCore, key: "AND WORK A PROGRAM BUILT INTO CODEAF IS FOR GOES TO IT WHOLE"}, + {id: "program.the-one-asked-for", class: lawCore, key: "so does work the person asks one for, by name or as `/name`."}, // ── the mark codeaf leaves on work it did in somebody's name. It is core // rather than verb: `bash` is where it happens, but `bash` is pi's own // description and this law is codeaf's, and it is stated in ONE place for diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index d5330ad37..c50d74e43 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -471,9 +471,22 @@ const fixedPrefixTarget = 48_000 // prompt text was offered and declined: the paragraph is how the conversation // learns what senior-dev is for and which folder to hand it, and cutting // other lanes' wording to make room was the riskier edit days before a ship. +// +// 2026-09-24, codeaf reaches for a program by itself, and the owner's call by +// name: "raise the cap only as much as necessary — the prompts will be refined +// later". The programs paragraph now says work a program is for goes to it +// whole, rather than to the conversation or its own worker whatever its +// critical path, and that work the person asks one for by name goes to it; +// senior-dev's guide claims the complex, many-sided coding work it was going +// unused on (an issue in a mature codebase whose cause spans files) and says +// its brief carries the issue in full; and `via` says when it is set. That +// grew fixed by 202 bytes (page 153, `propose_task` 49) to 56,447, and lean +// by 153 to 47,859. Both arms already had room, 26 and 13 bytes of it, so +// fixed rises by 176 and lean by 140, and both sit exactly on the +// measurement again. const ( - fixedPrefixWaiver = 8_271 - leanPrefixWaiver = 16_219 + fixedPrefixWaiver = 8_447 + leanPrefixWaiver = 16_359 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane diff --git a/internal/session/session.go b/internal/session/session.go index 168e1d044..d9342d364 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2645,6 +2645,11 @@ type Agent struct { // person is not currently saying under their live authority // (task_forward.go). personHeard uint64 + // programBounced is the [Agent.personSeq] of the person's message a + // proposal was last turned back for, because that message named a program + // and the proposal did not (delegate_asked.go). It is what makes the bounce + // once per message: the next proposal for the same message passes as it is. + programBounced uint64 // callOutcomes is whether a finished call came back a failure, by call occurrence // (admission_compile.go). It is recorded at the batch's own fan-out because // the flag the tool returned does not survive into the transcript, and it is diff --git a/internal/session/spawnfloor.go b/internal/session/spawnfloor.go index 5fc9a088a..e6eb045d8 100644 --- a/internal/session/spawnfloor.go +++ b/internal/session/spawnfloor.go @@ -37,13 +37,25 @@ import "strings" const spawnFloorRefusal = "this ask is one command — do it here. A commit, an undo, a one-file edit or a single read stays in the conversation; handing it to a task is how the work gets dropped." // refuseProposedTask is every check that can turn a propose_task call around -// BEFORE a card is raised or a slot is taken: the spawn floor, then a -// depends_on that can never resolve. Both used to live as endings of -// [Agent.proposeTask]; they are here so that road does not grow (the -// complexity ratchet holds it at 16). +// BEFORE a card is raised or a slot is taken: a proposal that left out the +// program the person named, the spawn floor, then a depends_on that can never +// resolve. They used to live as endings of [Agent.proposeTask]; they are here +// so that road does not grow (the complexity ratchet holds it at 16). +// +// THE PROGRAM THE PERSON NAMED COMES FIRST, and it lifts the floor. "fix this +// file with senior-dev" is a one-file fix on the floor's reading and an ask +// for a program on the person's: the proposal without `via` is turned back +// to name it, and the one that names it is not refused for being small +// (delegate_asked.go). A proposal naming a program the person did not ask +// for meets the floor as any proposal does. func (a *Agent) refuseProposedTask(spec taskSpec) string { - if !a.config.InTask && trivialAsk(a.taskRequest()) { - return spawnFloorRefusal + if bounce := a.programAskBounce(spec); bounce != "" { + return bounce + } + if !a.config.InTask { + if asked := a.taskRequest(); trivialAsk(asked) && !a.config.askedForProgram(asked, spec.via) { + return spawnFloorRefusal + } } if missing, failed := a.graph().doomedDependencies(spec.dependsOn); len(missing)+len(failed) > 0 { if bashBeltAsked() { diff --git a/internal/session/task.go b/internal/session/task.go index b82d4a3d6..e7d37764f 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -179,6 +179,15 @@ var taskDescription = "Hand self-contained work to a task outside this conversat // carries it whole, and [composeBrief] bounds the person's verbatim ask and // nothing else — so a findings-rich handoff reaches the worker entire, and this // sentence is the only thing standing between the model and writing one. +// +// AND `via` SAYS WHEN IT IS SET, NOT ONLY WHAT IT IS. It opened on "Optional", +// and a model already writing a proposal for an issue in a mature project read +// that as the field to leave out. The hand-off page says codeaf prefers a +// program for the work its guide claims and uses one the person asks for +// (delegate_door.go's [delegateFact]); this is that same preference at the +// moment the field is being filled, in as few bytes as say it. The page stays +// the rule's home: on the lean belt this schema is fetched on demand, and the +// page is all that is read before the model decides to propose at all. var taskSchemaJSON = `{"type":"object","properties":{` + `"title":{"type":"string","description":"One line naming the work as a person would say it"},` + `"summary":{"type":"string","description":"Two or three lines the person reads to decide whether to redirect it"},` + @@ -192,7 +201,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + - `"via":{"type":"string","description":"Optional: a program your instructions list, to do the whole task alone in ground (or this conversation's folder)"},` + + `"via":{"type":"string","description":"A program your instructions list, to do the whole task alone in ground (or this conversation's folder): set it for work one is for, and when the person names one"},` + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + `"no_progress":{"type":"integer","description":"Optional. Tool calls in a row that may add nothing before it is stopped as stuck (default ` + strconv.Itoa(taskNoProgress) + `). Raise it for work that must read a great deal first"}` + `},"required":["title","summary","brief","deliverable","acceptance"],"additionalProperties":false}` @@ -639,10 +648,11 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged } return bare.Settled(problem, true) } - // THE DOOR REFUSALS, before a card or a slot. A trivial ask and a - // depends_on that can never resolve are both "do not start this"; they - // live in one helper so this road does not grow another ending - // (complexity_test.go's ratchet on this function). + // THE DOOR REFUSALS, before a card or a slot. A proposal that left out the + // program the person named, a trivial ask and a depends_on that can never + // resolve are all "do not start this"; they live in one helper so this + // road does not grow another ending (complexity_test.go's ratchet on this + // function). if refusal := a.refuseProposedTask(spec); refusal != "" { return bare.Settled(refusal, true) } @@ -653,7 +663,7 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged if _, err := a.delegateFor(spec.via); err != nil { return bare.Settled(err.Error(), true) } - if a.config.InTask || chatRunEngine == nil { + if !a.mayHandToProgram() { return bare.Settled(spec.via+" can only be given work from the conversation, and only where the run road is linked", true) } } From fba1baa875a5b9c5389f70dddc8bcddfc437ad66 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:29:16 -0400 Subject: [PATCH 117/195] tui3: a task's row leaves its clock out while its room is open, rather than show the second it was clicked Opening a task's room froze its side-list row 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 whose header read 1m 21s. The row now drops its clock, its call's age and its phase call's figures while the room is open, and reads the whole true age again when the person leaves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 4 +++- internal/tui3/runclock_test.go | 24 ++++++++++++++++++++++++ internal/tui3/task.go | 16 +++++++++++++--- internal/tui3/taskphase.go | 9 +++++---- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 9f53f820a..5eedd6ca4 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -57,7 +57,9 @@ its hand-in, the build and tests it ran itself, and how it finished — with the model in flight as the last line, `◐ thinking` and its seconds. The next section says what each step means. The line over it pins 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. +and the landed card show, counted from the moment codeaf handed the work over. 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` diff --git a/internal/tui3/runclock_test.go b/internal/tui3/runclock_test.go index e01e42fb0..0a0290b59 100644 --- a/internal/tui3/runclock_test.go +++ b/internal/tui3/runclock_test.go @@ -146,3 +146,27 @@ func TestARunningClockTrustsTheReportedAgeOverAnotherMachinesStart(t *testing.T) t.Fatalf("the rail reads %q ten seconds into the work, want 10s", got) } } + +// A ROW WHOSE ROOM IS OPEN DRAWS NO CLOCK, RATHER THAN ONE STOPPED AT THE CLICK. +// Standing in senior-dev's room froze its row's age at the second the room +// opened: the side list read `2s` for a minute and more beside a page whose +// header read `1m 21s`. The row now drops its clock while the room is open and +// reads the whole true age again the moment the person leaves. +func TestARowWhoseRoomIsOpenDrawsNoStoppedClock(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + now := started.Add(2 * time.Second) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskRunning, + session.TaskNotice{StartedAt: started, CostUSD: 0.04})}) + a.freezeNode(7) + now = started.Add(81 * time.Second) + got := plain(a.railTelemetry(a.tasks[7], 40)) + if strings.Contains(got, "2s") || strings.Contains(got, "1m") || !strings.Contains(got, "$0.04") { + t.Fatalf("a row whose room is open reads %q, want its spend and no clock", got) + } + a.thawNode(7) + if got := plain(a.railTelemetry(a.tasks[7], 40)); !strings.HasPrefix(got, "1m 21s") { + t.Fatalf("the row read %q once its room closed, want the whole age 1m 21s", got) + } +} diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 71475a6ff..5feaf9478 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -5608,8 +5608,11 @@ func (a *app) railWaiting(node *taskNode, width int) []string { func (a *app) railTelemetry(node *taskNode, width int) string { segs := make([]string, 0, 5) // A NODE WITH NO ANCHOR HAS NO AGE TO DRAW. Counted from the zero instant it - // read `2562047h 47m`, which is not a measurement of anything. - if clock := countUpWord(a.taskNow(node).Sub(node.began)); clock != "" && !node.began.IsZero() { + // read `2562047h 47m`, which is not a measurement of anything. And a node + // whose room is open draws none either ([app.taskNow]): the room's own header + // carries the live figure, and a number stopped at the moment of the click + // read `2s` beside a senior-dev page reading `1m 21s`. + if clock := countUpWord(a.taskNow(node).Sub(node.began)); clock != "" && !node.began.IsZero() && node.froze.IsZero() { segs = append(segs, clock) } if node.tokens > 0 { @@ -5647,7 +5650,7 @@ func (a *app) railTelemetry(node *taskNode, width int) string { // that says how that is going. Both are empty under [taskToolFloor]: a call // that has just started is a call nobody is waiting on yet. func (a *app) taskClock(node *taskNode) (string, func(string) string) { - if node.toolBegan.IsZero() { + if node.toolBegan.IsZero() || !node.froze.IsZero() { return "", nil } age := a.taskNow(node).Sub(node.toolBegan) @@ -5672,6 +5675,13 @@ func (a *app) taskClock(node *taskNode) (string, func(string) string) { // is the opposite of what a person reading needs. It thaws when they leave, at // the value it would have had all along, because nothing here stops the clock // so much as stops reporting it. +// +// AND A ROW DRAWN AGAINST A FROZEN CLOCK DRAWS NO CLOCK AT ALL. Stopping the +// report is not the same as reporting the stopped value: an age that stays at +// the second of the click is a wrong measurement sitting beside the room's +// right one, and a run's time is a figure the person reads to the second +// ([app.railTelemetry], [app.taskClock] and [app.railPhase] each leave theirs +// out while this is set). func (a *app) taskNow(node *taskNode) time.Time { if !node.froze.IsZero() { return node.froze diff --git a/internal/tui3/taskphase.go b/internal/tui3/taskphase.go index 56e602674..63c33f4dc 100644 --- a/internal/tui3/taskphase.go +++ b/internal/tui3/taskphase.go @@ -136,16 +136,17 @@ func taskPhaseLine(node *taskNode) string { // deliberate rather than an oversight. This row is drawn against [app.taskNow], // which FREEZES while somebody is standing in the node's room — a number // climbing in the corner of the screen is pressure applied to a person who has -// already gone to look — and the room's own row ([app.roomCallRow]) counts on -// [app.now], because inside the room the seconds this request has been out are -// exactly what they went there to see. +// already gone to look — so while it is frozen the row says the phase and leaves +// the request's figures out rather than draw them stopped; the room's own row +// ([app.roomCallRow]) counts on [app.now], because inside the room the seconds +// this request has been out are exactly what they went there to see. func (a *app) railPhase(node *taskNode, width int) []string { word := taskPhaseLine(node) line := fit(word, width) if line == "" { return nil } - if call := node.phaseCall; call != nil { + if call := node.phaseCall; call != nil && node.froze.IsZero() { fields := append([]rowField{rowSay(word)}, a.callFields(call, a.taskNow(node))...) if said := rowLed(fields, width); said != "" { line = said From 3ad6f4cf4c6f2eb66a0ad06aa8b46d5dc48f9514 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:37:30 -0400 Subject: [PATCH 118/195] session, run, delegate, codeaf: a program works in the folder it is given, on a branch of its own in a repository A program ran through the general task machinery: a copy of the folder cut for every run, the brief's paths rewritten to name the copy, the program's commits squashed and its HEAD put back at the landing, and placement rules for each, every layer patching the last; and at a shell the same program committed on the person's current branch and died in a plain folder with `workspace is not a git repository`. Now one contract holds for the conversation's run and for `codeaf senior-dev` alike (internal/session's programfolder.go). The program works in the folder itself, snapped to its repository's root. In a repository codeaf writes the person's branch down, refuses a checkout with uncommitted changes or a merge half done before anything starts, cuts `task/<title>-<id>` and leaves the person's branch where it was; anywhere else, a repository at the home folder included, the program is told `--in-place`. When the run ends, however it ends, what it left uncommitted is committed onto its branch and the branch stays checked out; a run that changed nothing switches back and deletes its branch; a HEAD the program moved is left alone and said; its notes are moved into the run's record folder. One folder takes one program run at a time, held by a file lock, and a run whose process went away is finished by the next codeaf that finds it. The copy, RehomeBrief, the squash and HEAD-restoring landing and their tests are gone; the manual, the folder rule (38 bytes shorter; both prefix waivers come down by that), the receipt, the card, the stop, the reopen and the ending lines say the new contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried.go | 146 +++- cmd/codeaf/carried_child_test.go | 9 +- cmd/codeaf/carried_folder_test.go | 250 +++++++ docs/design/delegate/DESIGN.md | 23 +- docs/design/delegate/PROTOCOL.md | 64 +- internal/delegate/delegate.go | 33 +- internal/delegate/rehome.go | 127 ---- internal/delegate/rehome_test.go | 82 --- internal/manual/chat/delegates.md | 125 ++-- internal/manual/chat/senior-dev.md | 252 +++---- internal/manual/chat_test.go | 12 + internal/run/delegateworker.go | 27 +- internal/run/delegateworker_test.go | 24 - internal/run/enginewire.go | 1 - internal/seniordev/seniordev.go | 12 +- internal/seniordev/tool/path.go | 14 +- internal/seniordev/tool/registry.go | 6 +- internal/seniordev/util/gitexclude.go | 11 +- internal/seniordev/util/gitidentity.go | 9 +- internal/session/delegate_door.go | 415 ++--------- internal/session/delegate_door_test.go | 129 ++-- internal/session/delegate_landing_test.go | 413 +++++------ internal/session/delegate_stop_test.go | 111 +-- internal/session/prefixbudget_test.go | 9 +- internal/session/program_ground_test.go | 40 +- internal/session/programfolder.go | 820 ++++++++++++++++++++++ internal/session/programfolder_test.go | 290 ++++++++ internal/session/stoprun.go | 107 +-- internal/session/task.go | 11 +- internal/session/task_run.go | 13 +- internal/session/task_run_belt.go | 443 ++++-------- internal/session/task_run_clock.go | 2 +- internal/session/task_run_clock_test.go | 8 +- internal/session/task_run_orphan_test.go | 5 +- internal/session/task_run_settle_test.go | 6 +- internal/session/taskstands.go | 41 +- 36 files changed, 2447 insertions(+), 1643 deletions(-) create mode 100644 cmd/codeaf/carried_folder_test.go delete mode 100644 internal/delegate/rehome.go delete mode 100644 internal/delegate/rehome_test.go create mode 100644 internal/session/programfolder.go create mode 100644 internal/session/programfolder_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index bafec4c25..293062b2b 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -30,6 +30,7 @@ import ( "os" "os/signal" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -109,6 +110,10 @@ type carriedRoad struct { completerFor func(model string) modelapi.Completer serves func(model string) bool seat string + // sign is the person's `attribution` row, which puts the trailer on the + // one commit codeaf writes when the run ends (internal/session's + // ProgramFolder.Finish). + sign bool } // carriedModels resolves a shell run's road. It is the person's own profile, @@ -135,6 +140,7 @@ func profileRoad() (carriedRoad, error) { completerFor: adapters.forModel, serves: func(model string) bool { return session.ServesModel(sources, model) }, seat: seats.Work.Model, + sign: settings.Attribution, }, nil } @@ -208,6 +214,28 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { 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.sign) + 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() @@ -291,7 +319,7 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { result, runErr := delegate.Run(runCtx, delegate.Launch{ Name: inv.Program.Name, Bin: exe, - Args: carriedChildLine(inv), + 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()), @@ -312,9 +340,60 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { _ = api.Close() view.closed(ended) session.CloseUsage() + finish(view.endingWords()) return view.end(result, runErr, limited.Load(), api.Spent(), ended.Sub(started)) } +// 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, sign bool) (*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, Sign: sign, + 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). @@ -478,6 +557,11 @@ type carriedView struct { // 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 { @@ -493,7 +577,46 @@ func (v *carriedView) begin() { if v.records != nil { return } - v.say("%s · working in %s", v.inv.Program.Name, v.inv.Workspace) + 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) { @@ -660,6 +783,11 @@ func (v *carriedView) end(result delegate.Result, runErr error, limited bool, sp 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 { @@ -684,6 +812,12 @@ func (v *carriedView) end(result delegate.Result, runErr error, limited bool, sp 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 { @@ -713,6 +847,14 @@ func (v *carriedView) end(result delegate.Result, runErr error, limited bool, sp 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 { diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go index dd83a0b01..990cd85f8 100644 --- a/cmd/codeaf/carried_child_test.go +++ b/cmd/codeaf/carried_child_test.go @@ -128,14 +128,19 @@ func askCarried(ctx context.Context, api delegate.ModelAPI, question string) (st // 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", and with the build's own list — senior-dev itself — when it -// is "real". +// 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 } diff --git a/cmd/codeaf/carried_folder_test.go b/cmd/codeaf/carried_folder_test.go new file mode 100644 index 000000000..1e4d48df0 --- /dev/null +++ b/cmd/codeaf/carried_folder_test.go @@ -0,0 +1,250 @@ +//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/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] -- <brief>", 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("folder", 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 +} diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index ef1f0a050..2b05bd98e 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -9,8 +9,19 @@ > 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 landing (one squashed commit for a tree, the answer folded in for -> text), the stop and the reader below all carry over. +> 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 @@ -50,7 +61,7 @@ at the person's discretion. | Command | `/<name> <brief>`, 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 | +| 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 | @@ -262,6 +273,12 @@ 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): <path>`, dozens per run. These stay on inside the copy, because senior-dev's crash recovery and its diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index 7768d7f2a..2a5ddc3e8 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -25,11 +25,11 @@ 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.** The copy a program that edits files works in, the rule that only that -copy lands, 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 +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 @@ -44,27 +44,33 @@ codeaf <name> <command> --json --dir <workspace> [--max-cost USD] [--max-hours H ``` **The folder is codeaf's to read, and the program is told what it found.** A -repository with a commit gets a working copy cut from it. A folder with no git -history (a plain folder, or a repository with no commit) has nothing to cut -from, so the program works in the folder itself and codeaf puts the program's -own `PlainFolder` flags on its line (senior-dev's is `--in-place`); its landing -commits nothing, because the work is already there, and the run's page says so. -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. - -**The brief names the copy.** Where the program works in a copy, every spelling -of the proposed folder in the brief (as proposed, resolved, under `~`) is -rewritten to the copy's path before the child is started -(`delegate.RehomeBrief`), whole paths only. A senior-dev run briefed on "the -checkout at /Users/…/happy-dom-task" ran its git commands there, in the -person's checkout, because that is what it was told. - -**A tree program's work lands as its branch.** Its commits are squashed into -one `task:` commit on the copy's branch, the branch is put where the person's -repository can reach it, the copy is given back, and nothing is merged into the -person's checkout: the page says `its work is on the branch <branch> in -<folder>; nothing was merged into your checkout`. An hour-long run meeting the -checkout's hour of changes at a merge was a finished run reading as failed. +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/<title>-<id>`; 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. 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 <branch> in +<folder>, N files, and that branch is checked out there; your branch <yours> 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, @@ -82,10 +88,10 @@ 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 run's working copy, which is cut from the folder the proposal - names (`propose_task`'s `ground`) or else the conversation's own. + that line in the folder the proposal names (`propose_task`'s `ground`) or else + the conversation's own. - **From a shell,** `codeaf <name> <brief>` becomes the host: it serves the model - API itself and starts the same child. + 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. diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index 6cc899e75..d01ff0f80 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -36,9 +36,10 @@ import ( // The two things a program can leave behind. const ( - // LandsTree is a program that works in the working copy it is given and - // leaves its changes there: codeaf squashes them into one commit and merges - // that home the way every task lands. + // 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 @@ -65,8 +66,8 @@ type Delegate struct { // the program of its own. // // THE PROGRAM OWNS WHAT IS TRUE OF IT, AND CODEAF OWNS WHAT IS TRUE OF - // EVERY PROGRAM. The copy a program works in, what lands from it and the - // fact that nobody can be asked anything are codeaf's mechanics, stated + // 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. @@ -84,23 +85,23 @@ type Delegate struct { // 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 it has a history to cut a working copy from is - // read by codeaf before the program starts: a plain folder is worked in - // where it is, 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. + // 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. // - // A PLAIN FOLDER'S NOTES ARE MOVED OUT OF IT. A program handed a folder with - // no git history 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. - // codeaf moves the folder this names into the task's own record folder when - // such a run ends. In a copy they stay in the copy, which the person's - // folder never sees. + // 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 diff --git a/internal/delegate/rehome.go b/internal/delegate/rehome.go deleted file mode 100644 index 7b61bed08..000000000 --- a/internal/delegate/rehome.go +++ /dev/null @@ -1,127 +0,0 @@ -package delegate - -import ( - "sort" - "strings" -) - -// Rehome is one spelling of a folder a brief may name, and the folder in the -// program's working copy it stands for: the folder the task was proposed on -// maps to where that folder is inside the copy, and the repository around it -// maps to the copy's root. -type Rehome struct { - From string - To string -} - -// RehomeBrief rewrites every mention of the folders a task was proposed on -// into the working copy the program was handed, so the brief a program reads -// names only the folder it works in. -// -// IT EXISTS BECAUSE A PROGRAM DID WHAT ITS BRIEF SAID. A conversation briefed -// senior-dev on "the checkout at /Users/…/happy-dom-task", which was the task's -// folder and so exactly right as a description; codeaf then handed senior-dev -// a copy of that folder, and senior-dev's model, reading the path, ran its git -// commands in the person's checkout instead. It committed there, made branches -// there, and the copy's own work would not merge over what it had done. The -// copy IS that folder as far as the work is concerned, so the brief is made to -// say so: the one fact the program needs to find its work is where it stands, -// and a path it cannot use is a path it is better never told. -// -// A mention is replaced only where it is the whole path or a path inside it — -// `/a/b` is rewritten in `/a/b` and `/a/b/src`, never in `/a/bc` or `/x/a/b` — -// so a sibling folder or a longer path is left as it was. Where several -// spellings match at one place the longest wins, so a subfolder's spelling is -// read whole before the repository around it can be. -// -// THE BRIEF IS READ ONCE, LEFT TO RIGHT, AND WHAT IS WRITTEN IS NEVER READ -// AGAIN. It was rewritten one spelling at a time, each pass over the text the -// last had produced; a copy that lives INSIDE the folder (a repository at the -// home folder, whose copies sit under ~/.codeaf) starts with that folder's own -// spelling, so every pass found its own output again and a brief came back -// naming `…/trees/3/.codeaf/…/trees/3/.codeaf/…`. A path the brief already -// spells inside the copy is copied through as it stands for the same reason. -func RehomeBrief(brief string, moves []Rehome) string { - if brief == "" { - return brief - } - type spelling struct { - text string - to string - keep bool - } - seen := map[string]bool{} - var spellings []spelling - add := func(text, to string, keep bool) { - text = strings.TrimRight(strings.TrimSpace(text), "/") - if text == "" || !strings.ContainsRune(text, '/') || seen[text] { - return - } - seen[text] = true - spellings = append(spellings, spelling{text: text, to: to, keep: keep}) - } - // THE COPY'S OWN PATHS FIRST, so a spelling of the folder that is also the - // start of a path already in the copy never claims it. - for _, move := range moves { - to := strings.TrimRight(strings.TrimSpace(move.To), "/") - if to != "" { - add(to, to, true) - } - } - for _, move := range moves { - to := strings.TrimRight(strings.TrimSpace(move.To), "/") - if to != "" { - add(move.From, to, false) - } - } - if len(spellings) == 0 { - return brief - } - sort.SliceStable(spellings, func(i, j int) bool { return len(spellings[i].text) > len(spellings[j].text) }) - - var out strings.Builder - for at := 0; at < len(brief); { - matched := false - if at == 0 || !pathByte(brief[at-1]) { - for _, one := range spellings { - if strings.HasPrefix(brief[at:], one.text) && endsName(brief[at+len(one.text):]) { - if one.keep { - out.WriteString(one.text) - } else { - out.WriteString(one.to) - } - at += len(one.text) - matched = true - break - } - } - } - if !matched { - out.WriteByte(brief[at]) - at++ - } - } - return out.String() -} - -// endsName says the text after a match does not continue its last name: it is -// empty, a separator, or a sentence's full stop and not a file's extension. -func endsName(after string) bool { - switch { - case after == "": - return true - case after[0] == '.': - return len(after) == 1 || !nameByte(after[1]) - } - return !nameByte(after[0]) -} - -// nameByte is a byte a path's last name continues through, so a match -// followed by one is a longer name and not the folder. -func nameByte(b byte) bool { - return b == '-' || b == '_' || b == '.' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' -} - -// pathByte is a byte a path runs through before a match, so a match preceded -// by one is the tail of a longer path. -func pathByte(b byte) bool { return nameByte(b) || b == '/' || b == '~' } diff --git a/internal/delegate/rehome_test.go b/internal/delegate/rehome_test.go deleted file mode 100644 index 90b41d727..000000000 --- a/internal/delegate/rehome_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package delegate - -import "testing" - -// moveAll maps every spelling to one folder, the shape a task proposed on a -// repository's own root takes. -func moveAll(to string, from ...string) []Rehome { - moves := make([]Rehome, 0, len(from)) - for _, spelling := range from { - moves = append(moves, Rehome{From: spelling, To: to}) - } - return moves -} - -// The brief a program reads names the copy it works in wherever it named the -// folder the task was proposed on, in every spelling, and leaves every other -// path alone. -func TestRehomeBriefNamesTheCopyWhereverTheFolderWasNamed(t *testing.T) { - const to = "/Users/p/.codeaf/trees/3" - moves := moveAll(to, "~/Code/app", "/Users/p/Code/app", "/private/Users/p/Code/app/") - for _, c := range []struct{ in, want string }{ - {"work in the checkout at /Users/p/Code/app (a git repo).", "work in the checkout at /Users/p/.codeaf/trees/3 (a git repo)."}, - {"cd /Users/p/Code/app/packages/x && npm test", "cd /Users/p/.codeaf/trees/3/packages/x && npm test"}, - {"the repo is ~/Code/app.", "the repo is /Users/p/.codeaf/trees/3."}, - {"resolved: /private/Users/p/Code/app", "resolved: /Users/p/.codeaf/trees/3"}, - {"`/Users/p/Code/app`", "`/Users/p/.codeaf/trees/3`"}, - // Not the folder: a sibling, a longer name, an extension, a deeper root. - {"/Users/p/Code/app-two and /Users/p/Code/apps", "/Users/p/Code/app-two and /Users/p/Code/apps"}, - {"/Users/p/Code/app.tar", "/Users/p/Code/app.tar"}, - {"/mnt/Users/p/Code/app", "/mnt/Users/p/Code/app"}, - {"nothing to rewrite", "nothing to rewrite"}, - } { - if got := RehomeBrief(c.in, moves); got != c.want { - t.Errorf("RehomeBrief(%q) = %q, want %q", c.in, got, c.want) - } - } - if got := RehomeBrief("at /a/b", moveAll("/a/b", "/a/b")); got != "at /a/b" { - t.Errorf("a copy that is the folder itself rewrote the brief: %q", got) - } -} - -// A TASK PROPOSED ON A SUBFOLDER names that subfolder inside the copy, and the -// repository around it names the copy's root. The copy is cut at the -// repository's root, so a subfolder mapped to the copy's root sent every path -// in the brief to a file that does not exist, and the repository's own -// spelling was left pointing at the person's checkout. -func TestRehomeBriefMapsASubfolderIntoTheCopyAndTheRepositoryToItsRoot(t *testing.T) { - moves := []Rehome{ - {From: "/Users/p/Code/app/packages/foo", To: "/c/trees/3/packages/foo"}, - {From: "~/Code/app/packages/foo", To: "/c/trees/3/packages/foo"}, - {From: "/Users/p/Code/app", To: "/c/trees/3"}, - {From: "~/Code/app", To: "/c/trees/3"}, - } - for _, c := range []struct{ in, want string }{ - {"fix /Users/p/Code/app/packages/foo/src/a.ts, then run git -C /Users/p/Code/app status", - "fix /c/trees/3/packages/foo/src/a.ts, then run git -C /c/trees/3 status"}, - {"cd ~/Code/app/packages/foo && make", "cd /c/trees/3/packages/foo && make"}, - {"see ~/Code/app/packages/bar/x.go", "see /c/trees/3/packages/bar/x.go"}, - } { - if got := RehomeBrief(c.in, moves); got != c.want { - t.Errorf("RehomeBrief(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -// A COPY INSIDE THE FOLDER IS NAMED ONCE. A repository at the home folder keeps -// its copies under ~/.codeaf, so the copy's path starts with the folder's own -// spelling; rewriting spelling by spelling found its own output again on every -// pass. The spellings are deduplicated, the text is read once, and a path the -// brief already spells inside the copy is left as it is. -func TestRehomeBriefNamesACopyInsideTheFolderOnce(t *testing.T) { - const to = "/Users/p/.codeaf/v3/projects/x/s/trees/3" - moves := moveAll(to, "/Users/p", "/Users/p", "/Users/p/") - for _, c := range []struct{ in, want string }{ - {"work in /Users/p on the dotfiles", "work in " + to + " on the dotfiles"}, - {"read " + to + "/notes and /Users/p/.zshrc", "read " + to + "/notes and " + to + "/.zshrc"}, - } { - if got := RehomeBrief(c.in, moves); got != c.want { - t.Errorf("RehomeBrief(%q) = %q, want %q", c.in, got, c.want) - } - } -} diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 2554133ca..e1da529de 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -4,9 +4,10 @@ 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 a copy of your folder, under this +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 leaves its work on a branch of its own when it ends. +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, `/<name> @@ -39,8 +40,8 @@ Type its name as a command, then the brief: /<name> rewrite the auth middleware to use the new session store ``` -That is `/task` with the worker chosen. A run starts at once in a copy of your folder, the -turn goes on, and the row appears on the rail. +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 @@ -80,14 +81,15 @@ stays in the conversation, but "fix this file with senior-dev" goes to senior-de looked like work, goes to codeaf's own worker and never to a program. A task never hands its work to a program. `/<name> <brief>` starts the program at once, with no card. -## Which folder a program works in — a repository I have not cloned, it edited files outside its copy, a folder with no git +## 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 a copy of one folder: the one the task names as its -`ground`, or this conversation's own folder when it 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. **Only what it changes inside that copy is kept**, on -the task's own branch. Anything it changed anywhere else is not part of the task, and the -task's ending does not see it. +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 +`/<name>` 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. **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 @@ -96,27 +98,21 @@ project's folder, and <folder> is your home folder; say which folder the work is ground`. `/<name>` 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. -**The brief it reads names its copy.** Wherever the brief names the task's folder, codeaf -rewrites that path to the copy's before the program reads it, so it is never pointed at -your checkout. The copy is of the whole repository: a subfolder is rewritten to the same -subfolder in the copy, and the repository around it to the copy's root. - So when the work belongs in a repository that is not on this machine (a benchmark task -that names a repository and a commit, or a project you have not cloned), the model clones -it first, into a new folder, onto a branch at the commit the work names, and hands the -program that folder. It is told never to write a brief that sends the program to work in -another folder, because nothing the program did there could land. +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, or a repository with no commit yet) has -nothing to copy from, so the program works in that folder itself, and codeaf tells it so -on the line it starts it with (senior-dev is given `--in-place`). Nothing is committed: its -changes are already in the folder when it ends. Its own records (senior-dev's -`.senior-dev/`) are moved out of the folder into the task's record folder when it ends. +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. -At a shell nobody does that for you: clone the repository, then run `codeaf <name>` inside -it, or name the folder with `--dir`. +At a shell, clone the repository yourself, then run `codeaf <name>` inside it, or name the +folder with `--dir`. -## What it cannot do — why it did not ask me, no questions, no step cap, why it was refused +## 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 @@ -131,52 +127,55 @@ service that reports no prices (a local proxy, a vendor's own API, a plan you si 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 runs alone.** While one is running, no other task can join its copy, and it cannot be -started under another run. Both are refused with the folder that is busy: -`work is already underway in a copy of <folder>; <name> runs alone, so propose it again -when that work has ended`. - **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 <name> was running` where -it was last seen working, or `<name> had ended; codeaf closed before its work was brought -in` at the program's exit. Nothing carries it on; the next hand-off starts a run of its own. +it was last seen working, or `<name> had ended; codeaf closed before it could say where its +work is` at the program's exit; the next codeaf to find the run commits what it left on +its branch. Nothing carries it on; the next hand-off starts a run of its own. -A name your build does not carry is refused with the ones it does: -`this codeaf carries no program called <name>; it carries …`. +## Why was the delegate refused — uncommitted changes, the folder is busy, it runs alone, no such program -## Where its work goes — its own branch, not merged into mine, squashed into one commit, the wip commits, what it costs +**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: `<folder> has changes +that are not committed (<files>); commit or stash them, then ask again`, or `<folder> 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. -A program that edits code works in a copy cut from your folder. When it ends, every commit -it made in that copy is squashed into **one commit**. The commit's subject is the task's -title, and its body is the program's own account of the ending. +**One folder takes one program run at a time**, from any conversation, any window or a +shell: `<folder> is busy: <name>, task 4 (…), is working in it, and one folder takes one +program run at a time; ask again when that run has ended`. -**That commit stays on the task's own branch** (`task/<title>-<id>`) in your repository, -and **codeaf does not merge it into your checkout**. Your files and your branch are exactly -as you left them. The task's page says `its work is on the branch <branch> in <folder>; -nothing was merged into your checkout`, and the conversation is told the same with the -number of files and the command that brings it in, `git -C '<folder>' merge <branch>`. -Ask the chat to merge it, or run that yourself, when you are ready. Nothing can conflict -when the run ends, because the landing writes nothing of yours; a conflict only appears -when you merge. +**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 <folder>; +<name> runs alone, so propose it again when that work has ended`. -A run you stop keeps its work the same way: what it had made by then is squashed into one -commit on the task's own branch, and the task says `its work so far is kept on <branch> -and did not go into <folder>`. - -If the program switched branches in its copy, its work still lands on the task's own -branch, whether it ended or you stopped it, and the branch it had moved to (even one of -yours) is never reset or committed on by codeaf; the task's page names that branch. -Commits it had made on the task's own branch before it moved stay there, under its -finished work. +A name your build does not carry is refused with the ones it does: +`this codeaf carries no program called <name>; it carries …`. -When there is nothing to land, it says `nothing to land: the run's working copy holds no -change` (`it had changed nothing` for a run you stopped), and the task's branch, which -would hold nothing, is deleted. A folder with no git history is the exception: the -program works in it directly. +## 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/<title>-<id>`) 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, however 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. The task's page +and the conversation say ``its work is on the branch <branch> in <folder>, N files, and +that branch is checked out there; your branch <yours> is as it was: `git -C '<folder>' +switch <yours>` goes back to it, and `git -C '<folder>' merge <branch>` 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 +<folder> is back on your branch <yours> and its branch <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. @@ -193,4 +192,4 @@ none: their engines need a Unix shell, process groups and file locks, so the com 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 a copy of that machine's folder. +and a run you start happens there, in that machine's folder, on a branch of its own. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 5eedd6ca4..6253a97af 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -7,7 +7,8 @@ codeaf carries. At a shell the same program is `codeaf senior-dev <brief>`. It i 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 a copy of your folder. It writes your brief down word for word, reads +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 @@ -123,9 +124,9 @@ 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 its copy 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. +`.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: @@ -140,27 +141,22 @@ 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 a copy of the folder it is handed, and only what it changes in that -copy is kept, on the task's own branch. So it has to be handed the repository the work -belongs in. +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, onto a branch at that commit, and hands -senior-dev that folder. A benchmark task works this way: senior-dev gets a copy of the -project's own repository and not of the benchmark's, so the benchmark's files, its -reference solution among them, are not in its copy. +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 you proposed is fine: codeaf rewrites that path to senior-dev's -copy before senior-dev reads it, so its commands run in the copy. The copy is of the whole -repository, so a subfolder you proposed becomes the same subfolder in the copy, and the -repository around it becomes the copy's root. - -A brief that tells senior-dev to make a checkout of its own somewhere else does not work. -It has no copy of that folder, so nothing it does there lands: its file tools refuse to -write outside its copy, and what a shell command changes out there stays where it is. +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, no step cap, no Windows @@ -177,33 +173,32 @@ on services that report no prices). `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 copy.** Its file tools (`write`, `edit`, `apply_patch`) +**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 copy lands. +same way, and nothing a shell command changes outside the folder is part of the task. -**It keeps its record in git**, unless it runs `--in-place`; from the chat, codeaf chooses -that for a folder with no git history (see the section on plain folders). +**It keeps its record in git**, on its own branch, unless it runs `--in-place`; codeaf +chooses that for a folder with no git history, from the chat and at a shell alike (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. -## senior-dev on a folder that is not a git repository — a plain folder, no git, --in-place, operation not permitted, .Trash +## 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 -From the chat, codeaf reads the task's folder before it starts senior-dev. A repository -with at least one commit gets a copy, and the work lands on a branch of its own. **A folder with no git history — -a plain folder, or a repository with no commit yet — has nothing to copy from**, so -senior-dev works in that folder itself, and codeaf starts it with `--in-place`: it commits -nothing, and keeps its checkpoints outside the folder. +Yes. The folder is read before senior-dev starts. **A folder with no git history — a +plain folder, or a repository with no first commit yet — is worked in as it is**, and +senior-dev is started with `--in-place`, from the chat and at a shell alike: it keeps its +checkpoints outside the folder and makes no commits. So is a folder inside a git +repository whose root is your home folder (a dotfiles repository): no branch is ever cut +there. -When it ends there is nothing to commit, because its changes are already in the folder. -The task's page says `its work is in <folder>, which has no git history, so nothing was -committed`. **Its own records are moved out of your folder** when the run ends or you -stop it: `.senior-dev/` (the brief, its checklist, the command it pinned, its session -database and its whole conversation with its model) goes into the task's record folder, -beside `delegate-conversation.jsonl`, and the page adds `its notes (.senior-dev/) are kept -in <path>`. A `.senior-dev/` already in the folder when the run began is left where it is. +When it ends its changes are already in the folder. The task's page says `its work is in +<folder>, which has no git history, so nothing was committed` (or, under a repository at +your home folder, `its work is in <folder>; the git repository around it is at <repo>, +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 @@ -216,80 +211,87 @@ skipped like any other. It is never started on your home folder or a folder abov (see the programs page): to check what it changed, it reads every file in the folder, and your home folder is not one project. -At a shell, pass `--in-place` yourself. Without it senior-dev stops at once with -`workspace is not a git repository: <folder>; run with --in-place to work in a plain -folder`. A shell run moves nothing: delete its `.senior-dev/` when you are done with it. - -To have its work isolated and left on a branch as one commit instead, make the folder a -repository with a first commit (`git init`, `git add -A`, `git commit`) before you ask. -Delete any `.senior-dev/` a shell run left there first, or `git add -A` commits its -database and its conversation. - -## Where senior-dev's work lands — its own branch, not merged, one squashed commit - -senior-dev commits every file it writes inside its copy (`wip(write): <path>`, -`wip(edit): <path>`), which is how it keeps a record to restore from. None of those -commits reaches your branch. When the run ends, they are squashed into **one commit** -whose subject is `task:` and the task's title, and whose body is senior-dev's own ending. - -**That commit is left on the task's own branch in your repository, and nothing is merged -into your checkout.** The task's page says `its work is on the branch <branch> in -<folder>; nothing was merged into your checkout`, and the conversation is told -``its work is on the branch <branch> in <folder>, N files; nothing was merged into your -checkout, and `git -C '<folder>' merge <branch>` brings it in``. Merge it when you are -ready, or ask the chat to. A run can take an hour, and a merge at its end used to meet -whatever changed in your checkout meanwhile; now nothing can clash until you choose to -merge. +A shell run used to stop at once there with `workspace is not a git repository: +<folder>; run with --in-place to work in a plain folder`. It no longer does: `--in-place` +is passed for you. + +## 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 <path>`. 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/<title>-<id>`) +in your folder and checks it out there, and senior-dev works on it. senior-dev commits +every file it writes (`wip(write): <path>`, `wip(edit): <path>`) 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, 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 <branch> in <folder>, N files, +and that branch is checked out there; your branch <yours> is as it was: `git -C '<folder>' +switch <yours>` goes back to it, and `git -C '<folder>' merge <branch>` 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 when it submitted (`senior-dev's model said: …`) and what senior-dev itself saw when it ran the project's build and tests (`senior-dev observed: …`). Read the second for "did it work". -Its own notes live in `.senior-dev/` in the copy: the brief, its checklist, the command -it pinned and its session database. That folder is kept out of git, so it never lands. - -**A run you stop keeps its work the same way.** What it had made by then, committed or -not, is squashed into one `task:` commit on the task's own branch, and the task says `its -work so far is kept on <branch> and did not go into <folder> · merge that branch to bring -it in, or delete it to drop it`, with the files it had changed. - -When a run changed nothing, there is nothing to land and the task says so (`it had changed -nothing` for a run you stopped); its branch, which would hold nothing, is deleted rather -than left in your repository. On a folder with no git history nothing is committed at -all: the work is already in the folder. - -## When it moved to another branch in its copy — "work on a new branch", my own branch, a detached HEAD - -Its shell can run `git checkout` in its copy, and a brief that says "work on a new -branch" makes that likely. It changes nothing about where the work lands: when the run -ends, or you stop it, codeaf puts the copy back on the task's own branch without touching -its files, and squashes the finished tree onto it. The branch it had moved to is never -reset or committed on by codeaf, even when that is one of your own branches, so what it -left there stays. - -The task's page says so beside the landing, in these words after the program's name: -`had moved its copy to the branch <branch>; its work was committed on <task branch>, and -any commit it made on <branch> is still on that branch`, or `had left its copy on no -branch; its work was committed on <task branch>`. - -When the work was not built on where the copy started (it cut its own branch from -somewhere else), the squash also undoes whatever the copy's starting point had and its -work did not, and the page adds `its work was not built on the commit its copy started -from, so the commit on <task branch> may also undo changes that commit had; read its diff -before you merge it`. - -When it had committed on the task's own branch before it moved, and the copy it left was -not built on those commits (it went back to the start to look at it, say), they are not -squashed away: the finished tree is committed on top of them, so every one stays on the -task's branch, and the page adds `the commits it had made on <task branch> are kept there, -under its finished work; that work was not built on them, so it may also undo their -changes; read its diff before you merge it`. - -In either case the conversation is told too, after the merge command: `its work was not -built on everything that branch held, so the merge may also undo changes; read its diff -before you merge it`. - -So a brief need not ask for a branch: codeaf already gives the work one. +**A run you stop keeps its work the same way**: the stop says `its work so far stays on +its branch <branch>, checked out in <folder>` at once, and the page then says where it is +in the words above. + +**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 <folder> is back on +your branch <yours> and its branch <branch> was deleted`. + +## Does senior-dev change my branch — your branch never moves, going back, a HEAD it moved + +No. Your branch (or, when your checkout was on no branch, the commit it was on) is +written down before senior-dev starts, and the run never writes to it, resets it or +merges into it. After the run your folder is on senior-dev's branch; `git -C '<folder>' +switch <yours>` goes back, and the page names the exact command. From no branch it +names `git -C '<folder>' switch --detach <commit>`. + +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 <folder> on the branch +<other> instead of its own branch <branch>, so codeaf changed nothing there: nothing was +committed and nothing was switched; <branch> holds N files` (or `on no branch, at +<commit>`). Look at that branch before you commit anything there. + +## 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: `<folder> 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: `<folder> 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, 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: `<folder> 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`). +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 @@ -382,8 +384,10 @@ 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 -At a shell, `codeaf senior-dev` prints each stage, step and model call as it happens, then -how the run ended, then `the run's record is in` and the run's record folder, and last one +At a shell, `codeaf senior-dev` first says where it works (`senior-dev · working in +<folder>, on its own branch <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: ``` @@ -410,7 +414,8 @@ ended. `codeaf senior-dev <brief>` is `codeaf senior-dev run -- <brief>`. codeaf gives every program it carries four flags: -- `--dir DIR` — the folder to work in (the current one by default); +- `--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. @@ -419,7 +424,7 @@ 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 in a folder without git: no commits, and its checkpoints kept - outside the folder; + outside the folder. codeaf passes it itself for a folder with no git history; - `--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; @@ -432,13 +437,13 @@ senior-dev's own flags on `run`: ## 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 copy has been made — to the moment senior-dev's own process ended. -Making the copy before it, and landing the work after it, are not counted. A run whose -senior-dev never started is timed to the moment the run ended. +`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 the work has landed), its row and card once it has landed, the note the +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 · <title> · 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`, @@ -468,8 +473,8 @@ conversation that started the run lists it once, by the number its rail shows. 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 whose work codeaf never brought in reads `incomplete — codeaf -closed while this was still running`. +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 @@ -484,13 +489,14 @@ A run ends in one of these ways, and the task's ending says which: 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`, no git repository at a shell without `--in-place`); + 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 its work was brought in` — senior-dev had - already exited, and codeaf stopped before its work was landed (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 committed what was left + (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 @@ -504,8 +510,12 @@ When that engine is stopped (`codeaf engine --stop`, a signal) or crashes, the c itself 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` beside it, no stage, nothing waiting on you, and no fault. If senior-dev had already exited, it reads -`senior-dev had ended; codeaf closed before its work was brought in`, and its work is -where senior-dev left it, not squashed. +`senior-dev had ended; codeaf closed before it could say where its work is`. + +**Its folder is finished by the next codeaf that finds the run**: the one that opens that +conversation, hands work off in it, or starts a run in that folder. What senior-dev left +uncommitted is committed on its branch, which stays checked out, its notes are moved out, +and the page adds where the work is, as a run that ended would say it. **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 @@ -517,6 +527,8 @@ or hands work off in it, writes it. own, under its own task number, with its own brief and its own page. The old run's 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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c44cf1a07..bb09c5147 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -957,6 +957,18 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"will codeaf hand work to a program without being asked", "delegates"}, {"is naming a delegate enough to make codeaf use it", "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 refused: changes that are not committed", "senior-dev"}, + {"senior-dev says my folder is busy", "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"}, + {"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 diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index e4a96f8f2..7f78d749c 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -10,13 +10,14 @@ package run // // What differs is inside: there is no model turn here. The program runs as a // child process of codeaf's own executable (`codeaf <name> run --json …`) in -// the run's working copy, 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. +// 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 ──────────────────────── // @@ -102,21 +103,14 @@ type DelegateSetup struct { Ledger string // Keepalive overrides the model API's keepalive interval, for a test. Keepalive time.Duration - // PlainFolder says the working folder has no git history + // PlainFolder says the program works in its folder without git // (session.RunSpec.PlainFolder), so the program's line carries its own - // flags for one (delegate.Delegate.PlainFolder). + // 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 - // Ground is every spelling of the folder the task was proposed on and of - // the repository around it, each paired with where it stands in the copy, - // when the program works in a copy (session.RunSpec.Ground). The brief is - // rewritten to name the copy wherever it named either - // (delegate.RehomeBrief), so the program is never told a path it must not - // work in. Empty for a program working in the folder itself. - Ground []delegate.Rehome // 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 @@ -473,7 +467,6 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err if brief == "" { brief = strings.TrimSpace(task.Title) } - brief = delegate.RehomeBrief(brief, w.setup.Ground) started = time.Now() sink.record.StartedAt = started result, err := delegate.Run(launchCtx, delegate.Launch{ diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go index 6efd04626..875f497e2 100644 --- a/internal/run/delegateworker_test.go +++ b/internal/run/delegateworker_test.go @@ -346,30 +346,6 @@ func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { } } -// A brief that named the folder the task was proposed on reaches the program -// naming the copy it works in, so the program is never told a path it must not -// work in. -func TestDelegateWorkerHandsTheProgramABriefThatNamesItsCopy(t *testing.T) { - store := runOpenStore(t) - ground := filepath.Join(t.TempDir(), "project") - if _, err := store.Amend(store.RootID(), "work in the checkout at "+ground+" and commit there."); err != nil { - t.Fatal(err) - } - args := filepath.Join(t.TempDir(), "args") - t.Setenv("FAKE_ARGS", args) - workspace := t.TempDir() - m, setup := fakeDelegate(t, passLine("done")) - setup.Ground = []delegate.Rehome{{From: ground, To: workspace}} - worker := run.NewDelegateWorker(store, workspace, m, setup, 0, 0) - if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil { - t.Fatal(err) - } - got, _ := os.ReadFile(args) - if !strings.Contains(string(got), "work in the checkout at "+workspace+" and commit there.") || strings.Contains(string(got), ground) { - t.Fatalf("the program was handed:\n%s\nwant the brief naming its copy %s and never %s", got, workspace, ground) - } -} - // 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. diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index f574b1ff1..6610ef3e1 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -53,7 +53,6 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar Serves: spec.Serves, Seat: WorkSeat(spec.ProfileDir, spec.WorkModel), PlainFolder: spec.PlainFolder, - Ground: spec.Ground, 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 diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index ed7fc655c..27875812d 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -1,8 +1,10 @@ //go:build !windows // Package seniordev is senior-dev: an autonomous coding agent codeaf carries -// and runs, and nothing else can. It takes one brief, works in a working copy -// under a model it reaches only through codeaf, submits a frozen candidate, +// and runs, and nothing else can. It takes one brief, works in the folder it is +// handed — on the branch codeaf cut for it there when the folder is a git +// repository (internal/session's programfolder.go) — under a model it reaches +// only through codeaf, submits a frozen candidate, // checks it with the project's own build and tests, and ends with one record // that keeps what its model claimed apart from what it saw // (internal/seniordev/app; its own account of the run is ARCHITECTURE.md in @@ -85,8 +87,10 @@ var Program = delegate.Delegate{ "carries the issue or ask in full, what done means and how to check it, and what must not change.", Lands: delegate.LandsTree, // Its recorder is git unless it is told --in-place, which keeps its - // checkpoints outside the folder and commits nothing; a folder with no git - // history has nothing else it can run on. + // checkpoints outside the folder and commits nothing. codeaf passes it for + // every folder it works in without git — no history, or a repository at + // the home folder — because the recorder's own reading climbs to any + // repository around the folder. PlainFolder: []string{"--in-place"}, // Where it keeps its records in the folder it works in: the brief, the // checklist, the pinned command, its session database and its model diff --git a/internal/seniordev/tool/path.go b/internal/seniordev/tool/path.go index 431e75a1a..d9d58ea59 100644 --- a/internal/seniordev/tool/path.go +++ b/internal/seniordev/tool/path.go @@ -44,13 +44,13 @@ func (r *Registry) resolvePath(path string) (string, error) { // both sides before they are compared (and macOS's /var and /private/var are // the same place by the same rule). // -// WHY IT EXISTS. codeaf lands only the copy of the folder a run is handed. A -// run told by its brief to "make a checkout" cloned a repository into the -// person's own projects folder and edited it there with these tools: the task -// ended saying it had changed nothing, and the edits sat in a folder of the -// person's that no task owned. Reads stay open, because a task's statement can -// live outside its copy; the shell cannot be fenced this way, and the prompt -// says so. +// WHY IT EXISTS. codeaf keeps only what a run changes in the folder it is +// handed, on the branch it cut there. A run told by its brief to "make a +// checkout" cloned a repository into the person's own projects folder and +// edited it there with these tools: the task ended saying it had changed +// nothing, and the edits sat in a folder of the person's that no task owned. +// Reads stay open, because a task's statement can live outside its folder; +// the shell cannot be fenced this way, and the prompt says so. func (r *Registry) resolveWritePath(path string) (string, error) { resolved, err := r.resolvePath(path) if err != nil || !r.confineWrites { diff --git a/internal/seniordev/tool/registry.go b/internal/seniordev/tool/registry.go index 066d09ea8..36fdfd9cd 100644 --- a/internal/seniordev/tool/registry.go +++ b/internal/seniordev/tool/registry.go @@ -181,9 +181,9 @@ type RegistryOptions struct { Config *config.Service AllowExternalDirectories bool // ConfineWrites refuses every file write outside the workspace, whatever - // AllowExternalDirectories says of reads: codeaf lands only the copy a - // program works in, so a write anywhere else is work that can never come - // back and a change made to somebody's folder directly (path.go). + // AllowExternalDirectories says of reads: codeaf keeps only what a program + // changes in the folder it is handed, so a write anywhere else is work that + // no run owns and a change made to somebody else's folder (path.go). ConfineWrites bool // HardConfineShellPaths rejects parsed external shell operands instead of // asking permission, for an embedder that must not prompt. senior-dev leaves it diff --git a/internal/seniordev/util/gitexclude.go b/internal/seniordev/util/gitexclude.go index 193beb882..afc6e73de 100644 --- a/internal/seniordev/util/gitexclude.go +++ b/internal/seniordev/util/gitexclude.go @@ -21,12 +21,11 @@ const excludeSentinel = "# senior-dev: workflow artifacts (managed by senior-dev // reads for workspace, once. // // THE FILE IS THE ONE GIT READS, which is not always <git-dir>/info/exclude. -// In a linked worktree — and the working copy codeaf cuts for a task is one — -// the git dir is .git/worktrees/<name>, and git ignores an info/ folder there -// in favour of the common dir's. An exclude written beside the git dir changed -// nothing: `.senior-dev/` stayed untracked, and a landing that stages the -// tree's own status would have committed senior-dev's database, spec and tool -// logs into the person's branch. `rev-parse --git-path` names the file git +// In a linked worktree the git dir is .git/worktrees/<name>, and git ignores +// an info/ folder there in favour of the common dir's. An exclude written +// beside the git dir changed nothing: `.senior-dev/` stayed untracked, and a +// commit of the tree's own status would have taken senior-dev's database, +// spec and tool logs with it. `rev-parse --git-path` names the file git // actually consults, which for a linked worktree is the repository's shared // one. func EnsureSeniorDevExcluded(ctx context.Context, workspace string) (bool, error) { diff --git a/internal/seniordev/util/gitidentity.go b/internal/seniordev/util/gitidentity.go index 35fc4b51b..934f62776 100644 --- a/internal/seniordev/util/gitidentity.go +++ b/internal/seniordev/util/gitidentity.go @@ -12,11 +12,10 @@ package util // command that can commit carries an identity of its own, as `-c` overrides, // which GIT_AUTHOR_* and GIT_COMMITTER_* in the environment still win over. // -// None of these commits is what a person keeps. When codeaf runs senior-dev on -// a task, the run's commits are squashed into the one commit that lands, and -// that commit carries codeaf's identity rather than this one. The address is -// therefore a local one: it names the program that made a commit and no -// account anywhere. +// These commits stay on the branch codeaf cut for the run, under the one +// commit codeaf makes of whatever the run left uncommitted when it ended, +// which carries codeaf's identity rather than this one. The address is a local +// one: it names the program that made a commit and no account anywhere. const ( CommitterName = "senior-dev" CommitterEmail = "senior-dev@localhost" diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index d352973b3..57d485c2d 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -14,10 +14,12 @@ package session // IT RIDES THE RUN ROAD WHATEVER THE BELT SAYS. `/task` takes the run road only // under CODEAF_TASK_BELT=bash, because that road's WORKER is the bash belt. A // program's worker is the program, so the road is asked for outright here: the -// store, the copy, the supervisor and the landing are the run's, and nothing in -// them reads the belt switch. What a delegated run does not have is the review -// round, because a check seat is a bash-belt worker and the belt may be off; the -// program's own verification is what its terminal record reports. +// store, the supervisor and the row are the run's, and nothing in them reads +// the belt switch. What a delegated run does not have is a copy — a program +// that edits files works in the folder itself, on a branch of its own in a +// repository (programfolder.go) — or the review round, because a check seat is +// a bash-belt worker and the belt may be off; the program's own verification is +// what its terminal record reports. import ( "context" @@ -29,7 +31,6 @@ import ( "strings" "github.com/Agent-Field/codeaf/internal/delegate" - "github.com/Agent-Field/codeaf/internal/plandb" ) // DelegateRow is one program as a surface lists it: the command word, the @@ -90,7 +91,7 @@ func (c Config) mayDelegate() bool { // a program is for, what its brief must hold and what it needs of its folder // are the program's own [delegate.Delegate.Guide], printed under its name, so // nothing here names senior-dev. What is true of every program that edits -// files — the copy it works in, what lands, and so which folder it must be +// files — that it works in the folder itself, and so which folder it must be // handed — is codeaf's mechanics, and is said here once ([delegateFolderRule]). // That nobody can be asked anything is `propose_task`'s own `brief` // description, and that small work is never handed off is this section's @@ -136,20 +137,18 @@ var delegateFact = beltFact{ // the conversation handed senior-dev the one repository it knew — the // benchmark's, which holds the task's reference solution beside its statement — // and wrote a brief telling it to make a checkout of the real one. senior-dev -// cloned it into the person's own projects folder and edited it there, outside -// the copy codeaf lands from, and the task ended saying it had changed nothing. -// The copy is cut from the folder the proposal names, so the folder is the one -// thing the model has to get right, and fetching a repository that is not here -// is its job, done before the proposal. +// cloned it into the person's own projects folder and edited it there, and the +// task ended saying it had changed nothing. The program works in the folder +// the proposal names ([PrepareProgramFolder]), so the folder is the one thing +// the model has to get right, and fetching a repository that is not here is +// its job, done before the proposal. // -// AND IT PROMISES NO MERGE, because there is none. It said "only that copy -// lands", and a model told its work lands tells the person the work is in their -// folder; a program's work is left on the task's own branch -// ([delegateKeepsBranch]), and bringing it in is a separate step. -const delegateFolderRule = "\nIt works in a copy of the task's folder, and only that copy's work is kept, on a branch\n" + - "nothing merges, so hand it the repository the work belongs in: clone one this machine\n" + - "lacks into a new folder, on a branch at the commit the work names, and pass it as\n" + - "`ground`. Never brief it to work elsewhere." +// AND IT PROMISES NO MERGE, because there is none: in a repository the work is +// left on the program's own branch, checked out in that folder, and bringing +// it into the person's branch is a separate step the landing's line names. +const delegateFolderRule = "\nIt works in the task's folder itself, on a branch of its own in a repository, so hand\n" + + "it the repository the work belongs in: clone one this machine lacks into a new folder,\n" + + "at the commit the work names, and pass it as `ground`. Never brief it to work elsewhere." // carriesTreeProgram says whether any program this conversation can hand work // to edits files, which is when [delegateFolderRule] is true of it. @@ -175,72 +174,70 @@ func (c Config) delegateGuides() string { return strings.Join(items, "\n") } -// delegateKeepsBranch says how a tree program's work comes home: ON ITS -// BRANCH, and never merged into the person's checkout by codeaf. -// -// A PROGRAM'S HOUR OF WORK IS NOT MERGED BEHIND ANYBODY'S BACK. A merge at the -// end of an hour meets whatever happened to the checkout in that hour — the -// person's own edits, another task's landing, a conversation that kept working -// — and a clash then turned a finished run into one that read as failed, with -// its work parked on a branch anyway. So the branch is the landing: one -// squashed commit, reachable from the folder the task was proposed on, and the -// conversation or the person merges it when they choose. Nothing can conflict -// at the landing, because the landing writes nothing of anybody's. -func delegateKeepsBranch(via *delegate.Delegate, plain bool) bool { - return via != nil && via.LandsTree() && !plain -} - -// branchOnlySentence is what a person is told about work that landed as its -// branch: where it is, and that it is theirs to bring in. -func branchOnlySentence(branch, root string) string { - return "its work is on the branch " + branch + " in " + root + "; nothing was merged into your checkout" -} - // delegateReceipt is the sentence an approved hand-off to a program adds to // its receipt: who has the work, where, and where it will be when it ends. -// ground is the folder the run was started on; whether it has a history to -// copy is read off it the way the run read it ([delegateOnPlainFolder]), and -// not off the live run, which a program that dies in its first second has -// already left by the time the receipt is written. +// ground is the folder the run was started on and record the run's own record +// of it, written as the run started ([runCopyOf]): the program's branch, and +// the person's branch it was cut from. They are read off the record and not off +// the live run, which a program that dies in its first second has already +// left by the time the receipt is written. // -// IT NEVER SAYS THE WORK LANDS. It said "lands when it ends", and a program's -// work is left on the task's own branch and merged by nobody; a model that read -// "lands" told the person their folder held work it did not. +// IT NEVER SAYS THE WORK LANDS. A program's work is left on its own branch and +// merged by nobody; a model that read "lands" told the person their branch +// held work it did not. // // IT NAMES THE FOLDER. A receipt that said "a copy" and "the folder itself" // without saying which let a model that had named ~/Desktop/pong read that its // program was there while it had been handed the person's home folder. -func delegateReceipt(ground string, via delegate.Delegate) string { +func delegateReceipt(ground string, via delegate.Delegate, record *TaskCopyRecord) string { if !via.LandsTree() { return "It is " + via.Name + "'s: it works alone, and its answer arrives when it ends." } - if !hasGitHistory(ground) { + if record == nil || record.Branch == "" { return "It is " + via.Name + "'s: it works alone in " + ground + " itself, which has no git history, so its changes are there as it makes them." } - return "It is " + via.Name + "'s: it works alone in a copy of " + ground + ", and when it ends its work is left on the task's own branch; nothing is merged into the checkout." + folder := ProgramFolder{Home: record.Home, Start: record.HomeSha} + return "It is " + via.Name + "'s: it works alone in " + ground + " itself, on a new branch " + record.Branch + "; " + + folder.homeWords() + " does not move, and when it ends " + record.Branch + " stays checked out there with its work." +} + +// runRowCopy is the record a run's row was published with as it started +// ([runCopyOf]): where it works, and a program's branch. Nil when there is +// none. +func (a *Agent) runRowCopy(id uint64) *TaskCopyRecord { + g := a.graph() + if g == nil { + return nil + } + if kept, ok := runRowOf(g, id); ok { + return kept.Copy + } + return nil } // programPlace is where a program works, in a person's words: the folder -// itself, or a copy of it when it has a history to copy from and the program +// itself, on a branch of its own when it is a repository and the program // edits code. func programPlace(program delegate.Delegate, ground string) string { if !program.LandsTree() || !hasGitHistory(ground) { return ground } - return "a copy of " + ground + return ground + ", on a branch of its own" } -// hasGitHistory says ground is in a repository with at least one commit — the -// same reading [delegateOnPlainFolder] makes of the copy it was given. +// hasGitHistory says a program handed ground works there on a branch of its +// own: ground is in a repository with a commit, whose root is below the home +// folder. It is the one reading of "a branch or not" ([programFolderOf]), so +// the card, the receipt and the run cannot disagree about it. func hasGitHistory(ground string) bool { - root, ok := repositoryRoot(ground) - return ok && hasCommit(root) + _, repo, _, _ := programFolderOf(ground) + return repo } // delegateStartedReceipt is an approved hand-off's receipt: a task's first line // and its wake sentence, with the program's own account of where it works -// ([Agent.delegateReceipt]) in place of a task's "in a copy of its own", which -// a program on a plain folder is not. +// ([delegateReceipt]) in place of a task's "in a copy of its own", which a +// program never is. // on is the models the person asked it to work with, "" for the crew's. func delegateStartedReceipt(id uint64, title, on, where, elsewhere string) string { if on != "" { @@ -326,7 +323,8 @@ func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, if a.config.InTask { return 0, "", "", errors.New("a task cannot hand its work to " + program.Name + "; only the conversation can") } - if refusal := programHomeRefusal(program, canonicalPath(a.config.Workspace), "open codeaf in that folder, or ask for the work in the chat and say which folder it is in"); refusal != "" { + folder := canonicalPath(a.config.Workspace) + if refusal := programHomeRefusal(program, folder, "open codeaf in that folder, or ask for the work in the chat and say which folder it is in"); refusal != "" { return 0, "", "", errors.New(refusal) } g := a.graph() @@ -335,300 +333,35 @@ func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, } id := g.reserve() title := taskPersonTitle(brief) - if err := a.startKnownTaskRunVia(ctx, id, title, brief, nil, delegateStand(a.config.Workspace, program), "", &program); err != nil { + if err := a.startKnownTaskRunVia(ctx, id, title, brief, nil, delegateStand(folder), "", &program); err != nil { return 0, "", "", err } return id, title, "", nil } -// delegateStand is where a program works. One that lands a tree gets a working -// copy of the folder, as every task does; one that lands text reads the -// person's folder in place and changes nothing, which is what it promises. -func delegateStand(workspace string, program delegate.Delegate) taskStand { - if program.LandsTree() { - return taskStand{dir: workspace, mode: TaskModeWorktree} - } - return taskStand{dir: workspace, mode: TaskModeInPlace} +// delegateStand is where a program works: the folder itself, always. One that +// edits files is readied there by [PrepareProgramFolder], on a branch of its +// own in a repository; one that lands text reads the person's folder and +// changes nothing, which is what it promises. +func delegateStand(folder string) taskStand { + return taskStand{dir: folder, mode: TaskModeInPlace} } -// landDelegateRun is a delegated run's landing, in place of the engine's own. -// -// A TREE PROGRAM'S COMMITS ARE SQUASHED. senior-dev commits every edit as it goes -// (`wip(edit): <path>`, dozens a run), so the copy's branch holds bookkeeping -// history that is the program's own and nobody else's; the engine's landing -// would also find nothing to commit, because everything is already committed, -// and answer "nothing to land" over a tree full of work. So the copy is taken -// back to the commit it stood on when the program started — recorded on the run -// at that moment, so the point is exact whatever the ground ladder put under it -// — with the tree and index kept, and committed once through the same road every -// task commits through. The subject is the task's title; the body is the -// terminal record's two sentences. Then the copy comes home the way every run's -// copy does. The one exception is a task's branch holding commits of the -// program's that the tree it left was not built on: the tree is committed on -// top of those, never over them ([headMove.squashOnto]). -// -// A TREE PROGRAM ON A PLAIN FOLDER LANDS NOTHING EITHER: there was no history -// to copy from, so it worked in the folder itself and its changes are already -// there ([delegateOnPlainFolder]). +// landDelegateRun is a delegated run's landing, in place of the engine's own: +// the program's folder finished per the contract ([ProgramFolder.Finish]) — +// what it left uncommitted committed on its branch with the run's ending as +// the commit's body, or a run that changed nothing undone — and the landing +// that says where the work is ([ProgramFolderEnd.landing]). // // A TEXT PROGRAM LANDS NOTHING: it worked in place and promised to change // nothing, and its answer is the run's result, which the outcome note carries. func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { - m := run.delegate - if m == nil || !m.LandsTree() || run.tree.dir == "" { + if run.folder == nil { return RunLanding{Home: mergeInPlace} } - if run.plain { - // A PLAIN FOLDER HAS NO HISTORY TO COMMIT TO, and the program worked in - // it where it stands: its changes are already the person's, and the - // landing is only the note that says where they are — and where the - // program's own records went, which are not the person's. - note := "its work is in " + run.ground + ", which has no git history, so nothing was committed" - if kept := a.keepPlainFolderNotes(run); kept != "" { - note += "; " + kept - } - if _, err := run.store.AddNote(run.root, run.root, note); err != nil { - if g := a.graph(); g != nil { - g.planNote("the run's landing note failed: " + err.Error()) - } - } - return RunLanding{Home: mergeInPlace} - } - dir := run.workspace - // THE SQUASH LANDS ON CODEAF'S BRANCH, WHEREVER THE PROGRAM LEFT HEAD. - moved := a.homeDelegateCopy(run) - // THE BRANCH AS THE PROGRAM LEFT IT is what says whether it held any work, - // and the landing below moves it, so it is kept for the branch-only - // landing's emptiness question ([dropEmptyTaskBranch]). - run.taskTip = moved.tip - message := "task: " + clip(firstLine(run.title), 72) - if result := strings.TrimSpace(summary.Result); result != "" { - message += "\n\n" + result - } - saved, _, _, err := commitTaskWorkAs(dir, message, nil, a.signsGitWork(), true) - landing := RunLanding{} - switch { - case err != nil: - landing.Refused = firstLine(err.Error()) - case len(saved) == 0: - landing.Refused = runNothingToLand - default: - landing.Branch, landing.Changed = currentBranch(dir), saved - landing.Unrelated = moved.warns() - } - note := landing.Refused - if note == "" { - note = fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) - } - if said := moved.sentence(m.Name, run.tree.branch, landing.Refused == ""); said != "" { - note += " · " + said - } - if _, err := run.store.AddNote(run.root, run.root, note); err != nil { - if g := a.graph(); g != nil { - g.planNote("the run's landing note failed: " + err.Error()) - } - } - return a.bringBeltRunHome(run, landing) -} - -// headMove is what a tree program had done with its copy's HEAD by the time -// it ended, as [delegateHeadHome] found it: the branch it had moved to (empty -// with detached set for no branch at all), and whether its work stood on the -// commit the copy started from. The zero value is a HEAD that never left the -// task's branch. -// -// tip is the task's branch as the program left it, read before codeaf moved -// anything; kept says that branch held commits of the program's that the HEAD -// it left was not built on, so its work is committed on top of them rather -// than squashed over them ([headMove.squashOnto]). -type headMove struct { - moved bool - from string - detached bool - unrelated bool - tip string - kept bool -} - -// squashOnto is the commit a tree program's finished tree is committed on: -// the commit its copy started from, so the program's own bookkeeping commits -// fold into one, or the task's branch as the program left it when that branch -// holds commits the finished tree was not built on. -// -// THE PROGRAM'S COMMITS ARE NEVER SQUASHED OVER FROM ELSEWHERE. senior-dev -// commits every write on the task's branch; a model that then ran `git -// checkout --detach` to look at the baseline, and was ended there by a limit, -// had that branch reset back to the start under a tree that held none of its -// work, and the branch, then empty, deleted with the only reference to an -// hour of paid commits. Committed on top, every one of them stays on the -// task's branch, and the note says the finished tree may undo them. -func (move headMove) squashOnto(startSha string) string { - if move.kept { - return move.tip - } - return startSha -} - -// warns says the landing's commit may also undo changes the branch held -// before it: work built on another commit than the copy's start, or on -// something other than the program's own commits on the task's branch. -func (move headMove) warns() bool { - return move.unrelated || move.kept -} - -// homeDelegateCopy puts a tree program's copy back on the task's own branch -// and takes that branch back to the commit its work is committed on -// ([headMove.squashOnto]), keeping the index and the files exactly as the -// program left them, so the one commit that follows holds the program's whole -// work. THE LANDING AND THE STOP BOTH TAKE IT: a stop that committed on -// whatever branch HEAD was on put codeaf's commit on the person's own branch -// while its report named the task's branch, which held nothing. -func (a *Agent) homeDelegateCopy(run *beltRun) headMove { - dir := run.workspace - move := delegateHeadHome(dir, run.tree.branch, run.startSha) - onto := move.squashOnto(run.startSha) - if onto == "" { - return move - } - if head, err := git(dir, "rev-parse", "--verify", "-q", "HEAD"); err == nil && strings.TrimSpace(head) == onto { - return move - } - if out, err := git(dir, "reset", "--soft", onto); err != nil { - if g := a.graph(); g != nil { - g.planNote(run.delegate.Name + "'s commits could not be squashed: " + firstLine(out)) - } - } - return move -} - -// delegateHeadHome puts a tree program's copy back on the task's own branch -// before its work is squashed, and answers what it found ([headMove]). -// -// A PROGRAM'S SHELL CAN MOVE HEAD, AND ONE DID. A brief said "work on a new -// branch", and senior-dev ran `git checkout -b` four times in one run. The -// landing squashed and committed on whatever branch HEAD was on, while the row, -// the note and the carry home all named codeaf's task branch, which held -// nothing: the person was told their work was on a branch that was empty. And -// where the program had checked out one of the PERSON'S OWN branches, the -// squash's `reset --soft` moved that branch back to the copy's first commit, -// taking the person's own commits off it. -// -// `git symbolic-ref` moves HEAD alone: the index and the files stay exactly as -// the program left them, so the squash and the commit that follow land its -// finished tree on the task's branch, and the branch the program moved to is -// never reset by codeaf. Any commit the program made there stays on that -// branch, which the note says. -// -// A PROGRAM WHOSE WORK DID NOT STAND ON THE COPY'S FIRST COMMIT is said out -// loud too. The squash commits the program's finished tree over that commit, -// so work the program built on some other commit (a branch cut from `main`, -// say) also undoes whatever the copy's first commit had and that one did not, -// and the diff is the only place that would show. -// -// THE TASK'S BRANCH IS READ BEFORE HEAD MOVES ONTO IT. Where it holds commits -// past the copy's start that the HEAD the program left was not built on, those -// are the program's own work, and the squash must not reset over them -// ([headMove.squashOnto]). -func delegateHeadHome(dir, branch, startSha string) headMove { - branch = strings.TrimSpace(branch) - if branch == "" { - return headMove{} - } - tip, _ := git(dir, "rev-parse", "--verify", "-q", "refs/heads/"+branch) - stay := headMove{tip: strings.TrimSpace(tip)} - current := currentBranch(dir) - if current == branch { - return stay - } - head, _ := git(dir, "rev-parse", "--verify", "-q", "HEAD") - head = strings.TrimSpace(head) - if _, err := git(dir, "symbolic-ref", "HEAD", "refs/heads/"+branch); err != nil { - return stay - } - move := headMove{moved: true, from: current, detached: current == "", tip: stay.tip} - if startSha != "" && head != "" { - _, err := git(dir, "merge-base", "--is-ancestor", startSha, head) - move.unrelated = err != nil - } - if move.tip != "" && move.tip != startSha { - _, err := git(dir, "merge-base", "--is-ancestor", move.tip, head) - move.kept = head == "" || err != nil - } - return move -} - -// sentence is what the landing note adds about a HEAD the program had moved: -// where it had left the copy, where its work was committed when anything was, -// and the warning about work built on another commit. Empty for a HEAD that -// never moved. A landing that committed nothing says only where HEAD had been, -// because "its work was committed" would be a claim about a commit that does -// not exist. -func (move headMove) sentence(name, branch string, landed bool) string { - if !move.moved { - return "" - } - said := name + " had moved its copy to the branch " + move.from - if move.detached { - said = name + " had left its copy on no branch" - } - if landed { - said += "; its work was committed on " + branch - } - if !move.detached { - said += ", and any commit it made on " + move.from + " is still on that branch" - } - switch { - case landed && move.kept: - said += " · the commits it had made on " + branch + " are kept there, under its finished work; that work was not built on them, " + - "so it may also undo their changes; read its diff before you merge it" - case landed && move.unrelated: - said += " · its work was not built on the commit its copy started from, so the commit on " + branch + - " may also undo changes that commit had; read its diff before you merge it" - } - return said -} - -// keepPlainFolderNotes moves a program's notes folder ([delegate.Delegate.Notes]) -// out of the plain folder it worked in and into the task's own record folder, -// beside its conversation with codeaf, and answers the sentence that says -// where they went ("" when nothing moved). -// -// THE PERSON'S FOLDER GETS BACK ONLY THE WORK. A senior-dev run left 46 files -// in `.senior-dev/` there — its session database and its whole conversation -// with its model among them — and the manual's own advice for isolation next -// time, `git init` then `git add -A`, would have committed every one. A notes -// folder that was already there when the run began is left alone, because it -// is not this run's alone. A move across disks falls back to a copy and then a -// removal, and a move that fails leaves the folder where it was, whole. -func (a *Agent) keepPlainFolderNotes(run *beltRun) string { - m := run.delegate - if m == nil || m.Notes == "" || run.notesWereThere || run.tree.dir == "" { - return "" - } - from := filepath.Join(run.tree.dir, m.Notes) - if info, err := os.Lstat(from); err != nil || !info.IsDir() { - return "" - } - taskDir := plandb.TaskDir(filepath.Dir(run.store.Path()), run.root) - if err := os.MkdirAll(taskDir, 0o700); err != nil { - return "" - } - to := filepath.Join(taskDir, m.Name) - for n := 1; ; n++ { - if _, err := os.Lstat(to); os.IsNotExist(err) { - break - } - to = filepath.Join(taskDir, fmt.Sprintf("%s.%d", m.Name, n)) - } - if err := os.Rename(from, to); err != nil { - if err := copyPath(from, to); err != nil { - _ = os.RemoveAll(to) - if g := a.graph(); g != nil { - g.planNote(m.Name + "'s notes could not be moved out of " + run.ground + ": " + err.Error()) - } - return "" - } - _ = os.RemoveAll(from) + outcome, result := runEndingWords(summary) + if result == "" { + result = outcome } - return "its notes (" + m.Notes + "/) are kept in " + to + return run.folder.Finish(result).landing() } diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 7fb98e0cb..ff94128c6 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "reflect" - "slices" "strconv" "strings" "testing" @@ -29,26 +28,21 @@ func testPrograms(name string) []delegate.Delegate { } // The whole road from the door to the branch: `/fake <brief>` starts a run -// whose spec names the delegate, the program's own commits in the copy are -// squashed into ONE commit whose subject is the task's title and whose body is -// the run's result, and that commit lands AS ITS BRANCH in the repository the -// copy was cut from — never merged into the person's checkout. The engine is a -// double whose `work` hook plays the program: two files, two commits, the way -// senior-dev commits every edit. -func TestADelegatedRunSquashesTheProgramsCommitsAndLandsThemAsABranch(t *testing.T) { +// whose spec names the delegate and whose workspace is THE PERSON'S FOLDER +// ITSELF, checked out on a branch codeaf cut for it. The program's own commits +// stay on that branch, what it left uncommitted is committed there in one +// commit whose subject is the task's title and whose body is the run's result, +// the branch is left checked out, and the person's own branch never moves. The +// engine is a double whose `work` hook plays the program: one file committed +// the way senior-dev commits every edit, and one left uncommitted. +func TestADelegatedRunWorksOnItsOwnBranchInTheFolderAndLeavesItCheckedOut(t *testing.T) { // The double answers the run's result off the completer it is handed, so - // the result is scripted there: the sentence the landing commit must carry. + // the result is scripted there: the sentence the last commit must carry. const result = "submitted and verified. fake's model said: tests pass" double := newBeltRunDouble(result) double.work = func(workspace string) { - for _, name := range []string{"one.txt", "two.txt"} { - if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o644); err != nil { - t.Error(err) - return - } - mustGit(t, workspace, "add", name) - mustGit(t, workspace, "-c", "user.name=p", "-c", "user.email=p@p", "commit", "-q", "-m", "wip(edit): "+name) - } + commitIn(t, workspace, "one.txt") + writeFile(t, filepath.Join(workspace, "two.txt"), "two\n") } registerBeltRunEngine(t, double) conversation := newTestRepo(t) @@ -79,46 +73,65 @@ func TestADelegatedRunSquashesTheProgramsCommitsAndLandsThemAsABranch(t *testing if spec.Brief != "add two files to the project" { t.Fatalf("brief = %q", spec.Brief) } - // The folder the task was proposed on is handed over in its spellings, so - // the program's brief names its copy wherever it named the folder. - if !slices.Contains(spec.Ground, delegate.Rehome{From: canonicalPath(conversation), To: spec.Workspace}) || canonicalPath(spec.Workspace) == canonicalPath(conversation) { - t.Fatalf("spec.Ground = %q for a copy at %q, want the proposed folder's spellings", spec.Ground, spec.Workspace) + // THE PROGRAM WORKS IN THE FOLDER ITSELF, on a branch of its own. + if canonicalPath(spec.Workspace) != canonicalPath(conversation) || spec.PlainFolder { + t.Fatalf("the program works in %q (plain %v), want the person's repository %q itself", spec.Workspace, spec.PlainFolder, conversation) + } + branch := currentBranch(conversation) + if !strings.HasPrefix(branch, "task/add-two-files-to-the-project-") { + t.Fatalf("the checkout is on %q while the program works, want a task branch of its own", branch) } endBeltRun(t, agent, double) - // THE CHECKOUT IS UNTOUCHED: nothing was merged into it. - if head := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "HEAD")); head != base { - t.Fatalf("the person's checkout moved from %s to %s; a program's work lands as its branch", base, head) + // THE PERSON'S BRANCH NEVER MOVED, and the program's branch is left checked + // out with the work in the folder. + if tip := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "work")); tip != base { + t.Fatalf("the person's branch moved from %s to %s", base, tip) + } + if head := currentBranch(conversation); head != branch { + t.Fatalf("the checkout is on %q after the run, want the program's branch %q left checked out", head, branch) } for _, name := range []string{"one.txt", "two.txt"} { - if _, err := os.Stat(filepath.Join(conversation, name)); !os.IsNotExist(err) { - t.Fatalf("%s was written into the person's checkout: %v", name, err) + if _, err := os.Stat(filepath.Join(conversation, name)); err != nil { + t.Fatalf("%s is not in the person's folder: %v", name, err) } } - // ONE COMMIT ON THE TASK'S BRANCH ABOVE THE BASE, and it is codeaf's - // landing commit, not the program's two. - branches := strings.Fields(gitOut(t, conversation, "branch", "--format=%(refname:short)", "--list", "task/*")) - if len(branches) != 1 { - t.Fatalf("want the task's one branch in the repository, got %q", branches) + if status := strings.TrimSpace(gitOut(t, conversation, "status", "--porcelain")); status != "" { + t.Fatalf("the run left the folder with changes that are not committed:\n%s", status) } - log := gitOut(t, conversation, "log", "--format=%s%n%b", base+".."+branches[0]) - subjects := strings.Fields(gitOut(t, conversation, "rev-list", base+".."+branches[0])) - if len(subjects) != 1 || strings.Contains(log, "wip(edit)") || !strings.HasPrefix(log, "task: ") { - t.Fatalf("the branch holds %d commits above the base, want one `task:` commit:\n%s", len(subjects), log) + // THE PROGRAM'S COMMIT STAYS, and codeaf's one commit of what was left is on + // top of it: the title, then the result. + subjects := strings.Fields(strings.ReplaceAll(gitOut(t, conversation, "log", "--format=%s", base+".."+branch), " ", "_")) + if len(subjects) != 2 || subjects[1] != "wip(edit):_one.txt" || !strings.HasPrefix(subjects[0], "add_two_files") { + t.Fatalf("the branch holds %q, want the program's commit under one commit of the title", subjects) } - if !strings.Contains(log, "fake's model said: tests pass") { - t.Fatalf("the landing commit's body does not carry the run's result:\n%s", log) + if body := gitOut(t, conversation, "log", "-1", "--format=%b", branch); !strings.Contains(body, "fake's model said: tests pass") { + t.Fatalf("the last commit's body does not carry the run's result:\n%s", body) } - // AND THE PAGE SAYS WHERE IT IS. + // AND THE PAGE AND THE CONVERSATION SAY WHERE IT IS AND HOW TO GO BACK. store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) defer store.Close() var said []string for _, n := range store.Notes(store.RootID(), 0) { said = append(said, n.Body) } - if joined := strings.Join(said, "\n"); !strings.Contains(joined, "its work is on the branch "+branches[0]) || - !strings.Contains(joined, "nothing was merged into your checkout") { - t.Fatalf("the run's notes = %q, want the branch it landed on", said) + root := canonicalPath(conversation) + want := "its work is on the branch " + branch + " in " + root + ", 2 files, and that branch is checked out there; your branch work is as it was: `git -C '" + + root + "' switch work` goes back to it, and `git -C '" + root + "' merge " + branch + "` from there brings the work in" + if joined := strings.Join(said, "\n"); !strings.Contains(joined, want) { + t.Fatalf("the run's notes = %q, want %q", said, want) + } + if got := conversationJournalLines(agent, want); got != 1 { + t.Fatalf("the conversation was told %d times %q", got, want) + } + var row TaskNotice + for _, kept := range agent.graph().runRows(id) { + if kept.ID == id { + row = kept + } + } + if row.Branch != branch || row.Merge != mergeKept || len(row.Changed) != 2 || row.Copy == nil || row.Copy.Branch != branch || row.Copy.Home != "work" { + t.Fatalf("the row = branch %q (%s), files %q, copy %+v; want the program's branch, kept, with both files", row.Branch, row.Merge, row.Changed, row.Copy) } } @@ -153,8 +166,8 @@ func TestADelegatedRunOnAPlainFolderIsToldSoAndLandsWhereItWorked(t *testing.T) double.mu.Lock() spec := double.spec double.mu.Unlock() - if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) || len(spec.Ground) != 0 { - t.Fatalf("spec = plain %v in %q, ground %q, want the plain folder itself, said to be one, and nothing to rewrite", spec.PlainFolder, spec.Workspace, spec.Ground) + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) { + t.Fatalf("spec = plain %v in %q, want the plain folder itself, said to be one", spec.PlainFolder, spec.Workspace) } endBeltRun(t, agent, double) @@ -176,7 +189,8 @@ func TestADelegatedRunOnAPlainFolderIsToldSoAndLandsWhereItWorked(t *testing.T) } } -// A folder with history is copied, and the program is told nothing extra. +// A folder with history is worked in on a branch, and the program is told +// nothing extra. func TestADelegatedRunOnARepositoryIsNotToldItIsPlain(t *testing.T) { double := newBeltRunDouble("done") registerBeltRunEngine(t, double) @@ -406,35 +420,38 @@ func TestThePagePrefersAProgramForItsWorkAndForTheAsk(t *testing.T) { } // THE FOLDER A PROGRAM IS HANDED IS CODEAF'S TO EXPLAIN, and it is explained -// only where it is true. A program that edits files works in a copy of the -// proposal's folder and lands only from there, so the page tells the model to -// hand it the repository the work belongs in — cloned first when this machine -// lacks it — and never to brief it to work somewhere else: the failure this -// sentence was written from is senior-dev cloning a repository into the +// only where it is true. A program that edits files works in the proposal's +// folder itself, on a branch of its own in a repository, so the page tells the +// model to hand it the repository the work belongs in — cloned first when this +// machine lacks it — and never to brief it to work somewhere else: the failure +// this sentence was written from is senior-dev cloning a repository into the // person's projects folder because its brief said to. A program that only -// answers works in place and lands nothing, so a build carrying only those is -// told nothing about copies. +// answers reads the folder and changes nothing, so a build carrying only those +// is told nothing about branches. func TestTheFolderRuleIsSaidWhereAProgramEditsFilesAndOnlyThere(t *testing.T) { tree := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} page := promptWithBeltFacts(tree) for _, want := range []string{ - "It works in a copy of the task's folder, and only that copy's work is kept, on a branch\nnothing merges", - "clone one this machine\nlacks into a new folder", - "branch at the commit the work names, and pass it as\n`ground`.", + "It works in the task's folder itself, on a branch of its own in a repository, so hand\nit the repository the work belongs in", + "clone one this machine lacks into a new folder", + "at the commit the work names, and pass it as `ground`.", "Never brief it to work elsewhere.", } { if !strings.Contains(page, want) { t.Fatalf("a build carrying a program that edits files is not told %q:\n%s", want, page) } } + if strings.Contains(page, "a copy of the task's folder") { + t.Fatalf("the page still says a program works in a copy:\n%s", page) + } textOnly := testPrograms("reader") textOnly[0].Lands = delegate.LandsText page = promptWithBeltFacts(Config{Workspace: t.TempDir(), Delegates: textOnly}) if !strings.Contains(page, "- `reader`: ") { t.Fatalf("the program that answers is not listed:\n%s", page) } - if strings.Contains(page, "copy of the task's folder") { - t.Fatalf("a build whose only program works in place is told about copies:\n%s", page) + if strings.Contains(page, "task's folder itself") { + t.Fatalf("a build whose only program reads in place is told about branches:\n%s", page) } } diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index 3e44d9fa8..5698017d0 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -1,13 +1,13 @@ package session -// WHERE A PROGRAM'S WORK LANDS, WHATEVER IT DID WITH HEAD. +// WHERE A PROGRAM'S WORK IS WHEN IT ENDS, WHATEVER IT DID IN THE FOLDER. // -// A program's shell can switch branches in its copy, and senior-dev did, four -// times in one run. The landing squashed onto whatever branch HEAD was on while -// the row and the note named codeaf's task branch, which then held nothing; and -// where the program checked out one of the person's own branches, the squash -// reset that branch and took the person's commits off it. These pin the work to -// the task's branch and the person's branches to their own history. +// A program works in the person's folder itself, on a branch codeaf cut for +// it when the folder is a repository (programfolder.go). These pin what the +// person finds when it ends: its branch checked out with everything it left +// committed there, their own branch untouched, nothing at all when it changed +// nothing, a HEAD its shell moved left exactly where it was, and its own notes +// moved out of the folder and never committed. import ( "context" @@ -69,189 +69,142 @@ func commitIn(t *testing.T, workspace string, names ...string) { } } -// taskBranchHolds asserts the task's one branch holds exactly one `task:` -// commit above base carrying every named file, and answers the branch. -func taskBranchHolds(t *testing.T, repo, base string, names ...string) string { +// taskBranchLog answers the task's one branch and the subjects of its history, +// newest first. +func taskBranchLog(t *testing.T, repo string) (string, string) { t.Helper() branches := strings.Fields(gitOut(t, repo, "branch", "--format=%(refname:short)", "--list", "task/*")) if len(branches) != 1 { t.Fatalf("want the task's one branch in the repository, got %q", branches) } - commits := strings.Fields(gitOut(t, repo, "rev-list", base+".."+branches[0])) - subject := strings.TrimSpace(gitOut(t, repo, "log", "-1", "--format=%s", branches[0])) - if len(commits) != 1 || !strings.HasPrefix(subject, "task: ") { - t.Fatalf("the task's branch holds %d commits above the base (last %q), want one `task:` commit", len(commits), subject) - } - files := gitOut(t, repo, "ls-tree", "--name-only", branches[0]) - for _, name := range names { - if !strings.Contains(files, name) { - t.Fatalf("the task's branch does not hold %s:\n%s", name, files) - } - } - return branches[0] + return branches[0], gitOut(t, repo, "log", "--format=%s", branches[0]) } -// A PROGRAM THAT SWITCHED TO A BRANCH OF ITS OWN still lands on the task's -// branch, and the note names the branch it had moved to. -func TestADelegatedRunThatSwitchedBranchLandsOnTheTaskBranch(t *testing.T) { - repo, base, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { - mustGit(t, workspace, "checkout", "-q", "-b", "senior-own") - commitIn(t, workspace, "one.txt", "two.txt") - }) - branch := taskBranchHolds(t, repo, base, "one.txt", "two.txt") - if row.Branch != branch || row.Merge != mergeKept { - t.Fatalf("the row names branch %q (%s), want the task's %q, kept", row.Branch, row.Merge, branch) - } - joined := strings.Join(notes, "\n") - if !strings.Contains(joined, "landed on "+branch+": 2 files · fake had moved its copy to the branch senior-own; its work was committed on "+branch) { - t.Fatalf("the page's notes do not say where the program had moved: %q", notes) +// A PROGRAM RUN THAT CHANGED NOTHING LEAVES NOTHING: the person's own branch is +// checked out again and the empty branch is gone, so every look-only, failed or +// crashed run does not leave one more `task/*` in the person's repository. +func TestADelegatedRunThatChangedNothingGoesBackAndLeavesNoBranch(t *testing.T) { + repo, base, row, notes := delegatedRunThatDid(t, nil, func(*testing.T, string) {}) + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a run that changed nothing left a branch behind: %q", branches) } - if strings.Contains(joined, "may also undo") { - t.Fatalf("work built on the copy's first commit was warned about: %q", notes) + if head := currentBranch(repo); head != "work" || strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) != base { + t.Fatalf("the checkout is on %q after a run that changed nothing, want the person's branch work at %s", head, base) } -} - -// A PROGRAM THAT LEFT HEAD ON NO BRANCH still lands on the task's branch. -func TestADelegatedRunOnADetachedHeadLandsOnTheTaskBranch(t *testing.T) { - repo, base, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { - mustGit(t, workspace, "checkout", "-q", "--detach") - commitIn(t, workspace, "one.txt") - }) - branch := taskBranchHolds(t, repo, base, "one.txt") - if row.Branch != branch { - t.Fatalf("the row names %q, want the task's branch %q", row.Branch, branch) + if row.Branch != "" { + t.Fatalf("the row names a branch %q over no work", row.Branch) } - if !strings.Contains(strings.Join(notes, "\n"), "fake had left its copy on no branch; its work was committed on "+branch) { - t.Fatalf("the page's notes do not say HEAD was on no branch: %q", notes) + if !strings.Contains(strings.Join(notes, "\n"), "it changed nothing, so "+canonicalPath(repo)+" is back on your branch work and its branch task/") { + t.Fatalf("the page does not say the run changed nothing and went back: %q", notes) } } -// A PROGRAM THAT CHECKED OUT THE PERSON'S OWN BRANCH never has codeaf rewrite -// it: the person's commit is still on their branch afterwards, and the work -// lands on the task's branch. -func TestADelegatedRunThatCheckedOutThePersonsBranchLeavesItsHistory(t *testing.T) { - repo, base, _, notes := delegatedRunThatDid(t, func(repo string) { - mustGit(t, repo, "checkout", "-q", "-b", "persons-feature") - commitIn(t, repo, "mine.txt") - mustGit(t, repo, "-c", "user.name=p", "-c", "user.email=p@p", "commit", "-q", "--amend", "-m", "the person's own commit") - mustGit(t, repo, "checkout", "-q", "-") +// A PERSON WHOSE CHECKOUT WAS ON NO BRANCH GETS THAT COMMIT BACK, and is told +// how to go back to it when the run leaves work. +func TestADelegatedRunFromADetachedCheckoutNamesTheCommitToGoBackTo(t *testing.T) { + repo, base, row, notes := delegatedRunThatDid(t, func(repo string) { + mustGit(t, repo, "checkout", "-q", "--detach") }, func(t *testing.T, workspace string) { - mustGit(t, workspace, "checkout", "-q", "persons-feature") - commitIn(t, workspace, "one.txt") + writeFile(t, filepath.Join(workspace, "one.txt"), "one\n") }) - if log := gitOut(t, repo, "log", "--format=%s", "persons-feature"); !strings.Contains(log, "the person's own commit") { - t.Fatalf("codeaf's landing took the person's own commit off their branch:\n%s", log) + branch, _ := taskBranchLog(t, repo) + if head := currentBranch(repo); head != branch || row.Branch != branch { + t.Fatalf("the checkout is on %q and the row names %q, want the program's branch %q", head, row.Branch, branch) } - branch := taskBranchHolds(t, repo, base, "one.txt") - if !strings.Contains(strings.Join(notes, "\n"), "fake had moved its copy to the branch persons-feature; its work was committed on "+branch) { - t.Fatalf("the page's notes do not name the person's branch the program moved to: %q", notes) + want := "your checkout was on no branch, at " + shortSha(base) + ", and `git -C '" + canonicalPath(repo) + "' switch --detach " + shortSha(base) + "` goes back to it" + if !strings.Contains(strings.Join(notes, "\n"), want) { + t.Fatalf("the page does not say how to go back to the commit: %q, want %q", notes, want) } } -// WORK NOT BUILT ON THE COPY'S FIRST COMMIT is warned about, because its squash -// may also undo what that commit had. -func TestADelegatedRunBuiltOnAnotherCommitIsWarnedAbout(t *testing.T) { - _, _, _, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { - mustGit(t, workspace, "checkout", "-q", "--orphan", "fresh") +// A HEAD THE PROGRAM'S SHELL MOVED IS LEFT WHERE IT IS. senior-dev's shell can +// run `git checkout`, and it did, four times in one run. codeaf then commits +// nothing and switches nothing: committing where HEAD is would put codeaf's +// commit on a branch that may be the person's own, and switching would carry +// whatever is in the folder somewhere nobody chose. It says where HEAD is. +func TestAProgramThatMovedHeadOffItsBranchIsLeftWhereItIs(t *testing.T) { + var left string + repo, base, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { commitIn(t, workspace, "one.txt") + mustGit(t, workspace, "checkout", "-q", "work") + writeFile(t, filepath.Join(workspace, "loose.txt"), "loose\n") + left = strings.TrimSpace(gitOut(t, workspace, "rev-parse", "HEAD")) }) - if !strings.Contains(strings.Join(notes, "\n"), "its work was not built on the commit its copy started from") { - t.Fatalf("work built on another commit landed without a word: %q", notes) + branch, log := taskBranchLog(t, repo) + if head := currentBranch(repo); head != "work" || left != base { + t.Fatalf("the checkout is on %q at %s, want it left on work where the program put it", head, left) } - // The outcome line, the one the conversation is handed, says it too. - if !strings.Contains(strings.Join(notes, "\n"), "brings it in; "+landingUnrelatedWarning) { - t.Fatalf("the conversation's merge line does not carry the warning: %q", notes) + if !strings.Contains(log, "wip(edit): one.txt") || strings.Count(log, "\n") != 2 { + t.Fatalf("the program's branch holds:\n%s\nwant its own commit and nothing of codeaf's", log) } -} - -// A PROGRAM RUN THAT CHANGED NOTHING LEAVES NO BRANCH. There is nothing on an -// empty branch to merge, and every look-only, failed or crashed run used to -// leave one more `task/*` in the person's repository. -func TestADelegatedRunThatChangedNothingLeavesNoBranch(t *testing.T) { - repo, _, row, notes := delegatedRunThatDid(t, nil, func(*testing.T, string) {}) - if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { - t.Fatalf("a run that changed nothing left a branch behind: %q", branches) + if status := gitOut(t, repo, "status", "--porcelain"); !strings.Contains(status, "loose.txt") { + t.Fatalf("codeaf committed what the program left while HEAD was elsewhere:\n%s", status) } - if row.Branch != "" { - t.Fatalf("the row names a branch %q over no work", row.Branch) + want := "fake left " + canonicalPath(repo) + " on the branch work instead of its own branch " + branch + + ", so codeaf changed nothing there: nothing was committed and nothing was switched; " + branch + " holds 1 file" + if !strings.Contains(strings.Join(notes, "\n"), want) { + t.Fatalf("the page does not say where HEAD was left: %q, want %q", notes, want) } - if !strings.Contains(strings.Join(notes, "\n"), "nothing to land: the run's working copy holds no change") { - t.Fatalf("the page does not say there was nothing to land: %q", notes) + if row.Branch != branch { + t.Fatalf("the row names %q, want the program's branch %q, which holds its commit", row.Branch, branch) } } -// THE CONVERSATION IS TOLD NOTHING WAS MERGED, WHERE THE BRANCH IS, AND HOW TO -// BRING IT IN. The line a landing delivers is the one account the chat's model -// gets, and it read like a merged run's: `landed on task/x: 2 files`. -func TestTheConversationIsToldABranchOnlyLandingWasNotMerged(t *testing.T) { - double := newBeltRunDouble("done") - double.work = func(workspace string) { commitIn(t, workspace, "one.txt", "two.txt") } - registerBeltRunEngine(t, double) - conversation := newTestRepo(t) - agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { - config.Workspace = conversation - config.Place = Place{Dir: t.TempDir()} - config.AskConsent = false - config.Delegates = testPrograms("fake") +// AND A HEAD LEFT ON NO BRANCH IS SAID WITH ITS COMMIT. +func TestAProgramThatDetachedHeadIsLeftWhereItIs(t *testing.T) { + var at string + repo, _, _, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + mustGit(t, workspace, "checkout", "-q", "--detach") + commitIn(t, workspace, "one.txt") + at = strings.TrimSpace(gitOut(t, workspace, "rev-parse", "HEAD")) }) - if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "add two files"); err != nil { - t.Fatalf("StartDelegate: %v", err) + if head := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); head != at || currentBranch(repo) != "" { + t.Fatalf("codeaf moved a detached HEAD from %s to %s", at, head) } - <-double.entered - endBeltRun(t, agent, double) - branches := strings.Fields(gitOut(t, conversation, "branch", "--format=%(refname:short)", "--list", "task/*")) - if len(branches) != 1 { - t.Fatalf("want the task's one branch, got %q", branches) - } - root := canonicalPath(conversation) - want := "its work is on the branch " + branches[0] + " in " + root + ", 2 files; nothing was merged into your checkout, and `git -C '" + - root + "' merge " + branches[0] + "` brings it in" - if got := conversationJournalLines(agent, want); got != 1 { - t.Fatalf("the conversation was told %d times %q", got, want) - } - if got := conversationJournalLines(agent, "landed on "+branches[0]); got != 0 { - t.Fatal("the conversation was told the work landed, the shape of a merged run") + if !strings.Contains(strings.Join(notes, "\n"), "fake left "+canonicalPath(repo)+" on no branch, at "+shortSha(at)+" instead of its own branch task/") { + t.Fatalf("the page does not say HEAD was left on no branch: %q", notes) } } // THE RECEIPT PROMISES NO MERGE. An approved hand-off to a program says who has -// the work and where it will be: on the task's own branch for a copy, in the -// folder itself for a folder with no history, in the conversation for one that -// only answers. +// the work and where it will be: on a new branch in the folder itself, left +// checked out, with the person's branch named as the one that does not move; +// in the folder itself for a folder with no history; in the conversation for +// a program that only answers. func TestAProgramsReceiptSaysWhereTheWorkWillBeAndPromisesNoMerge(t *testing.T) { tree := testPrograms("fake")[0] - repo, plain := newTestRepo(t), t.TempDir() - if got := delegateReceipt(repo, tree); !strings.Contains(got, "it works alone in a copy of "+repo+", and when it ends its work is left on the task's own branch; nothing is merged into the checkout.") { - t.Fatalf("the receipt for a copy = %q", got) + repo, plain := "/r/repo", "/r/plain" + record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "main", HomeSha: "0123456789abcdef"} + if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in /r/repo itself, on a new branch task/pong-abc123; your branch main does not move, and when it ends task/pong-abc123 stays checked out there with its work."; got != want { + t.Fatalf("the receipt for a repository = %q, want %q", got, want) + } + detached := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", HomeSha: "0123456789abcdef"} + if got := delegateReceipt(repo, tree, detached); !strings.Contains(got, "; the commit 0123456789ab does not move") { + t.Fatalf("the receipt for a detached checkout = %q", got) } - if got := delegateReceipt(plain, tree); !strings.Contains(got, "in "+plain+" itself, which has no git history") { + if got := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}); got != "It is fake's: it works alone in /r/plain itself, which has no git history, so its changes are there as it makes them." { t.Fatalf("the receipt for a plain folder = %q", got) } reader := tree reader.Lands = delegate.LandsText - if got := delegateReceipt(plain, reader); got != "It is fake's: it works alone, and its answer arrives when it ends." { + if got := delegateReceipt(plain, reader, nil); got != "It is fake's: it works alone, and its answer arrives when it ends." { t.Fatalf("the receipt for a program that answers = %q", got) } - for _, got := range []string{delegateReceipt(repo, tree), delegateReceipt(plain, tree), delegateReceipt(plain, reader)} { - if strings.Contains(got, "lands") { - t.Fatalf("a receipt promises a landing: %q", got) + for _, got := range []string{delegateReceipt(repo, tree, record), delegateReceipt(plain, tree, nil), delegateReceipt(plain, reader, nil)} { + if strings.Contains(got, "lands") || strings.Contains(got, "copy") { + t.Fatalf("a receipt promises a landing or a copy: %q", got) } } } -// A PROGRAM HANDED A SUBFOLDER'S TASK READS PATHS THAT EXIST IN ITS COPY. The -// copy is of the whole repository, so the subfolder is the same subfolder in -// it and the repository is the copy's root; mapping the subfolder to the copy's -// root sent every path to a file that is not there, and left the repository's -// own spelling pointing at the person's checkout. -func TestAProgramHandedASubfoldersTaskReadsPathsThatExistInItsCopy(t *testing.T) { +// A PROGRAM HANDED A FOLDER INSIDE A REPOSITORY WORKS AT THE REPOSITORY'S ROOT, +// which is where its branch is, and its brief reaches it as it was written: +// there is no copy for a path to be rewritten into. +func TestAProgramHandedASubfolderWorksAtTheRepositorysRoot(t *testing.T) { double := newBeltRunDouble("") registerBeltRunEngine(t, double) repo := newTestRepo(t) sub := filepath.Join(repo, "packages", "foo") - if err := os.MkdirAll(filepath.Join(sub, "src"), 0o755); err != nil { - t.Fatal(err) - } writeFile(t, filepath.Join(sub, "src", "a.ts"), "export {}\n") mustGit(t, repo, "add", "-A") mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "the package") @@ -269,15 +222,82 @@ func TestAProgramHandedASubfoldersTaskReadsPathsThatExistInItsCopy(t *testing.T) double.mu.Lock() spec := double.spec double.mu.Unlock() - copyRoot := spec.Workspace - want := "fix " + copyRoot + "/packages/foo/src/a.ts, then run git -C " + copyRoot + " status" - if got := delegate.RehomeBrief(spec.Brief, spec.Ground); got != want { - t.Fatalf("the program would read %q, want %q", got, want) + if canonicalPath(spec.Workspace) != canonicalPath(repo) || spec.Brief != brief { + t.Fatalf("the program works in %q on %q, want the repository's root %q and the brief as written", spec.Workspace, spec.Brief, repo) + } + endBeltRun(t, agent, double) +} + +// A PROGRAM'S NOTES IN A REPOSITORY ARE MOVED OUT AND NEVER COMMITTED. They +// are the program's records — its database and its whole conversation — and a +// commit of what the run left would otherwise have taken them onto the branch. +func TestARepositoryRunsNotesAreMovedOutAndNeverCommitted(t *testing.T) { + double := newBeltRunDouble("done") + double.work = func(workspace string) { + writeFile(t, filepath.Join(workspace, ".fake", "spec.md"), "the brief\n") + writeFile(t, filepath.Join(workspace, "made.txt"), "made\n") + } + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + programs := testPrograms("fake") + programs[0].Notes = ".fake" + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = programs + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + branch, _ := taskBranchLog(t, repo) + files := gitOut(t, repo, "ls-tree", "-r", "--name-only", branch) + if !strings.Contains(files, "made.txt") || strings.Contains(files, ".fake") { + t.Fatalf("the program's branch holds:\n%s\nwant its work and none of its notes", files) + } + taskDir := plandb.TaskDir(place, spec.Store.RootID()) + if _, err := os.Stat(filepath.Join(taskDir, "fake", "spec.md")); err != nil { + t.Fatalf("the notes are not in the task's record folder: %v", err) } - if _, err := os.Stat(filepath.Join(copyRoot, "packages", "foo", "src", "a.ts")); err != nil { - t.Fatalf("the path the program reads is not in its copy: %v", err) + if _, err := os.Stat(filepath.Join(repo, ".fake")); !os.IsNotExist(err) { + t.Fatalf("the notes were left in the person's folder: %v", err) } +} + +// NOTES THAT WERE THERE BEFORE A REPOSITORY RUN are not this run's to take, do +// not refuse the run as changes of the person's, and are not committed. +func TestNotesThatWereThereBeforeARepositoryRunStayAndAreNotCommitted(t *testing.T) { + double := newBeltRunDouble("done") + double.work = func(workspace string) { writeFile(t, filepath.Join(workspace, "made.txt"), "made\n") } + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, ".fake", "old.md"), "an earlier run's\n") + programs := testPrograms("fake") + programs[0].Notes = ".fake" + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = programs + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file"); err != nil { + t.Fatalf("a folder whose only untracked files are the program's own notes was refused: %v", err) + } + <-double.entered endBeltRun(t, agent, double) + branch, _ := taskBranchLog(t, repo) + if files := gitOut(t, repo, "ls-tree", "-r", "--name-only", branch); strings.Contains(files, ".fake") || !strings.Contains(files, "made.txt") { + t.Fatalf("the program's branch holds:\n%s", files) + } + if _, err := os.Stat(filepath.Join(repo, ".fake", "old.md")); err != nil { + t.Fatalf("notes that were there before the run were taken: %v", err) + } } // plainFolderRun runs a program that keeps notes in `.fake` on a folder with @@ -364,106 +384,3 @@ func TestAPlainFolderRunLeavesNotesThatWereThereBeforeIt(t *testing.T) { t.Fatalf("notes that were not this run's alone were moved: %v", err) } } - -// A MOVED HEAD OVER NOTHING TO LAND CLAIMS NO COMMIT. The note names where the -// program had left HEAD either way, and says its work was committed, with the -// warning about work built elsewhere, only when a commit was made. -func TestAMovedHeadsNoteClaimsACommitOnlyWhenOneWasMade(t *testing.T) { - moved := headMove{moved: true, from: "senior-own", unrelated: true} - if got := moved.sentence("fake", "task/x", false); got != "fake had moved its copy to the branch senior-own, and any commit it made on senior-own is still on that branch" { - t.Fatalf("over nothing to land the note says %q", got) - } - if got := moved.sentence("fake", "task/x", true); !strings.Contains(got, "its work was committed on task/x") || !strings.Contains(got, "read its diff before you merge it") { - t.Fatalf("over a landing the note says %q", got) - } - if got := (headMove{moved: true, detached: true}).sentence("fake", "task/x", false); got != "fake had left its copy on no branch" { - t.Fatalf("a detached HEAD over nothing to land says %q", got) - } - if got := (headMove{}).sentence("fake", "task/x", true); got != "" { - t.Fatalf("a HEAD that never moved says %q", got) - } -} - -// taskBranchLog answers the task's one branch and the subjects of its history, -// newest first. -func taskBranchLog(t *testing.T, repo string) (string, string) { - t.Helper() - branches := strings.Fields(gitOut(t, repo, "branch", "--format=%(refname:short)", "--list", "task/*")) - if len(branches) != 1 { - t.Fatalf("want the task's one branch in the repository, got %q", branches) - } - return branches[0], gitOut(t, repo, "log", "--format=%s", branches[0]) -} - -// A PROGRAM THAT COMMITTED ON THE TASK'S BRANCH AND THEN LEFT HEAD AT THE BASE -// keeps those commits. senior-dev commits every write on the task's branch; a -// model that then ran `git checkout --detach` to look at the baseline, and was -// ended there, had its branch reset back to the base over its commits, and the -// branch, then empty, deleted: the paid work was unreachable. -func TestADelegatedRunThatLeftHeadAtTheBaseKeepsItsCommitsOnTheTaskBranch(t *testing.T) { - repo, _, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { - commitIn(t, workspace, "one.txt") - mustGit(t, workspace, "checkout", "-q", "--detach", "HEAD~1") - }) - branch, log := taskBranchLog(t, repo) - if !strings.Contains(log, "wip(edit): one.txt") { - t.Fatalf("the program's own commit is gone from the task's branch:\n%s", log) - } - if row.Branch != branch { - t.Fatalf("the row names %q, want the task's branch %q", row.Branch, branch) - } - if !strings.Contains(strings.Join(notes, "\n"), "the commits it had made on "+branch+" are kept there, under its finished work") { - t.Fatalf("the page does not say the program's commits are kept under its finished work: %q", notes) - } -} - -// A PROGRAM THAT COMMITTED ON THE TASK'S BRANCH AND THEN CUT A BRANCH OFF THE -// BASE keeps those commits too: its finished tree is committed on top of them, -// never over them. -func TestADelegatedRunThatBranchedOffTheBaseKeepsItsCommitsOnTheTaskBranch(t *testing.T) { - repo, _, _, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { - commitIn(t, workspace, "one.txt") - mustGit(t, workspace, "checkout", "-q", "-b", "experiment", "HEAD~1") - commitIn(t, workspace, "exp.txt") - }) - branch, log := taskBranchLog(t, repo) - if !strings.Contains(log, "wip(edit): one.txt") { - t.Fatalf("the program's own commit is gone from the task's branch:\n%s", log) - } - if files := gitOut(t, repo, "ls-tree", "--name-only", branch); !strings.Contains(files, "exp.txt") { - t.Fatalf("the task's branch does not end with the program's finished tree:\n%s", files) - } - if !strings.Contains(strings.Join(notes, "\n"), "read its diff before you merge it") { - t.Fatalf("work not built on the program's own commits landed without a word: %q", notes) - } -} - -// A PROGRAM RUN THAT CHANGED NOTHING OVER A CHECKOUT WITH UNCOMMITTED EDITS -// LEAVES NO BRANCH EITHER. The copy started from a commit holding the person's -// edits, the landing takes that commit back out, and the branch then stood at -// the person's own commit: not the start, so it was kept, empty. -func TestADelegatedRunThatChangedNothingOverADirtyCheckoutLeavesNoBranch(t *testing.T) { - repo, _, row, _ := delegatedRunThatDid(t, func(repo string) { - writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own unfinished line\n") - }, func(*testing.T, string) {}) - if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { - t.Fatalf("a run that changed nothing over a dirty checkout left a branch behind: %q", branches) - } - if row.Branch != "" { - t.Fatalf("the row names a branch %q over no work", row.Branch) - } -} - -// THE CONVERSATION'S MERGE LINE CARRIES THE PAGE'S WARNING. The line is the one -// account of a landing the chat's model reads, and asked to merge it had nothing -// telling it to look first. -func TestTheConversationsMergeLineWarnsAboutWorkBuiltElsewhere(t *testing.T) { - landing := RunLanding{Branch: "task/x", Changed: []string{"a.go"}, Home: mergeKept, Root: "/r", Unrelated: true} - if got := beltLandingLine(landing); !strings.HasSuffix(got, "`git -C '/r' merge task/x` brings it in; "+landingUnrelatedWarning) { - t.Fatalf("the merge line does not carry the warning: %q", got) - } - landing.Unrelated = false - if got := beltLandingLine(landing); strings.Contains(got, landingUnrelatedWarning) { - t.Fatalf("work built on its start was warned about: %q", got) - } -} diff --git a/internal/session/delegate_stop_test.go b/internal/session/delegate_stop_test.go index 7333340ca..03db4266e 100644 --- a/internal/session/delegate_stop_test.go +++ b/internal/session/delegate_stop_test.go @@ -1,13 +1,12 @@ package session -// WHERE A STOPPED PROGRAM'S WORK GOES, WHATEVER IT DID WITH HEAD. +// WHERE A STOPPED PROGRAM'S WORK GOES. // -// The landing puts a program's copy back on the task's own branch before its -// work is committed; the stop did not. A stopped program's work was committed -// on whatever branch HEAD was on, one of the person's own included, while the -// report named the task's branch, which held nothing; a stop that changed -// nothing left an empty branch; and a run that had committed every write, the -// way senior-dev does, was told as having changed nothing at all. +// A stop ends a program's run the way every ending of it ends: its folder +// finished (programfolder.go), with what it had left uncommitted committed on +// its own branch, that branch left checked out, and the person's branch where +// it was. A stop that changed nothing leaves no branch, and the person is told +// at once where the work will be. import ( "context" @@ -61,66 +60,86 @@ func stoppedDelegatedRunThatDid(t *testing.T, prepare func(repo string), play fu return conversation, row, beltRunNotes(t, filepath.Dir(spec.Store.Path()), spec.Store.RootID()) } -// A STOPPED PROGRAM THAT HAD CHECKED OUT THE PERSON'S OWN BRANCH never has -// codeaf commit on it: the person's branch is exactly as the program left it, -// and the work, committed and not, is on the task's branch the report names. -func TestAStoppedProgramOnThePersonsBranchLeavesItAndKeepsItsWorkOnTheTaskBranch(t *testing.T) { - var left string - repo, row, notes := stoppedDelegatedRunThatDid(t, func(repo string) { - mustGit(t, repo, "checkout", "-q", "-b", "persons-feature") - commitIn(t, repo, "mine.txt") - mustGit(t, repo, "checkout", "-q", "-") - }, func(t *testing.T, workspace string) { - mustGit(t, workspace, "checkout", "-q", "persons-feature") +// A STOPPED PROGRAM'S WORK IS COMMITTED ON ITS BRANCH, committed by the +// program or not, and the branch is left checked out: the stop's own words are +// the body of the commit that holds what it left. +func TestAStoppedProgramsWorkIsCommittedOnItsBranchAndLeftCheckedOut(t *testing.T) { + repo, row, notes := stoppedDelegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { commitIn(t, workspace, "one.txt") writeFile(t, filepath.Join(workspace, "two.txt"), "two\n") - left = strings.TrimSpace(gitOut(t, workspace, "rev-parse", "HEAD")) }) - if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "persons-feature")); tip != left { - t.Fatalf("codeaf's stop moved the person's branch from %s to %s:\n%s", left, tip, gitOut(t, repo, "log", "--format=%s", "persons-feature")) + branch, log := taskBranchLog(t, repo) + if head := currentBranch(repo); head != branch { + t.Fatalf("the checkout is on %q after the stop, want the program's branch %q", head, branch) } - branch, _ := taskBranchLog(t, repo) files := gitOut(t, repo, "ls-tree", "--name-only", branch) for _, name := range []string{"one.txt", "two.txt"} { if !strings.Contains(files, name) { - t.Fatalf("the task's branch does not hold %s:\n%s", name, files) + t.Fatalf("the program's branch does not hold %s:\n%s", name, files) } } - if row.Branch != branch { - t.Fatalf("the row names %q, want the task's branch %q", row.Branch, branch) + if !strings.Contains(log, "wip(edit): one.txt") { + t.Fatalf("the program's own commit is gone from its branch:\n%s", log) } - joined := strings.Join(notes, "\n") - if !strings.Contains(joined, "its work so far is kept on "+branch) || !strings.Contains(joined, "fake had moved its copy to the branch persons-feature") { - t.Fatalf("the stop does not say where the work is and where the program had moved: %q", notes) + if body := gitOut(t, repo, "log", "-1", "--format=%b", branch); !strings.Contains(body, "stopped") { + t.Fatalf("the commit of what the stop left does not say it was stopped:\n%s", body) } -} - -// A STOPPED PROGRAM THAT COMMITTED EVERY WRITE is told as having changed what -// it changed. senior-dev commits each write on the task's branch, so nothing -// is left uncommitted at a stop, and the stop read that as "it had changed -// nothing" and named no branch. -func TestAStoppedProgramThatCommittedEveryWriteReportsItsFiles(t *testing.T) { - repo, row, notes := stoppedDelegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { - commitIn(t, workspace, "one.txt", "two.txt") - }) - branch, _ := taskBranchLog(t, repo) - if row.Branch != branch || len(row.Changed) != 2 { - t.Fatalf("the row names %q with %q, want the task's branch %q with both files", row.Branch, row.Changed, branch) + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")); tip != strings.TrimSpace(gitOut(t, repo, "rev-parse", branch+"~2")) { + t.Fatalf("the person's branch moved to %s", tip) + } + if row.Branch != branch || len(row.Changed) != 2 || row.Merge != mergeKept { + t.Fatalf("the row names %q with %q (%s), want the program's branch with both files", row.Branch, row.Changed, row.Merge) } - joined := strings.Join(notes, "\n") - if strings.Contains(joined, "it had changed nothing") || !strings.Contains(joined, "its work so far is kept on "+branch) { - t.Fatalf("a stopped run that committed its writes says: %q", notes) + if joined := strings.Join(notes, "\n"); !strings.Contains(joined, "stopped · its work is on the branch "+branch+" in "+canonicalPath(repo)+", 2 files, and that branch is checked out there") { + t.Fatalf("the stop does not say where the work is: %q", notes) } } // A STOPPED PROGRAM THAT CHANGED NOTHING LEAVES NO BRANCH, as one that ended -// does. +// does, and the person's own branch is checked out again. func TestAStoppedProgramThatChangedNothingLeavesNoBranch(t *testing.T) { repo, row, notes := stoppedDelegatedRunThatDid(t, nil, func(*testing.T, string) {}) if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { t.Fatalf("a stopped run that changed nothing left a branch behind: %q", branches) } - if row.Branch != "" || !strings.Contains(strings.Join(notes, "\n"), "stopped · it had changed nothing") { + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q, want the person's branch work back", head) + } + if row.Branch != "" || !strings.Contains(strings.Join(notes, "\n"), "stopped · it changed nothing, so "+canonicalPath(repo)+" is back on your branch work") { t.Fatalf("a stopped run that changed nothing draws %q and says %q", row.Branch, notes) } } + +// THE STOP SAYS AT ONCE WHERE THE WORK WILL BE: on the program's branch in the +// person's folder, not on "its branch", which named nothing a person could +// find. +func TestAStoppedProgramSaysWhereItsWorkWillBe(t *testing.T) { + double := newBeltRunDouble("unused") + double.honoursStop = true + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add files") + if err != nil { + t.Fatal(err) + } + <-double.entered + branch := currentBranch(repo) + said, err := agent.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)) + if err != nil { + t.Fatal(err) + } + if want := "its work so far stays on its branch " + branch + ", checked out in " + canonicalPath(repo); !strings.Contains(said, want) { + t.Fatalf("the stop said %q, want %q", said, want) + } + beltRunWaitFor(t, "the run to end", func() bool { + agent.beltMu.Lock() + defer agent.beltMu.Unlock() + return agent.beltRun == nil + }) +} diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index c50d74e43..e6acc12a9 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -484,9 +484,14 @@ const fixedPrefixTarget = 48_000 // by 153 to 47,859. Both arms already had room, 26 and 13 bytes of it, so // fixed rises by 176 and lean by 140, and both sit exactly on the // measurement again. +// +// 2026-09-24, a program works in the folder itself. The folder rule stopped +// describing a copy nothing merges and says the folder and its branch instead +// ([delegateFolderRule]), 38 bytes shorter on both arms, and both waivers come +// down by exactly that: fixed measures 56,409 and lean 47,821. const ( - fixedPrefixWaiver = 8_447 - leanPrefixWaiver = 16_359 + fixedPrefixWaiver = 8_409 + leanPrefixWaiver = 16_321 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go index 1c134d989..609674fe9 100644 --- a/internal/session/program_ground_test.go +++ b/internal/session/program_ground_test.go @@ -14,8 +14,10 @@ import ( // made ~/Desktop/pong, named it as ground and said `in place`; the ladder's // `in place` rung answered with the conversation's folder before ground was // read, and senior-dev was handed the person's home folder. A program's folder -// is its ground, or the conversation's folder when it names none, and `where` -// is not read for it. +// is its ground, or the conversation's folder when it names none, `where` is +// not read for it, and it is that folder itself, never a copy. A ground that +// is not there yet is taken when the folder it would be made in is there, and +// made only when the run starts; one with nowhere to be made is refused. func TestAProgramWorksInTheGroundItWasGivenAndNowhereElse(t *testing.T) { conversation := t.TempDir() ground := newTestRepo(t) @@ -25,16 +27,23 @@ func TestAProgramWorksInTheGroundItWasGivenAndNowhereElse(t *testing.T) { }) for _, where := range []string{"in place", "", filepath.Join(conversation, "elsewhere")} { stand := agent.resolveTaskGround(taskSpec{via: "fake", where: where, ground: ground, brief: "build it", deliverable: "the game", acceptance: "it runs"}) - if stand.refusal != "" || stand.ask != "" || stand.dir != canonicalPath(ground) || stand.mode != TaskModeWorktree { - t.Fatalf("where %q: stand = %+v, want a copy of the ground %s", where, stand, canonicalPath(ground)) + if stand.refusal != "" || stand.ask != "" || stand.dir != canonicalPath(ground) || stand.mode != TaskModeInPlace { + t.Fatalf("where %q: stand = %+v, want the ground %s itself", where, stand, canonicalPath(ground)) } } stand := agent.resolveTaskGround(taskSpec{via: "fake", where: "in place", brief: "build it", deliverable: "the game", acceptance: "it runs"}) if stand.refusal != "" || stand.dir != canonicalPath(conversation) { t.Fatalf("no ground: stand = %+v, want the conversation's folder %s", stand, canonicalPath(conversation)) } - if stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: filepath.Join(conversation, "missing"), brief: "b", deliverable: "d", acceptance: "a"}); !strings.Contains(stand.refusal, "not there") { - t.Fatalf("a ground that is not there: stand = %+v, want the refusal", stand) + fresh := filepath.Join(conversation, "pong") + if stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: fresh, brief: "b", deliverable: "d", acceptance: "a"}); stand.refusal != "" || stand.dir != canonicalPath(fresh) { + t.Fatalf("a new folder whose parent is there: stand = %+v, want it taken as %s", stand, canonicalPath(fresh)) + } + if _, err := os.Stat(fresh); !os.IsNotExist(err) { + t.Fatalf("the card made the folder before anybody approved the work: %v", err) + } + if stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: filepath.Join(conversation, "missing", "deeper"), brief: "b", deliverable: "d", acceptance: "a"}); !strings.Contains(stand.refusal, "not there") { + t.Fatalf("a ground with nowhere to be made: stand = %+v, want the refusal", stand) } } @@ -73,30 +82,33 @@ func TestAProgramIsNeverHandedTheHomeFolder(t *testing.T) { } } -// THE RECEIPT NAMES THE FOLDER, and whether it has a history is read off the -// folder rather than off a live run a program that died at once has left. +// THE RECEIPT NAMES THE FOLDER AND THE BRANCH, read off the record the run +// wrote as it started rather than off a live run a program that died at once +// has left. func TestAProgramsReceiptNamesItsFolder(t *testing.T) { tree := testPrograms("fake")[0] repo := newTestRepo(t) plain := t.TempDir() - if got, want := delegateReceipt(repo, tree), "It is fake's: it works alone in a copy of "+repo+", and when it ends its work is left on the task's own branch; nothing is merged into the checkout."; got != want { - t.Fatalf("the receipt for a copy = %q, want %q", got, want) + record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "work"} + if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in "+repo+" itself, on a new branch task/pong-abc123; your branch work does not move, and when it ends task/pong-abc123 stays checked out there with its work."; got != want { + t.Fatalf("the receipt for a repository = %q, want %q", got, want) } - if got, want := delegateReceipt(plain, tree), "It is fake's: it works alone in "+plain+" itself, which has no git history, so its changes are there as it makes them."; got != want { + if got, want := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}), "It is fake's: it works alone in "+plain+" itself, which has no git history, so its changes are there as it makes them."; got != want { t.Fatalf("the receipt for a plain folder = %q, want %q", got, want) } - got := delegateStartedReceipt(3, "Pong", "", delegateReceipt(plain, tree), "") + got := delegateStartedReceipt(3, "Pong", "", delegateReceipt(plain, tree, nil), "") if !strings.HasPrefix(got, "task 3 started: Pong\nIt is fake's: it works alone in "+plain+" itself") || strings.Contains(got, "a copy of its own") || !strings.Contains(got, taskHandoffWakeSentence) { t.Fatalf("the started receipt = %q", got) } } // THE CARD NAMES THE PROJECT. A program's card said `where:` and the path its -// copy would have under codeaf's state; it says the folder, or a copy of it. +// copy would have under codeaf's state; it says the folder itself, and that it +// gets a branch of its own there when the folder is a repository. func TestAProgramsCardNamesTheProject(t *testing.T) { repo, plain := newTestRepo(t), t.TempDir() config := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} - if got := taskCardWhere(config, 1, taskSpec{via: "fake", ground: repo}); got != "a copy of "+repo { + if got := taskCardWhere(config, 1, taskSpec{via: "fake", ground: repo}); got != repo+", on a branch of its own" { t.Fatalf("a repository's card says where: %q", got) } if got := taskCardWhere(config, 1, taskSpec{via: "fake", ground: plain}); got != plain { diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go new file mode 100644 index 000000000..99bb75905 --- /dev/null +++ b/internal/session/programfolder.go @@ -0,0 +1,820 @@ +package session + +// A PROGRAM WORKS IN THE FOLDER IT IS GIVEN, ON A BRANCH OF ITS OWN WHEN THAT +// FOLDER IS A GIT REPOSITORY. +// +// THE CONTRACT. This is the whole of what codeaf does to the folder a program +// that edits files works in (senior-dev first), for a run a conversation hands +// off and for one a person starts at a shell alike; senior-dev.md and +// delegates.md say it in a person's words. +// +// 1. WHICH FOLDER. The folder the proposal names as `ground`, or the +// conversation's own when it names none (a typed `/senior-dev` names none), +// or the one a shell run was started in or named with `--dir` — THAT FOLDER +// ITSELF, never a copy of it. Inside a git repository it is the +// repository's root. It is never the home folder or a folder holding it +// ([programHomeRefusal]). A folder that is not there yet is made, empty, +// when the folder it would be made in is there. +// 2. A GIT REPOSITORY — history, a commit, and a root below the home folder. +// The person's branch (or the commit their checkout is on) is written +// down, `git switch -c` cuts the program's own branch ([taskBranchName]), +// and the program works there in its own git mode. THE PERSON'S BRANCH +// NEVER MOVES. A checkout with changes that are not committed, or in the +// middle of a merge, a rebase or a cherry-pick, is refused before anything +// starts, with what is in the way named ([programCheckoutInTheWay]). +// 3. ANYTHING ELSE — no history, no commit yet, or a repository whose root is +// the home folder or above it: the program works in the folder as it is, +// started with its own flags for that ([delegate.Delegate.PlainFolder]; +// senior-dev's `--in-place`). codeaf passes them whenever it decided so, +// because the program's own reading of a folder climbs to any repository +// around it. +// 4. WHEN IT ENDS, however it ends — done, not finished, stopped, crashed, or +// found at a reopen with nothing driving it: in a repository, what the +// program left uncommitted is committed onto its branch in one commit (the +// task's title, the result under it) and the branch is LEFT CHECKED OUT, so +// the person sees the work in their folder. A run that changed nothing is +// undone: the person's branch is checked out again and the empty branch +// deleted. A HEAD the program's shell moved off its branch is left exactly +// where it is, and said. In either kind of folder the program's notes +// ([delegate.Delegate.Notes]) are moved into the run's record folder +// unless they were there before the run. +// 5. ONE RUN PER FOLDER. codeaf starts and stops the run and keeps its money, +// its time and its screen, and nothing else. A second program run on a +// folder one is working in — from any conversation, any window, or a +// shell — is refused, naming the run that holds it; the hold is a file +// lock, which dies with the process that took it ([claimProgramFolder]). +// +// WHY THERE IS SO LITTLE HERE. Until 2026-09-24 a program ran through the +// general task machinery: a copy of the folder cut for every run, the brief's +// paths rewritten to name the copy, the program's commits squashed and its +// HEAD put back at the landing, and a ladder of placement rules, each layer +// patching the one before it. The owner asked why it was so hard to have +// senior-dev just work on the problem — "if it's in a git repo, great - if +// not, just do it" — and the answer was that codeaf had made it hard. The +// copy, the rewriting, the squash and the ladder went, and this is what +// stayed. +// +// ONE ROAD FOR BOTH DOORS. The conversation's run (task_run_belt.go) and the +// shell's `codeaf senior-dev` (cmd/codeaf/carried.go) prepare a folder with +// [PrepareProgramFolder] and finish it with [ProgramFolder.Finish], so a +// person at a shell and a person in the chat get the same folder, the same +// branch, the same refusals and the same last sentence. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" +) + +// programFolderDir is where the hold on each folder a program works in, and +// the record of that run's folder, live: under the state root, keyed by the +// folder, and never inside the person's folder. +const programFolderDir = "program-folders" + +// programFolderShown is how many of the paths in the way a refused checkout +// names before it counts the rest. +const programFolderShown = 3 + +// ProgramFolderOrder is what a door hands [PrepareProgramFolder]. +type ProgramFolderOrder struct { + // Program is the program that will work in the folder. + Program delegate.Delegate + // Dir is the folder asked for, absolute. + Dir string + // Title is the run's title: the program's branch is named from it, and + // the commit that finishes the run carries it. Empty is the first words of + // Brief, the way a task names itself from its brief ([taskPersonTitle]). + Title string + Brief string + // Holder is how a second run on the folder is told whose run holds it: + // `task 4 (Fix the parser)`, or `a run started at a shell`. + Holder string + // Keep is the run's record folder. The program's notes are moved into it + // when the run ends, and it is the name a reopen finds the run's folder by + // ([settleOwedProgramFolder]). + Keep string + // Instead is what a folder that is the home folder is answered with, + // after the refusal itself ([programHomeRefusal]). + Instead string + // Place is the conversation's session folder, which says where the + // repository's git lock lives ([lockGitRoot]); zero for a shell run, + // which takes none. + Place Place + // Sign puts the attribution trailer on the commit that finishes the run + // ([signed]). + Sign bool +} + +// ProgramFolder is one program run's folder as [PrepareProgramFolder] readied +// it. It is also the record a later process finishes the run's folder from +// when the process that started it went away first +// ([settleOwedProgramFolder]), which is why its fields are written down. +type ProgramFolder struct { + // Program is the program's name, and Title is the run's. + Program string `json:"program"` + Title string `json:"title"` + // Dir is the folder the program works in, spelled the way the door asked + // for it when it asked for the folder itself. + Dir string `json:"dir"` + // Branch is the program's own branch, cut by codeaf; empty for a folder the + // program works in without git. Home is the branch the person had checked + // out, empty when their checkout was on no branch, and Start is the commit + // it stood on: together they are where going back goes. + Branch string `json:"branch,omitempty"` + Home string `json:"home,omitempty"` + Start string `json:"start,omitempty"` + // Outer is a repository around a folder worked in without git, which codeaf + // cut no branch in because its root holds the home folder. + Outer string `json:"outer,omitempty"` + // Notes is the program's notes folder inside Dir, and NotesWereThere says + // it was already there when the run began, which leaves it where it is. + Notes string `json:"notes,omitempty"` + NotesWereThere bool `json:"notesWereThere,omitempty"` + // Keep is the run's record folder ([ProgramFolderOrder.Keep]) and Sign is + // its attribution ([ProgramFolderOrder.Sign]). + Keep string `json:"keep,omitempty"` + Sign bool `json:"sign,omitempty"` + // Ended is the sentence the run's folder was finished with. Empty is a + // folder still owed its ending. + Ended string `json:"ended,omitempty"` + + key string + place Place + lock *os.File +} + +// Plain says the program works in its folder without git. +func (f *ProgramFolder) Plain() bool { return f == nil || f.Branch == "" } + +// PrepareProgramFolder readies the folder a program was asked to work in, per +// the contract at the top of this file, and holds it for the run: the folder +// resolved and made when it must be, the hold taken, a run that went away in +// it finished first, and in a repository the checkout read and the program's +// branch cut. The refusal is a sentence a person can act on, and nothing has +// been changed when there is one. +func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { + if strings.TrimSpace(order.Dir) == "" { + return nil, errors.New(order.Program.Name + " was handed no folder to work in") + } + asked := absolutePath(filepath.Clean(strings.TrimSpace(order.Dir))) + dir, repo, outer, refusal := programFolderAt(order.Program, asked, order.Instead) + if refusal != "" { + return nil, errors.New(refusal) + } + title := strings.TrimSpace(order.Title) + if title == "" { + title = taskPersonTitle(order.Brief) + } + folder := &ProgramFolder{ + Program: order.Program.Name, Title: title, Dir: dir, Outer: outer, + Notes: order.Program.Notes, Keep: order.Keep, Sign: order.Sign, + key: canonicalPath(dir), place: order.Place, + } + lock, holder := claimProgramFolder(folder.key, order.Program.Name+", "+order.Holder) + if holder != "" { + return nil, errors.New(programFolderBusy(dir, holder)) + } + folder.lock = lock + // A RUN THAT WENT AWAY IN THIS FOLDER IS FINISHED BEFORE THE NEXT ONE + // STARTS: its leftovers committed on its branch and its notes moved, so + // they are neither refused as the person's changes nor handed to the next + // run as its own. Nothing else holds the folder, because this does — and + // on a filesystem that takes no locks nothing can say so, so an owed run + // there is left for its own conversation's reopen. + if owed, ok := readProgramFolder(folder.key); ok && owed.Ended == "" && lock != nil { + owed.key, owed.place = folder.key, order.Place + owed.Ended = owed.settle("").Sentence() + owed.write() + } + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.Mkdir(dir, 0o755); err != nil { + folder.release() + return nil, fmt.Errorf("make the folder %s: %w", dir, err) + } + } + if folder.Notes != "" { + _, err := os.Lstat(filepath.Join(dir, folder.Notes)) + folder.NotesWereThere = err == nil + } + if !repo { + folder.write() + return folder, nil + } + if err := folder.cutBranch(); err != nil { + folder.release() + return nil, err + } + return folder, nil +} + +// cutBranch reads the person's checkout and cuts the program's branch in it, +// under the repository's git lock when there is a session to keep one. +func (f *ProgramFolder) cutBranch() error { + if strings.TrimSpace(f.place.Dir) != "" { + defer lockGitRoot(f.place, f.key)() + } + if refusal := programCheckoutInTheWay(f.Dir, f.Notes); refusal != "" { + return errors.New(refusal) + } + start, err := git(f.Dir, "rev-parse", "--verify", "HEAD") + if err != nil { + return fmt.Errorf("%s has no commit to cut a branch from: %s", f.Dir, firstLine(start)) + } + f.Start, f.Home = strings.TrimSpace(start), currentBranch(f.Dir) + f.Branch = taskBranchName(f.Title) + // THE RECORD IS WRITTEN BEFORE THE BRANCH IS CUT, so a process that goes + // away between the two leaves a record a later one can finish from rather + // than a branch nothing knows about. + f.write() + if out, err := git(f.Dir, "switch", "-q", "-c", f.Branch); err != nil { + _ = os.Remove(programFolderRecord(f.key)) + return fmt.Errorf("could not cut %s's branch in %s: %s", f.Program, f.Dir, firstLine(out)) + } + return nil +} + +// programFolderAt is the folder a program asked to work in asked works in, +// whether it works there on a branch, the repository around it codeaf will +// not cut one in, and the refusal when there is nowhere it may work. +func programFolderAt(program delegate.Delegate, asked, instead string) (string, bool, string, string) { + dir, repo, outer, refusal := programFolderOf(asked) + if refusal != "" { + return "", false, "", refusal + } + if refusal := programHomeRefusal(program, dir, instead); refusal != "" { + return "", false, "", refusal + } + return dir, repo, outer, "" +} + +// programFolderOf is [programFolderAt] without the home folder's refusal, +// which is the program's to say: the repository's root when asked is in a +// repository with a commit whose root is below the home folder, and asked +// itself otherwise. A folder that is not there yet is read by the folder it +// would be made in, and refused when that is not there either. +func programFolderOf(asked string) (dir string, repo bool, outer string, refusal string) { + probe := asked + if info, err := os.Stat(asked); err != nil { + parent := filepath.Dir(asked) + if info, err := os.Stat(parent); err != nil || !info.IsDir() { + return "", false, "", asked + " is not there, and neither is " + parent + ", the folder it would be made in" + } + probe = parent + } else if !info.IsDir() { + return "", false, "", asked + " is a file, not a folder" + } + root, ok := repositoryRoot(probe) + switch { + case !ok || !hasCommit(root): + return asked, false, "", "" + case holdsHomeFolder(root): + // A REPOSITORY AT THE HOME FOLDER IS NOBODY'S PROJECT. A dotfiles + // repository there would otherwise have every folder under home read as + // its subfolder, and the program's branch cut in the person's dotfiles. + return asked, false, root, "" + case canonicalPath(asked) == root: + return asked, true, "", "" + } + return root, true, "", "" +} + +// programCheckoutInTheWay is why a repository's checkout cannot take a +// program's branch now, in a sentence that says what to do; empty when nothing +// is in the way. The program's own notes folder is never in the way: it is +// the program's, and it is kept out of every commit. +// +// A CHANGE THAT IS NOT COMMITTED IS THE PERSON'S, AND A BRANCH CUT OVER IT +// TAKES IT ALONG. The program would count it as its own work, commit it with +// its first write, or — restoring a tree whose tests cannot start — put the +// file back to its last commit. So nothing starts until the person has put it +// somewhere of their own. +func programCheckoutInTheWay(dir, notes string) string { + for _, half := range []struct{ path, what string }{ + {"MERGE_HEAD", "merge"}, + {"rebase-merge", "rebase"}, + {"rebase-apply", "rebase"}, + {"CHERRY_PICK_HEAD", "cherry-pick"}, + {"REVERT_HEAD", "revert"}, + } { + out, err := git(dir, "rev-parse", "--git-path", half.path) + if err != nil { + continue + } + path := strings.TrimSpace(out) + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + if _, err := os.Lstat(path); err == nil { + return dir + " is in the middle of a " + half.what + "; finish it or abort it, then ask again" + } + } + out, err := git(dir, "status", "--porcelain", "--untracked-files=all", "-z") + if err != nil { + return "git could not read " + dir + ": " + firstLine(out) + } + var paths []string + for _, path := range porcelainZPaths(out) { + if notes != "" && (path == notes || strings.HasPrefix(path, strings.TrimSuffix(notes, "/")+"/")) { + continue + } + paths = append(paths, path) + } + if len(paths) == 0 { + return "" + } + return dir + " has changes that are not committed (" + namedFew(paths, programFolderShown) + "); commit or stash them, then ask again" +} + +// porcelainZPaths is every path `git status --porcelain -z` names, a rename +// by where it went. +func porcelainZPaths(out string) []string { + fields := strings.Split(out, "\x00") + var paths []string + for i := 0; i < len(fields); i++ { + entry := fields[i] + if len(entry) < 4 { + continue + } + paths = append(paths, entry[3:]) + if entry[0] == 'R' || entry[0] == 'C' { + // The path it came from follows, and is not a second change. + i++ + } + } + return paths +} + +// programFolderBusy is the refusal for a folder another program run holds. +func programFolderBusy(dir, holder string) string { + return dir + " is busy: " + holder + ", is working in it, and one folder takes one program run at a time; ask again when that run has ended" +} + +// ProgramFolderEnd is how a program's run left its folder, as +// [ProgramFolder.Finish] found it and made it. +type ProgramFolderEnd struct { + Folder ProgramFolder + // Changed is every path the program's branch changed from where it + // started. + Changed []string + // Kept says the program's branch holds its work. + Kept bool + // Dropped says the run changed nothing, so the person's own branch (or + // commit) is checked out again and the program's branch is gone. + Dropped bool + // Moved says HEAD was not on the program's branch when the run ended: + // HeadOn is the branch it was on, empty with At naming the commit when it + // was on none. + Moved bool + HeadOn string + At string + // Refused is git's own line when what the program left could not be + // committed, or the checkout could not be put back. + Refused string + // Notes is where the program's notes went, as a sentence. + Notes string +} + +// Finish ends a program's run in its folder, per the fourth point of the +// contract at the top of this file, and lets the folder go. result is the +// run's ending in words, the body of the commit that holds what the program +// left. It answers what it found and did. +func (f *ProgramFolder) Finish(result string) ProgramFolderEnd { + end := f.settle(result) + f.Ended = end.Sentence() + f.write() + f.release() + return end +} + +// settle is [ProgramFolder.Finish] without the record and the hold, which the +// caller owns. +func (f *ProgramFolder) settle(result string) ProgramFolderEnd { + end := ProgramFolderEnd{Folder: *f} + // THE NOTES GO FIRST, so the commit below can never hold them. + end.Notes = f.keepNotes() + if f.Branch == "" { + return end + } + if strings.TrimSpace(f.place.Dir) != "" { + defer lockGitRoot(f.place, f.key)() + } + if head := currentBranch(f.Dir); head != f.Branch { + // A HEAD THE PROGRAM MOVED IS LEFT WHERE IT IS. Committing there would put + // codeaf's commit on a branch that may be the person's own, and moving HEAD + // back would carry whatever is in the folder somewhere nobody chose; the + // person is told where it is instead, and decides. + end.Moved, end.HeadOn = true, head + if head == "" { + end.At = shortCommit(f.Dir, "HEAD") + } + if tip := branchCommit(f.Dir, f.Branch); tip != "" { + end.Changed = changedBetween(f.Dir, f.Start, tip) + end.Kept = tip != f.Start + } + return end + } + end.Refused = f.commitLeftovers(result) + head, _ := git(f.Dir, "rev-parse", "--verify", "HEAD") + end.Changed = changedSince(f.Dir, f.Start) + if end.Refused == "" && strings.TrimSpace(head) == f.Start { + // A RUN THAT CHANGED NOTHING LEAVES NOTHING: no branch holding nothing + // in the person's repository, and their own branch checked out again. + if refused := f.goBack(); refused != "" { + end.Refused = refused + return end + } + end.Dropped = true + return end + } + end.Kept = true + return end +} + +// commitLeftovers commits everything the program left uncommitted in its +// folder onto its branch, in one commit whose subject is the run's title and +// whose body is result, and answers git's line when it would not go. +// +// IT IS THE PROGRAM'S FOLDER, SO IT IS ALL OF IT. The checkout was clean when +// the branch was cut ([programCheckoutInTheWay]), so everything in it now that +// is not committed is the run's. The notes folder is left out by name as well, +// for a folder whose notes were there before the run and are not ignored. +func (f *ProgramFolder) commitLeftovers(result string) string { + add := []string{"add", "-A", "--", "."} + if f.Notes != "" { + add = append(add, ":(exclude)"+f.Notes) + } + if out, err := git(f.Dir, add...); err != nil { + return "git add: " + firstLine(out) + } + if _, err := git(f.Dir, "diff", "--cached", "--quiet"); err == nil { + return "" + } + message := clip(firstLine(f.Title), 72) + if strings.TrimSpace(message) == "" { + // A run a shell started with no brief, on a command of its own, has no + // title, and git takes no commit without a subject. + message = f.Program + "'s work" + } + if result = strings.TrimSpace(result); result != "" { + message += "\n\n" + result + } + args := append([]string{"-c", "commit.gpgsign=false"}, codeafGitIdentity()...) + args = append(args, "commit", "-q", "--no-verify", "-m", signed(message, f.Sign)) + if out, err := git(f.Dir, args...); err != nil { + return "git commit: " + firstLine(out) + } + return "" +} + +// goBack checks out the person's own branch again (or the commit their +// checkout was on) and deletes the program's empty branch, answering git's +// line when either would not go. +func (f *ProgramFolder) goBack() string { + back := []string{"switch", "-q", f.Home} + if f.Home == "" { + back = []string{"switch", "-q", "--detach", f.Start} + } + if out, err := git(f.Dir, back...); err != nil { + return firstLine(out) + } + if out, err := git(f.Dir, "branch", "-q", "-D", f.Branch); err != nil { + return firstLine(out) + } + return "" +} + +// keepNotes moves the program's notes folder out of the folder it worked in +// and into the run's record folder, and answers the sentence that says where +// they went ("" when nothing moved). +// +// THE PERSON'S FOLDER GETS BACK ONLY THE WORK. A senior-dev run left 46 files +// in `.senior-dev/` — its session database and its whole conversation with its +// model among them — where `git add -A` would commit every one; and the next +// run in the same folder read the last one's checklist and pinned command as +// its own. A notes folder that was there when the run began is left alone, +// because it is not this run's alone. A move across disks falls back to a +// copy and then a removal, and a move that fails leaves the folder whole. +func (f *ProgramFolder) keepNotes() string { + if f.Notes == "" || f.NotesWereThere || strings.TrimSpace(f.Keep) == "" { + return "" + } + from := filepath.Join(f.Dir, f.Notes) + if info, err := os.Lstat(from); err != nil || !info.IsDir() { + return "" + } + if err := os.MkdirAll(f.Keep, 0o700); err != nil { + return "" + } + to := filepath.Join(f.Keep, f.Program) + for n := 1; ; n++ { + if _, err := os.Lstat(to); os.IsNotExist(err) { + break + } + to = filepath.Join(f.Keep, fmt.Sprintf("%s.%d", f.Program, n)) + } + if err := os.Rename(from, to); err != nil { + if err := copyPath(from, to); err != nil { + _ = os.RemoveAll(to) + return "its notes (" + f.Notes + "/) could not be moved out of " + f.Dir + ": " + err.Error() + } + _ = os.RemoveAll(from) + } + return "its notes (" + f.Notes + "/) are kept in " + to +} + +// abandon lets a folder go that a run was readied in and then never started: +// the branch it cut, which holds nothing, deleted and the person's own checked +// out again. A nil folder is a run that readied none. +func (f *ProgramFolder) abandon() { + if f != nil { + f.Finish("") + } +} + +// tree is the folder as a run's tree: the folder itself, worked in where it +// is, with the program's branch and where the person's checkout was, which the +// run's row writes down ([runCopyOf]). Zero for a nil folder. +func (f *ProgramFolder) tree() taskTree { + if f == nil { + return taskTree{} + } + tree := taskTree{dir: f.Dir, merge: mergeInPlace, ground: f.Dir, mode: TaskModeInPlace, rung: GroundRungHere} + if f.Branch != "" { + tree.root, tree.branch, tree.home, tree.homeSha = f.Dir, f.Branch, f.Home, f.Start + } + return tree +} + +// StopPromise is what a person who stops a program's run is told at once +// about where its work will be. +func (f *ProgramFolder) StopPromise() string { + if f.Plain() { + return "its work so far stays in " + f.Dir + } + return "its work so far stays on its branch " + f.Branch + ", checked out in " + f.Dir +} + +// Sentence is how a run left its folder, in the one sentence the run's page, +// the conversation and a shell run's last lines all say: where the work is, +// how much of it, that its branch is checked out, and how to go back to the +// person's own branch and bring the work in. +func (e ProgramFolderEnd) Sentence() string { + f := e.Folder + var said string + switch { + case f.Branch == "" && f.Outer != "": + said = "its work is in " + f.Dir + "; the git repository around it is at " + f.Outer + + ", which holds your home folder, so codeaf cut no branch there and committed nothing" + case f.Branch == "": + said = "its work is in " + f.Dir + ", which has no git history, so nothing was committed" + case e.Moved: + where := "the branch " + e.HeadOn + if e.HeadOn == "" { + where = "no branch, at " + e.At + } + said = f.Program + " left " + f.Dir + " on " + where + " instead of its own branch " + f.Branch + + ", so codeaf changed nothing there: nothing was committed and nothing was switched" + if e.Kept { + said += "; " + f.Branch + " holds " + fileCount(len(e.Changed)) + } + case e.Dropped: + said = "it changed nothing, so " + f.Dir + " is back on " + f.homeWords() + " and its branch " + f.Branch + " was deleted" + case e.Refused != "" && !e.Kept: + said = "it changed nothing, but " + f.Dir + " could not be put back on " + f.homeWords() + " (" + e.Refused + + "), so its empty branch " + f.Branch + " is still checked out there" + case e.Refused != "": + said = "its branch " + f.Branch + " is checked out in " + f.Dir + ", but what it left uncommitted could not be committed (" + + e.Refused + "), so those changes are in the folder, uncommitted; " + f.goBackWords() + default: + said = "its work is on the branch " + f.Branch + " in " + f.Dir + ", " + fileCount(len(e.Changed)) + + ", and that branch is checked out there; " + f.goBackWords() + } + if e.Notes != "" { + said += "; " + e.Notes + } + return said +} + +// homeWords names where the person's checkout was before the run. +func (f ProgramFolder) homeWords() string { + if f.Home != "" { + return "your branch " + f.Home + } + return "the commit " + shortSha(f.Start) +} + +// goBackWords is the two commands a person holding a program's finished +// branch wants: the one that goes back to their own branch, and the one that +// brings the work in from there. THE FOLDER IS QUOTED FOR A SHELL the way +// every path this package hands one is ([shellQuoted]). +func (f ProgramFolder) goBackWords() string { + folder := shellQuoted(f.Dir) + if f.Home == "" { + return "your checkout was on no branch, at " + shortSha(f.Start) + ", and `git -C " + folder + + " switch --detach " + shortSha(f.Start) + "` goes back to it" + } + return "your branch " + f.Home + " is as it was: `git -C " + folder + " switch " + f.Home + + "` goes back to it, and `git -C " + folder + " merge " + f.Branch + "` from there brings the work in" +} + +// landing is a finished folder as the run's landing: the program's branch +// when it holds the work, the files, and the sentence ([RunLanding.Line]). +func (e ProgramFolderEnd) landing() RunLanding { + landing := RunLanding{Changed: e.Changed, Home: mergeInPlace, Line: e.Sentence()} + if e.Kept { + landing.Branch, landing.Home = e.Folder.Branch, mergeKept + } + return landing +} + +// shortSha is a commit as a person reads it. +func shortSha(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} + +// shortCommit is the commit a ref names, as a person reads it. +func shortCommit(dir, ref string) string { + out, err := git(dir, "rev-parse", "--verify", "-q", ref) + if err != nil { + return "" + } + return shortSha(strings.TrimSpace(out)) +} + +// changedSince is every path HEAD's tree differs from a commit in, empty when +// either cannot be read: the work a program's branch holds past its start. +func changedSince(dir, sha string) []string { + return changedBetween(dir, sha, "HEAD") +} + +// changedBetween is every path two commits' trees differ in. +func changedBetween(dir, from, to string) []string { + if strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" { + return nil + } + out, err := git(dir, "diff", "--name-only", from, to) + if err != nil { + return nil + } + var paths []string + for _, line := range strings.Split(out, "\n") { + if line = strings.TrimSpace(line); line != "" { + paths = append(paths, line) + } + } + return paths +} + +// claimProgramFolder takes the hold on one folder for a program's run and +// writes holder into it, so a second run is told whose it is. It answers the +// held lock, or the holder of a lock somebody else has; a nil lock with no +// holder is a filesystem that takes no locks, and the run goes ahead unheld, +// which is what every run did before the hold existed. +// +// flock DIES WITH ITS PROCESS, however it dies, so a crashed codeaf leaves no +// hold behind to be broken by hand; and it is per open file, so two +// conversations in one engine exclude each other exactly as two windows do. +func claimProgramFolder(key, holder string) (*os.File, string) { + directory := home.Join("v3", programFolderDir) + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, "" + } + path := filepath.Join(directory, programFolderName(key)+".lock") + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, "" + } + if err := filelock.Lock(file, true, true); err != nil { + _ = file.Close() + if !isLockHeld(err) { + return nil, "" + } + held, _ := os.ReadFile(path) + if said := strings.TrimSpace(string(held)); said != "" { + return nil, said + } + return nil, "another program run" + } + _ = file.Truncate(0) + _, _ = file.WriteAt([]byte(holder), 0) + return file, "" +} + +// programFolderHolder is who holds a folder now, "" when nobody does: the +// hold asked for and let go at once, for a door that only wants to know. +func programFolderHolder(key string) string { + lock, holder := claimProgramFolder(key, "") + if lock != nil { + _ = filelock.Unlock(lock) + _ = lock.Close() + } + return holder +} + +// release lets the folder go. +func (f *ProgramFolder) release() { + if f.lock == nil { + return + } + _ = filelock.Unlock(f.lock) + _ = f.lock.Close() + f.lock = nil +} + +// programFolderName is one folder's name under [programFolderDir]: the head +// of the SHA-256 of its resolved path, the way a repository's git lock is +// named ([gitRootLockFile]). +func programFolderName(key string) string { + digest := sha256.Sum256([]byte(filepath.Clean(key))) + return hex.EncodeToString(digest[:])[:gitRootLockStem] +} + +// programFolderRecord is where one folder's run is written down. +func programFolderRecord(key string) string { + return filepath.Join(home.Join("v3", programFolderDir), programFolderName(key)+".json") +} + +// write keeps the record, whole, beside the hold. It is a record, so a disk +// that refuses it costs a later process its ending and never the run. +func (f *ProgramFolder) write() { + body, err := json.MarshalIndent(f, "", " ") + if err != nil { + return + } + path := programFolderRecord(f.key) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + temporary := path + ".tmp" + if err := os.WriteFile(temporary, body, 0o600); err != nil { + return + } + _ = os.Rename(temporary, path) +} + +// readProgramFolder is the record of the last run in one folder. +func readProgramFolder(key string) (*ProgramFolder, bool) { + return readProgramFolderAt(programFolderRecord(key)) +} + +func readProgramFolderAt(path string) (*ProgramFolder, bool) { + body, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var folder ProgramFolder + if json.Unmarshal(body, &folder) != nil || strings.TrimSpace(folder.Dir) == "" { + return nil, false + } + folder.key = canonicalPath(folder.Dir) + return &folder, true +} + +// settleOwedProgramFolder finishes the folder of the run whose record folder +// is keep, when that run's process went away before it could: the reopen of +// its conversation, or the next hand-off in it, comes here +// ([endOrphanedProgramRun]). It answers how the folder was left, and false +// when nothing was owed or somebody else holds the folder now. +func settleOwedProgramFolder(keep string) (ProgramFolderEnd, bool) { + if strings.TrimSpace(keep) == "" { + return ProgramFolderEnd{}, false + } + records, _ := filepath.Glob(filepath.Join(home.Join("v3", programFolderDir), "*.json")) + for _, path := range records { + owed, ok := readProgramFolderAt(path) + if !ok || owed.Ended != "" || filepath.Clean(owed.Keep) != filepath.Clean(keep) { + continue + } + lock, holder := claimProgramFolder(owed.key, owed.Program+", finishing a run codeaf closed under") + if holder != "" || lock == nil { + // A HOLD SOMEBODY ELSE HAS, or one nobody can take, is a folder this + // reopen cannot know is idle: it is left for the next codeaf that can. + return ProgramFolderEnd{}, false + } + owed.lock = lock + return owed.Finish(""), true + } + return ProgramFolderEnd{}, false +} + +// taskBranchName is the branch a task's work is cut on: `task/`, the title as +// a branch name can spell it, and a short random tail, so the same work +// proposed twice lands on two branches. ONE SPELLING FOR EVERY ROAD that cuts +// one — a task's own worktree ([cutTaskWorktree]) and a program's branch in +// the person's folder ([PrepareProgramFolder]) — so a person reading `git +// branch` meets one shape. +func taskBranchName(title string) string { + return "task/" + slugify(title) + "-" + shortID() +} diff --git a/internal/session/programfolder_test.go b/internal/session/programfolder_test.go new file mode 100644 index 000000000..d50fc1383 --- /dev/null +++ b/internal/session/programfolder_test.go @@ -0,0 +1,290 @@ +package session + +// THE CONTRACT OF A PROGRAM'S FOLDER (programfolder.go), in real git in +// temporary repositories: which folder, a branch in a repository and nothing +// of git anywhere else, a checkout that is in the way refused before anything +// starts, one run per folder, and a run whose process went away finished by +// the next codeaf that finds it. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// programAgent is a conversation on folder carrying the fake program, with a +// run engine double registered and not yet released. +func programAgent(t *testing.T, folder string) (*Agent, *beltRunDouble) { + t.Helper() + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = folder + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent, double +} + +// A CHECKOUT WITH CHANGES THAT ARE NOT COMMITTED IS REFUSED BEFORE ANYTHING +// STARTS — a modified file, an untracked one, a staged one — naming them, and +// nothing is switched, cut or started. The proposal is refused before its +// card, in the same words. +func TestAProgramIsRefusedACheckoutWithChangesThatAreNotCommitted(t *testing.T) { + for _, tc := range []struct { + name string + dirty func(t *testing.T, repo string) + named string + }{ + {"modified", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own line\n") + }, "(shared.txt)"}, + {"untracked", func(t *testing.T, repo string) { writeFile(t, filepath.Join(repo, "notes", "draft.md"), "draft\n") }, "(notes/draft.md)"}, + {"staged", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, "new.go"), "package x\n") + mustGit(t, repo, "add", "new.go") + }, "(new.go)"}, + {"many", func(t *testing.T, repo string) { + for _, name := range []string{"a.go", "b.go", "c.go", "d.go", "e.go"} { + writeFile(t, filepath.Join(repo, name), "x\n") + } + }, "(a.go, b.go, c.go and 2 more)"}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newTestRepo(t) + tc.dirty(t, repo) + agent, double := programAgent(t, repo) + want := canonicalPath(repo) + " has changes that are not committed " + tc.named + "; commit or stash them, then ask again" + _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project") + if err == nil || err.Error() != want { + t.Fatalf("StartDelegate = %v, want %q", err, want) + } + if double.didRun() { + t.Fatal("a refused checkout started a run") + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("a refused checkout was switched to %q", head) + } + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a refused checkout was given a branch: %q", branches) + } + stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: repo, brief: "b", deliverable: "d", acceptance: "a"}) + if stand.refusal != want { + t.Fatalf("the proposal's refusal = %q, want %q", stand.refusal, want) + } + }) + } +} + +// A CHECKOUT IN THE MIDDLE OF A MERGE IS REFUSED, and says what to do. +func TestAProgramIsRefusedACheckoutInTheMiddleOfAMerge(t *testing.T) { + repo := newTestRepo(t) + mustGit(t, repo, "checkout", "-q", "-b", "other") + writeFile(t, filepath.Join(repo, "shared.txt"), "theirs\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "theirs") + mustGit(t, repo, "checkout", "-q", "work") + writeFile(t, filepath.Join(repo, "shared.txt"), "ours\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "ours") + if _, err := git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "merge", "other"); err == nil { + t.Fatal("the merge did not stop on its conflict") + } + agent, double := programAgent(t, repo) + _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project") + if err == nil || err.Error() != canonicalPath(repo)+" is in the middle of a merge; finish it or abort it, then ask again" { + t.Fatalf("StartDelegate = %v, want the merge named", err) + } + if double.didRun() { + t.Fatal("a checkout in the middle of a merge started a run") + } +} + +// A PLAIN FOLDER IS WORKED IN AS IT IS: no git is made there, and the program +// is started with its own flags for it. +func TestAProgramOnAPlainFolderGetsNoGit(t *testing.T) { + folder := t.TempDir() + agent, double := programAgent(t, folder) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a thing"); err != nil { + t.Fatal(err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) { + t.Fatalf("the program works in %q (plain %v), want the folder itself without git", spec.Workspace, spec.PlainFolder) + } + if _, err := os.Stat(filepath.Join(folder, ".git")); !os.IsNotExist(err) { + t.Fatalf("a plain folder was made a repository: %v", err) + } +} + +// A REPOSITORY AT THE HOME FOLDER IS NOBODY'S PROJECT. A folder under a +// dotfiles repository rooted at home is worked in as a plain folder — no +// branch cut in the person's dotfiles, the program told it works without git +// — and the ending says why nothing was committed. +func TestAFolderInARepositoryAtTheHomeFolderIsWorkedInWithoutGit(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + mustGit(t, home, "init", "-q") + writeFile(t, filepath.Join(home, ".zshrc"), "export A=1\n") + mustGit(t, home, "add", "-A") + mustGit(t, home, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "dotfiles") + before := strings.TrimSpace(gitOut(t, home, "rev-parse", "HEAD")) + project := filepath.Join(home, "Desktop", "pong") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + double := newBeltRunDouble("done") + double.work = func(workspace string) { writeFile(t, filepath.Join(workspace, "pong.py"), "print('pong')\n") } + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = project + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "build pong"); err != nil { + t.Fatal(err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(project) { + t.Fatalf("the program works in %q (plain %v), want %q without git", spec.Workspace, spec.PlainFolder, project) + } + if after := strings.TrimSpace(gitOut(t, home, "rev-parse", "HEAD")); after != before || strings.HasPrefix(currentBranch(home), "task/") { + t.Fatalf("the dotfiles repository moved from %s to %s (on %q)", before, after, currentBranch(home)) + } + if branches := strings.TrimSpace(gitOut(t, home, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a branch was cut in the repository at home: %q", branches) + } + notes := beltRunNotes(t, filepath.Dir(spec.Store.Path()), spec.Store.RootID()) + if !strings.Contains(strings.Join(notes, "\n"), "the git repository around it is at "+canonicalPath(home)+", which holds your home folder, so codeaf cut no branch there and committed nothing") { + t.Fatalf("the page does not say why nothing was committed: %q", notes) + } +} + +// A GROUND THAT IS NOT THERE YET IS MADE WHEN THE RUN STARTS, empty, and the +// program works in it. +func TestAMissingGroundIsMadeWhenTheRunStarts(t *testing.T) { + parent := t.TempDir() + fresh := filepath.Join(parent, "pong") + agent, double := programAgent(t, parent) + stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: fresh, brief: "b", deliverable: "d", acceptance: "a"}) + if stand.refusal != "" { + t.Fatalf("a new folder was refused: %q", stand.refusal) + } + program := testPrograms("fake")[0] + if err := agent.startKnownTaskRunVia(context.Background(), agent.graph().reserve(), "Pong", "build pong", nil, stand, "", &program); err != nil { + t.Fatal(err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + if info, err := os.Stat(fresh); err != nil || !info.IsDir() || canonicalPath(spec.Workspace) != canonicalPath(fresh) { + t.Fatalf("the program works in %q, and the new folder is %v (%v)", spec.Workspace, info, err) + } +} + +// ONE RUN PER FOLDER. A second program run on a folder one is working in — +// from another conversation here — is refused, naming the run that holds it, +// at its card and at its start alike; once the first has ended the folder is +// free again. +func TestASecondProgramRunOnTheSameFolderIsRefusedWhileTheFirstRuns(t *testing.T) { + repo := newTestRepo(t) + first, double := programAgent(t, repo) + id, title, _, err := first.StartDelegate(context.Background(), "fake", "the first piece of work") + if err != nil { + t.Fatal(err) + } + <-double.entered + second, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + want := canonicalPath(repo) + " is busy: fake, " + taskStopName(id, title) + ", is working in it, and one folder takes one program run at a time; ask again when that run has ended" + if _, _, _, err := second.StartDelegate(context.Background(), "fake", "a second piece"); err == nil || err.Error() != want { + t.Fatalf("the second run's start = %v, want %q", err, want) + } + if stand := second.resolveTaskGround(taskSpec{via: "fake", ground: repo, brief: "b", deliverable: "d", acceptance: "a"}); stand.refusal != want { + t.Fatalf("the second run's proposal = %q, want %q", stand.refusal, want) + } + endBeltRun(t, first, double) + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the folder is still held by %q after its run ended", holder) + } +} + +// deadProgramFolder readies repo for a program's run the way a run that went +// away leaves it: its branch cut, a file of its work left uncommitted, and its +// hold dropped by a process that is gone, with its record folder keep. +func deadProgramFolder(t *testing.T, repo, keep string) *ProgramFolder { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The dead run", Holder: "task 9 (The dead run)", Keep: keep}) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(repo, "half.txt"), "half done\n") + folder.release() + return folder +} + +// A RUN FOUND INTERRUPTED AT A REOPEN IS FINISHED THE WAY ONE THAT ENDED IS: +// what it left uncommitted committed on its branch, the branch left checked +// out, and its row and page saying where the work is. +func TestAProgramRunFoundInterruptedAtAReopenIsFinishedInItsFolder(t *testing.T) { + repo := newTestRepo(t) + var dead *ProgramFolder + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, _ time.Time) { + dead = deadProgramFolder(t, repo, taskDir) + }) + row := reopenedRow(t, agent, id) + if head := currentBranch(repo); head != dead.Branch { + t.Fatalf("the checkout is on %q after the reopen, want the dead run's branch %q", head, dead.Branch) + } + if files := gitOut(t, repo, "ls-tree", "--name-only", dead.Branch); !strings.Contains(files, "half.txt") { + t.Fatalf("what the dead run left was not committed on its branch:\n%s", files) + } + if subject := strings.TrimSpace(gitOut(t, repo, "log", "-1", "--format=%s", dead.Branch)); subject != "The dead run" { + t.Fatalf("the commit is %q, want the run's title", subject) + } + if row.Branch != dead.Branch || !strings.Contains(row.Report, "its work is on the branch "+dead.Branch) || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { + t.Fatalf("the reopened row = %+v, want its ending and where its work is", row) + } + if again, ok := readProgramFolder(canonicalPath(repo)); !ok || again.Ended == "" { + t.Fatalf("the folder's record still says it is owed: %+v", again) + } +} + +// A RUN THAT WENT AWAY IN A FOLDER IS FINISHED BEFORE THE NEXT ONE STARTS +// THERE, so what it left is neither refused as the person's changes nor handed +// to the next run as its own. +func TestTheNextRunInAFolderFinishesTheOneThatWentAway(t *testing.T) { + repo := newTestRepo(t) + keep := t.TempDir() + dead := deadProgramFolder(t, repo, keep) + next, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + if err != nil { + t.Fatalf("the next run was refused over the dead one's leftovers: %v", err) + } + defer next.Finish("") + if files := gitOut(t, repo, "ls-tree", "--name-only", dead.Branch); !strings.Contains(files, "half.txt") { + t.Fatalf("the dead run's leftovers were not committed on its branch:\n%s", files) + } + if next.Home != dead.Branch { + t.Fatalf("the next run was cut from %q, want the dead run's branch %q, which was left checked out", next.Home, dead.Branch) + } +} diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index dc4144986..1b76bbe50 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -34,7 +34,10 @@ package session // what they had made is committed on the run's own branch and // the copy is given back ([keptWork], the road every stopped // task takes). NOTHING GOES INTO THE PERSON'S FOLDER: work -// that was stopped half-way is work nobody checked. +// that was stopped half-way is work nobody checked. A +// program's run has no copy: its folder is finished the way +// every ending of it finishes it ([ProgramFolder.Finish]), on +// its own branch, which the person's branch never becomes. // // IT IS IDEMPOTENT, like every other stop here (cancel.go): a second press on a // run that is stopping says so, and a press on a run that is over says that. @@ -90,7 +93,11 @@ func (a *Agent) stopBeltRow(id uint64, why string) (string, bool, error) { if cut != nil { cut() } - return "stopping " + stopBecause(name, why) + " — its branch is kept", true, nil + promise := "its branch is kept" + if run.folder != nil { + promise = run.folder.StopPromise() + } + return "stopping " + stopBecause(name, why) + " — " + promise, true, nil } joined := false for _, row := range run.joined { @@ -222,20 +229,32 @@ func (a *Agent) beltRunRootRow(id string) (uint64, bool) { // and the conversation are told once where that work is, and the rows settle as // stopped by a person. NOTHING IS LANDED AND NO TURN IS BOUGHT: the person // ended the spend, and a model call to narrate the ending would be more of it. +// +// A PROGRAM'S STOPPED WORK GOES WHERE AN ENDED ONE'S DOES: its folder finished +// the one way every ending of it is ([ProgramFolder.Finish]), with the stop's +// words as the body of the commit that holds what it left. func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { - merge, changed, moved := a.keepStoppedWork(run) report := stopBecause(taskStoppedWord, why) - if merge != mergeInPlace { - report += " · " + beltStoppedWhere(run.tree.branch, run.ground, changed) - } - if moved != "" { - report += " · " + moved - } - // A PROGRAM STOPPED IN A PLAIN FOLDER leaves the person's folder its work - // and nothing of its own, exactly as one that ended does. - if run.plain { - if kept := a.keepPlainFolderNotes(run); kept != "" { - report += " · " + kept + var merge, branch string + var changed []string + if run.folder != nil { + end := run.folder.Finish(report) + report += " · " + end.Sentence() + merge, changed = mergeInPlace, end.Changed + if end.Kept { + merge, branch = mergeKept, run.folder.Branch + } + } else { + merge, changed = keptWork(run.tree, run.title, nil, a.signsGitWork()) + if merge != mergeInPlace { + report += " · " + beltStoppedWhere(run.tree.branch, run.ground, changed) + } + // THE ROW NAMES A BRANCH ONLY WHEN THERE IS WORK ON IT, for the reason + // the sentence does ([beltStoppedWhere]): measured on the real binary, a + // run stopped in its first seconds drew `branch kept` beside "it had + // changed nothing". + if merge != mergeInPlace && len(changed) > 0 { + branch = run.tree.branch } } if _, err := run.store.AddNote(run.root, run.root, report); err != nil { @@ -254,14 +273,7 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { // ([Agent.beltRunEndedAt]). notice := TaskNotice{ ID: run.row, Title: run.title, State: TaskFailed, Stopped: true, - Report: report, Changed: changed, Merge: merge, EndedAt: a.beltRunEndedAt(run), - } - // THE ROW NAMES A BRANCH ONLY WHEN THERE IS WORK ON IT, for the reason the - // sentence does ([beltStoppedWhere]): measured on the real binary, a run - // stopped in its first seconds drew `branch kept` beside "it had changed - // nothing". - if merge != mergeInPlace && len(changed) > 0 { - notice.Branch = run.tree.branch + Report: report, Changed: changed, Merge: merge, Branch: branch, EndedAt: a.beltRunEndedAt(run), } g := a.graph() if g == nil { @@ -280,54 +292,3 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { // the stop ending ([Agent.settleJoinedRows], [TaskReasonOf]). a.settleJoinedRows(g, run, notice.EndedAt, TaskEndingStopped, cut) } - -// keepStoppedWork commits what a stopped run had made on the run's own branch -// ([keptWork]) and answers the merge, the files, and what a tree program had -// done with its copy's HEAD when it had moved it ([headMove.sentence]). -// -// A STOPPED PROGRAM'S WORK GOES WHERE A LANDED ONE'S DOES. The stop committed on -// whatever branch HEAD was on, so a program that had checked out one of the -// person's own branches had codeaf's commit put on it, while the report sent -// the person to the task's branch, which held nothing. The copy is put back on -// the task's branch first, with the landing's own guard over the program's -// commits there ([Agent.homeDelegateCopy]). -// -// AND ITS FILES ARE COUNTED FROM THE COPY'S START. senior-dev commits every -// write, so at a stop nothing is left uncommitted, and a count of what the stop -// itself committed told a run that had written a dozen files as one that "had -// changed nothing", naming no branch. A stop that changed nothing leaves no -// branch, as a landing that changed nothing does ([dropEmptyTaskBranch]). -func (a *Agent) keepStoppedWork(run *beltRun) (string, []string, string) { - sign := a.signsGitWork() - if run.delegate == nil || !run.delegate.LandsTree() || run.plain || run.tree.dir == "" { - merge, changed := keptWork(run.tree, run.title, nil, sign) - return merge, changed, "" - } - move := a.homeDelegateCopy(run) - committed := changedSince(run.workspace, run.startSha) - merge, changed := keptWork(run.tree, run.title, nil, sign) - changed = alsoChanged(changed, committed) - if len(changed) == 0 { - dropEmptyTaskBranch(run.tree, run.startSha, move.tip) - } - return merge, changed, move.sentence(run.delegate.Name, run.tree.branch, len(changed) > 0) -} - -// changedSince is every path HEAD's tree differs from a commit in, empty when -// either cannot be read: the work a copy's branch already holds past its start. -func changedSince(dir, sha string) []string { - if strings.TrimSpace(sha) == "" { - return nil - } - out, err := git(dir, "diff", "--name-only", sha, "HEAD") - if err != nil { - return nil - } - var paths []string - for _, line := range strings.Split(out, "\n") { - if line = strings.TrimSpace(line); line != "" { - paths = append(paths, line) - } - } - return paths -} diff --git a/internal/session/task.go b/internal/session/task.go index e7d37764f..c97875d92 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -893,7 +893,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec joined := a.beltRunStandsOn(p.stand) stand := p.stand if via != nil { - stand = delegateStand(stand.dir, *via) + stand = delegateStand(stand.dir) } asked := programAsked(spec) err := a.startKnownTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) @@ -904,7 +904,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) switch { case via != nil: - receipt = delegateStartedReceipt(p.id, spec.title, strings.Join(asked, ", "), delegateReceipt(canonicalPath(stand.dir), *via), elsewhere) + receipt = delegateStartedReceipt(p.id, spec.title, strings.Join(asked, ", "), delegateReceipt(canonicalPath(stand.dir), *via, a.runRowCopy(p.id)), elsewhere) case joined: receipt = withReport(receipt, "It joined the work already underway and shares its copy.") } @@ -1528,9 +1528,10 @@ func (a *Agent) taskClockTimer(after time.Duration) (<-chan time.Time, func()) { } // taskCardWhere is the card's `where`. A PROGRAM'S CARD NAMES ITS PROJECT: the -// folder it will work in, or a copy of it. The copy's own path under codeaf's -// state does not exist yet and is nobody's folder, and a card that showed it -// asked a person to approve work going somewhere they had never heard of. +// folder it will work in itself, and that it gets a branch of its own there +// when the folder is a repository ([programPlace]). A card that showed a path +// under codeaf's state asked a person to approve work going somewhere they had +// never heard of. func taskCardWhere(config Config, id uint64, spec taskSpec) string { for _, program := range config.Delegates { if program.Name == spec.via && strings.TrimSpace(spec.ground) != "" { diff --git a/internal/session/task_run.go b/internal/session/task_run.go index d8b4218ef..faf93deba 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -8175,11 +8175,6 @@ type taskTree struct { // ([stageTaskWork]). Every other worker's ledger is complete by // construction, and its landing reads the ledger alone, exactly as before. bashBelt bool - // keepsBranch is a copy whose work lands AS ITS BRANCH and is never merged: - // the branch is put where the person's repository can reach it and the - // copy is given back, and bringing it in is the person's call. A program's - // run is landed this way (delegate_door.go's [delegateKeepsBranch]). - keepsBranch bool } // gitRoot is the in-process half of the root repository's lock, and the file @@ -8331,7 +8326,7 @@ func cutTaskWorktree(ctx context.Context, place Place, root, session string, id root: root, dir: dir, mode: mode, - branch: "task/" + slugify(title) + "-" + shortID(), + branch: taskBranchName(title), title: title, promise: TaskModeWorktree, frozen: frozen, @@ -8780,12 +8775,6 @@ func (t taskTree) comeHome(title string, wrote []string, sign bool) (string, str // gives the person a durable result and gives the working copy back without // changing a byte of the checkout they are using. func (t taskTree) keptInsteadOfMerged() string { - // A COPY THAT LANDS AS ITS BRANCH is kept whatever the checkout looks like: - // the branch in the person's repository is the whole landing it was - // promised, and nothing of theirs is merged into. - if t.keepsBranch { - return branchOnlySentence(t.branch, t.root) - } // A TASK NEVER WRITES A PROTECTED, MOVED OR DETACHED CHECKOUT. if t.landsInThePersonsRepository() { return t.keptLandingSentence() diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 45a04f238..98a043d5c 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -75,8 +75,9 @@ type RunSpec struct { // for the life of the run; the engine reads and writes it like any other // writer of the store. Store *plandb.Store - // Workspace is the run's own working copy, the directory every worker - // types in and the landing commits. + // Workspace is the directory every worker types in and the landing + // commits: the run's own working copy, or the folder itself for a program + // that edits files (programfolder.go). Workspace string // Title and Brief are the run's own words: the title names the root row, // and the brief is the assignment the root worker reads. @@ -139,16 +140,10 @@ type RunSpec struct { // program reaches a model only through the API codeaf serves the run. Nil is // every run the conversation's own workers drive. Delegate *delegate.Delegate - // PlainFolder says the delegated run's folder has no git history, so the - // program is started with its own flags for one + // PlainFolder says the delegated run's program works in its folder without + // git ([ProgramFolder.Plain]), so it is started with its own flags for that // (delegate.Delegate.PlainFolder). False for every other run. PlainFolder bool - // Ground is every spelling of the folder a delegated run's task was - // proposed on, and of the repository around it, each paired with where it - // stands in the copy, when the program works in a copy: the brief it is - // handed names the copy wherever it named either (delegate.RehomeBrief). - // Empty for every other run. - Ground []delegate.Rehome // Crew is the conversation's crew as a delegated run's program is handed it // ([conversationCrew]), so the program works on the models the person // chose. Zero for every other run. @@ -216,22 +211,12 @@ type RunLanding struct { // brought back to its ground ([Agent.landBeltRun]). Empty is an engine's own // landing, which commits on the copy's branch and merges nothing. Home string - // Root is the repository a branch-only landing left its branch in - // ([delegateKeepsBranch]), set only for that landing: the folder the merge - // that brings the work in runs in ([beltLandingLine]). - Root string - // Unrelated says the branch's work was not built on everything that branch - // held before it ([headMove.warns]), so a merge of it may also undo changes; - // the line the conversation is handed says so ([beltLandingLine]). - Unrelated bool + // Line is a landing that says itself: a program's run, whose folder's + // ending ([ProgramFolderEnd.Sentence]) is the whole account of where its + // work is ([beltLandingLine]). Empty for every other run. + Line string } -// landingUnrelatedWarning is what the conversation's landing line adds for work -// that was not built on everything its branch held. THE PAGE SAID IT AND THE -// CHAT DID NOT: the line the chat's model reads offered the merge command -// alone, and a model asked to merge had nothing telling it to look first. -const landingUnrelatedWarning = "its work was not built on everything that branch held, so the merge may also undo changes; read its diff before you merge it" - // RunEngine is the run engine as this door reaches it. Start drives one store // to an outcome and answers what came of it; Land commits the run's working // copy onto its branch and answers where the work went. @@ -277,7 +262,8 @@ type beltRun struct { // workspace is the run's own copy, the directory every worker types in, and // ground is the folder that copy was cut from and comes home to. tree is the // copy as the ground ladder made it, kept so the run's landing is the ladder's - // own ([Agent.landBeltRun]). + // own ([Agent.landBeltRun]). A program that edits files has no copy: all + // three name the folder it works in ([ProgramFolder.tree]). workspace string ground string tree taskTree @@ -311,31 +297,12 @@ type beltRun struct { ended time.Time spent float64 // delegate is the program this run's root is handed to, nil for a run the - // conversation's own workers drive; startSha is the commit the copy stood on - // the moment the run began, the point a tree program's commits are squashed - // back to at landing (delegate_door.go). + // conversation's own workers drive; folder is the folder a program that + // edits files works in, held for the run and finished when it ends + // ([PrepareProgramFolder]), nil for every other run. It is set once, before + // the run starts, and never written again. delegate *delegate.Delegate - startSha string - // taskTip is the task's branch as a tree program left it, read by its - // landing before anything moved it ([Agent.homeDelegateCopy]). It is what - // says whether that branch ever held the program's work, which the tip - // after the landing's own squash cannot ([dropEmptyTaskBranch]). It is - // written and read on the run's own goroutine alone. - taskTip string - // plain is a tree program working in a folder with no git history - // ([delegateOnPlainFolder]): it is told so on its line, and its landing - // commits nothing, because the work is already where it belongs. - plain bool - // notesWereThere says the program's notes folder ([delegate.Delegate.Notes]) - // was already in a plain folder when the run began — left by a run started - // at a shell, say — so its landing leaves it where it is rather than take - // records that are not this run's alone. - notesWereThere bool - // groundMoves is every spelling of the folder a tree program's task was - // proposed on and of the repository around it, each paired with where it - // stands in the copy, when the program works in a copy of it - // ([delegateGroundMoves]); empty otherwise. - groundMoves []delegate.Rehome + folder *ProgramFolder } // startTaskRun is StartTask's second road, taken whenever the bash belt is asked @@ -386,8 +353,11 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s // startKnownTaskRunVia is [Agent.startKnownTaskRun] with the worker named: nil // is the conversation's own bash worker, and a program is the one the root task // is handed to (delegate_door.go). One body serves both because a -// delegated run IS a run — the store, the copy, the row and the stop road are -// the same — and a second body would be two roads that must stay in step. +// delegated run IS a run — the store, the row and the stop road are the same — +// and a second body would be two roads that must stay in step. What differs is +// where it works: a program that edits files works in the folder itself +// (programfolder.go), where every other run gets the copy its ground ladder +// cuts. // // asked is the models the person asked the program to work with, resolved; // none means the conversation's crew ([Agent.delegateCrew]). @@ -411,26 +381,49 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie return a.joinBeltRun(g, live, id, title, brief, dependencies, stand, via) } + // A PROGRAM THAT EDITS FILES WORKS IN THE FOLDER ITSELF (programfolder.go), + // and the folder is readied before anything else: a folder that refuses — + // changes that are not committed, another program's run in it — refuses + // before a store is seeded or a row is published. + var folder *ProgramFolder + if via != nil && via.LandsTree() { + prepared, err := PrepareProgramFolder(ProgramFolderOrder{ + Program: *via, Dir: stand.dir, Title: title, Holder: taskStopName(id, title), + Keep: plandb.TaskDir(filepath.Dir(path), storeID), Instead: "say which folder the work is in, as ground", + Place: a.config.Place, Sign: a.signsGitWork(), + }) + if err != nil { + return err + } + folder = prepared + } plan, store, err := a.seedBeltRunStore(g, path, storeID, title, brief) if err != nil { + folder.abandon() return err } if question = strings.TrimSpace(question); question != "" { if _, err := store.Revise(store.RootID(), plandb.TaskPatch{Question: &question}); err != nil { _ = store.Close() + folder.abandon() return err } } - tree, err := prepareTaskTreeOn(ctx, a.config.Place, a.config.Workspace, a.journalID(), id, title, stand) - if err != nil { - _ = store.Close() - return err + tree, ground := folder.tree(), canonicalPath(stand.dir) + if folder != nil { + // A PROGRAM'S GROUND IS THE FOLDER IT WORKS IN, which is the + // repository's root when it was handed a folder inside one. + ground = canonicalPath(folder.Dir) + } else { + tree, err = prepareTaskTreeOn(ctx, a.config.Place, a.config.Workspace, a.journalID(), id, title, stand) + if err != nil { + _ = store.Close() + return err + } } // THE COPY IS A SHELL WORKER'S, so its landing stages the tree's own status: // a run's workers edit through bash and fill no write ledger. tree.bashBelt = true - // AND A PROGRAM'S WORK LANDS AS ITS BRANCH ([delegateKeepsBranch]). - tree.keepsBranch = delegateKeepsBranch(via, delegateOnPlainFolder(tree, via)) // THE RUN'S CONTEXT IS ONE A PERSON'S STOP CAN CUT. It outlives the turn that // started it, which is the caller's business (task.go hands this door a // context no turn's ending cancels); what it must not outlive is the person @@ -439,16 +432,8 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie born := a.taskClockNow() run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, - workspace: tree.dir, ground: canonicalPath(stand.dir), tree: tree, cut: cut, - born: born, delegate: via, startSha: delegateStartSha(tree, via), - plain: delegateOnPlainFolder(tree, via), asked: asked, - } - if via != nil && via.LandsTree() && !run.plain { - run.groundMoves = delegateGroundMoves(stand.dir, tree.root, tree.dir) - } - if run.plain && via.Notes != "" { - _, err := os.Lstat(filepath.Join(tree.dir, via.Notes)) - run.notesWereThere = err == nil + workspace: tree.dir, ground: ground, tree: tree, cut: cut, + born: born, delegate: via, folder: folder, asked: asked, } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because @@ -475,13 +460,17 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie // Nothing opens a second store. // // A DELEGATE NEVER JOINS A RUN AND NOTHING JOINS A DELEGATE'S. A delegated run -// is a run of one task whose worker owns the whole copy for the hour; a second -// task beside it would be a bash worker typing in the tree the program is -// editing, and a delegate added under a live run would be a second program in -// the same tree. Both are refused with what is underway. +// is a run of one task whose worker owns its whole folder for the hour; a +// second task beside it would be a bash worker typing in the tree the program +// is editing, and a delegate added under a live run would be a second program +// beside the first. Both are refused with what is underway, and where. func (a *Agent) joinBeltRun(g *TaskGraph, live *beltRun, id uint64, title, brief string, dependencies []plandb.Dependency, stand taskStand, via *delegate.Delegate) error { if via != nil || live.delegate != nil { - return errors.New("work is already underway in a copy of " + live.ground + + where := "in a copy of " + live.ground + if live.folder != nil { + where = "in " + live.ground + } + return errors.New("work is already underway " + where + "; " + aloneName(via, live.delegate) + " runs alone, so propose it again when that work has ended") } if canonicalPath(stand.dir) != live.ground { @@ -521,40 +510,6 @@ func programName(via *delegate.Delegate) string { return strings.TrimSpace(via.Name) } -// delegateStartSha is the commit a tree delegate's copy stands on before the -// program has written a byte — the point its commits are squashed back to at -// landing (delegate_door.go). It is read NOW, off the copy itself: whatever the -// ground ladder put under this copy is under this commit, and everything the -// program commits is above it. Empty for every run that is not a tree delegate's. -func delegateStartSha(tree taskTree, via *delegate.Delegate) string { - if via == nil || !via.LandsTree() { - return "" - } - head, err := git(tree.dir, "rev-parse", "HEAD") - if err != nil { - return "" - } - return strings.TrimSpace(head) -} - -// delegateOnPlainFolder says a tree program is about to work in a folder with no -// git history to cut a copy from: a plain folder, or a repository with no -// commit yet. The copy road already answered that by working in the folder -// itself ([prepareTaskTreeOn]); this is the same fact read off the tree it -// answered with, for the program's line and its landing. -// -// IT WAS A RUN THAT DIED ON ITS FIRST LINE. senior-dev keeps its history in -// git unless it is told otherwise, and handed a plain folder it ended at once -// with "workspace is not a git repository", though it has a way of working -// without one. codeaf is the one that read the folder, so codeaf says so. -func delegateOnPlainFolder(tree taskTree, via *delegate.Delegate) bool { - if via == nil || !via.LandsTree() || tree.merge != mergeInPlace || tree.dir == "" { - return false - } - root, ok := repositoryRoot(tree.dir) - return !ok || !hasCommit(root) -} - // beltRunSpec is what the engine is handed for a run of this conversation: its // seats, its bounds and the copy it works in. // @@ -597,8 +552,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { Serves: a.servesModel, Conversation: a.runConversation(), Delegate: run.delegate, - PlainFolder: run.plain, - Ground: run.groundMoves, + PlainFolder: run.folder != nil && run.folder.Plain(), Crew: a.delegateCrew(run), } } @@ -628,75 +582,6 @@ func (a *Agent) delegateCrew(run *beltRun) delegate.Crew { } } -// delegateGroundMoves is every way a brief is likely to spell the folder a -// tree program's task was proposed on (as the proposal named it, absolute, -// with its links resolved, and under ~) and the repository it is in, each -// paired with where it stands in the program's copy. It is empty when the -// program works in that folder itself, where there is nothing to rewrite. -// -// THE COPY IS CUT AT THE REPOSITORY'S ROOT, NOT AT THE FOLDER. A task proposed -// on a subfolder of a repository (a conversation opened in one package of a -// monorepo) gets a copy of the whole repository, so the subfolder is the same -// subfolder inside the copy, and the repository's own root is the copy's root. -// Both used to be wrong: the subfolder was mapped to the copy's root, which sent -// every path under it to a file that does not exist, and the repository's -// spelling was left as it was, so `git -C <the person's checkout>` reached the -// program intact — the exact failure the rewrite exists to stop. -func delegateGroundMoves(proposed, root, copyDir string) []delegate.Rehome { - proposed = strings.TrimSpace(proposed) - if proposed == "" || canonicalPath(proposed) == canonicalPath(copyDir) { - return nil - } - ground := canonicalPath(proposed) - target, rel := copyDir, "" - if root = strings.TrimSpace(root); root != "" { - if within, err := filepath.Rel(canonicalPath(root), ground); err == nil && within != "." && - within != ".." && !strings.HasPrefix(within, "../") { - target, rel = filepath.Join(copyDir, within), within - } - } - groundSpellings := pathSpellings(proposed) - moves := make([]delegate.Rehome, 0, 2*len(groundSpellings)) - for _, spelling := range groundSpellings { - moves = append(moves, delegate.Rehome{From: spelling, To: target}) - } - if rel == "" { - return moves - } - // THE REPOSITORY IN EVERY SPELLING THE BRIEF COULD USE: its own, and the - // folder's spellings with the subfolder taken off, so `~/Code/app` is found - // wherever `~/Code/app/packages/foo` was how the task was named. - rootSpellings := pathSpellings(root) - for _, spelling := range groundSpellings { - if trimmed, ok := strings.CutSuffix(strings.TrimRight(spelling, "/"), "/"+filepath.ToSlash(rel)); ok && trimmed != "" { - rootSpellings = append(rootSpellings, trimmed) - } - } - for _, spelling := range rootSpellings { - moves = append(moves, delegate.Rehome{From: spelling, To: copyDir}) - } - return moves -} - -// pathSpellings is every way a brief is likely to spell one folder: as given, -// absolute, with its links resolved, and under ~. -func pathSpellings(path string) []string { - names := []string{path, canonicalPath(path)} - home, _ := os.UserHomeDir() - home = strings.TrimRight(home, "/") - if abs, err := filepath.Abs(path); err == nil && !strings.HasPrefix(path, "~") { - names = append(names, abs) - } - if home != "" { - for _, name := range append([]string(nil), names...) { - if rest, ok := strings.CutPrefix(name, home+"/"); ok { - names = append(names, "~/"+rest) - } - } - } - return names -} - // seedBeltRunStore opens a fresh store for a NEW hand-off, under the hand-off's // own number. A store already at the path is archived beside the session folder // the way a finished one always was, and never adopted, whatever its root says. @@ -730,7 +615,7 @@ func (a *Agent) seedBeltRunStore(g *TaskGraph, path, rootID, title, brief string if err != nil { return nil, nil, err } - endOrphanedProgramRun(old) + _, _ = endOrphanedProgramRun(old) _ = old.Close() archived := fmt.Sprintf("%s.%d", path, len(planArchivePaths(path))+1) if err := os.Rename(path, archived); err != nil { @@ -750,12 +635,12 @@ func programClosedSentence(name string) string { // programEndedSentence is the ending written on a program's run that codeaf // closed under AFTER the program had exited: its worker was still settling -// owed receipts, or the run was about to land. The program was not running, -// so the sentence does not say it was, and the work it left was never brought -// in — it is where the program left it (for a program that works in its own -// copy, in that copy on the task's branch, not squashed). +// owed receipts, or the run was about to end. The program was not running, so +// the sentence does not say it was; what was not done is the run's ending in +// its folder, which the next codeaf to find the run does +// ([settleOwedProgramFolder]) and says under this line. func programEndedSentence(name string) string { - return name + " had ended; codeaf closed before its work was brought in" + return name + " had ended; codeaf closed before it could say where its work is" } // runLimitSentence is the run engine's outcome word for a run a limit its @@ -766,25 +651,35 @@ func programEndedSentence(name string) string { const runLimitSentence = "a limit you set stopped it" // endOrphanedProgramRun ends a program's run whose store was left open by a -// process that went away, at the run's last evidence of life. It does nothing -// to a store whose run has ended, or whose run no program worked (the task's -// record folder holds no program record, [delegate.ProgramFile]). +// process that went away, at the run's last evidence of life, and finishes the +// folder it worked in when that process went away before it could +// ([settleOwedProgramFolder]), answering how it left the folder. It ends +// nothing in a store whose run has ended, or whose run no program worked (the +// task's record folder holds no program record, [delegate.ProgramFile]). // // THE ENDING IS WRITTEN WHEN THE RUN WAS LAST SEEN, NOT NOW. The process that // finds the store can be hours later than the one that lost it, and the page // counts a run's time to its ending ([plandb.Store.FailRootAt] says why). -func endOrphanedProgramRun(store *plandb.Store) { +// +// THE FOLDER IS FINISHED WHATEVER THE STORE SAYS. A run a person stopped, or +// one codeaf closed under, has its store's ending written before its folder is +// finished, so a process that went away in between leaves an ended store over +// a folder still on the program's branch with its last changes uncommitted. +func endOrphanedProgramRun(store *plandb.Store) (ProgramFolderEnd, bool) { rootID := store.RootID() root := store.Task(rootID) - if root == nil || terminalStoreStatus(root.Status) { - return + if root == nil { + return ProgramFolderEnd{}, false } taskDir := plandb.TaskDir(filepath.Dir(store.Path()), rootID) - record, ok := delegate.ReadProgram(taskDir) - if !ok { - return + if record, ok := delegate.ReadProgram(taskDir); ok && !terminalStoreStatus(root.Status) { + endProgramRunClosed(store, record, lastEvidenceOfLife(store, root, taskDir, record)) + } + end, settled := settleOwedProgramFolder(taskDir) + if settled { + _, _ = store.AddNote(rootID, rootID, end.Sentence()) } - endProgramRunClosed(store, record, lastEvidenceOfLife(store, root, taskDir, record)) + return end, settled } // endProgramRunClosed writes a program's run's ending when codeaf closed under @@ -878,8 +773,8 @@ func (a *Agent) endInterruptedProgramRun() { if !found || kept.State != TaskInterrupted { return } - endOrphanedProgramRun(store) - a.settleInterruptedProgramRow(g, store, kept) + end, settled := endOrphanedProgramRun(store) + a.settleInterruptedProgramRow(g, store, kept, end, settled) } // settleInterruptedProgramRow settles the row a reopen restored as interrupted @@ -897,7 +792,11 @@ func (a *Agent) endInterruptedProgramRun() { // at the store's ending otherwise ([runClockEnd]) — the pair every live settle // reads ([Agent.beltRunEndedAt]). The store's ending can come after the exit // by the whole wait for owed receipts, and that wait is not the run's time. -func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, kept TaskNotice) { +// +// AND IT SAYS WHERE THE WORK IS when this reopen finished the run's folder +// (settled): the folder's sentence under the ending, and the program's branch +// when it holds the work, as the live ending would have said them. +func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, kept TaskNotice, end ProgramFolderEnd, settled bool) { root := store.Task(store.RootID()) if root == nil || (root.Status != plandb.StatusFailed && root.Status != plandb.StatusCancelled) { return @@ -906,12 +805,19 @@ func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, k if !ok { return } - settled := kept - settled.State = TaskFailed - settled.Report, settled.Ending, settled.Stopped = interruptedProgramEnding(store, root, record) - settled.EndedAt = runClockEnd(kept.StartedAt, record, root.CompletedAt) - settled.Elapsed = 0 - a.publishRunRow(g, settled) + row := kept + row.State = TaskFailed + row.Report, row.Ending, row.Stopped = interruptedProgramEnding(store, root, record) + row.EndedAt = runClockEnd(kept.StartedAt, record, root.CompletedAt) + row.Elapsed = 0 + if settled { + row.Report = strings.TrimSpace(row.Report + "\n" + end.Sentence()) + row.Changed = end.Changed + if end.Kept { + row.Branch, row.Merge = end.Folder.Branch, mergeKept + } + } + a.publishRunRow(g, row) } // interruptedProgramEnding is how a program's run that a reopen settles ended, @@ -1165,8 +1071,8 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // page would read `running` and offer `stop it` for ever. A run that // already ended is left as it ended. // - // IT IS CLOSED BEFORE THE LANDING, NOT AFTER IT. The squash and the - // commit take their time, and a page that went on reading `running` over + // IT IS CLOSED BEFORE THE LANDING, NOT AFTER IT. The folder's last + // commit takes its time, and a page that went on reading `running` over // a program that had already exited was a page claiming a present that // was over — for the two limit endings alone, because every other ending // is written by the engine at the program's exit. @@ -1231,18 +1137,15 @@ func (a *Agent) landBeltRun(ctx context.Context, engine RunEngine, run *beltRun) return a.bringBeltRunHome(run, landing) } -// bringBeltRunHome is the second half of a run's landing, shared by the engine's -// landing and a delegate's: the copy's branch merged into the ground it was cut -// from, the person's unfinished work carried across or the branch kept and the -// files named, the copy given back, and the homecoming written on the run's page. +// bringBeltRunHome is the second half of a run's landing: the copy's branch +// merged into the ground it was cut from, the person's unfinished work carried +// across or the branch kept and the files named, the copy given back, and the +// homecoming written on the run's page. func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { if run.tree.dir == "" { return landing } merge, said, _, _ := run.tree.comeHome(run.title, nil, a.signsGitWork()) - if merge == mergeKept && run.tree.keepsBranch { - return a.branchOnlyLanding(run, landing, said) - } if landing.Refused != "" { // NOTHING TO LAND IS STILL AN ENDING: the copy was given back above, and // the sentence the engine answered is the whole account. @@ -1272,94 +1175,6 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { return landing } -// branchOnlyLanding is the landing of a copy whose work lands AS ITS BRANCH -// ([delegateKeepsBranch]), once the copy has come home and been given back: -// the branch named, with the repository it is in, and the homecoming written -// on the run's page; or, for a branch holding nothing, the branch deleted. -func (a *Agent) branchOnlyLanding(run *beltRun, landing RunLanding, said string) RunLanding { - if dropEmptyTaskBranch(run.tree, run.startSha, run.taskTip) { - // AN EMPTY BRANCH IS NOT A LANDING. The branch was kept for the person - // to merge, and there is nothing on it to merge: every look-only, - // failed or crashed program run left one more `task/*` branch at the - // commit it started from in the person's repository. It is deleted, and - // the run says what it always said about a copy that holds no change. - if landing.Refused == "" { - landing.Refused = runNothingToLand - } - return landing - } - if landing.Refused != "" { - // NOTHING TO LAND IS STILL AN ENDING, as on every other road. - return landing - } - // A BRANCH-ONLY LANDING IS A LANDING, not a refusal: the work is on its - // branch in the person's repository, which is where it was promised. - landing.Home, landing.Root = mergeKept, run.tree.root - if run.tree.branch != "" { - landing.Branch = run.tree.branch - } - if _, err := run.store.AddNote(run.root, run.root, said); err != nil { - if g := a.graph(); g != nil { - g.planNote("the run's homecoming note failed: " + err.Error()) - } - } - return landing -} - -// dropEmptyTaskBranch deletes a kept task branch that holds nothing past the -// commit its copy started from, and reports whether it did; the repository's -// lock is taken the way every landing's branch work takes it. -// -// THE BRANCH AS THE PROGRAM LEFT IT DECIDES, NOT THE BRANCH AFTER THE LANDING. -// before is the task's branch read before codeaf moved it ([beltRun.taskTip]), -// and only a branch that stood at the copy's start then can go: the landing's -// own squash resets the branch to that start, and a test on the tip after it -// once deleted a branch whose only reference to the program's commits was -// that branch. -// -// AND EMPTY IS MEASURED FROM THE GROUND'S OWN COMMIT ([taskGroundCommit]). A -// copy cut from a checkout with uncommitted edits starts from the commit that -// holds them, which the landing takes back out ([taskTree.replayOwnWork]), so -// an empty branch ends at the person's own commit, not at the start: it is -// empty when it holds no commit past that commit and no change from it. -func dropEmptyTaskBranch(tree taskTree, startSha, before string) bool { - if strings.TrimSpace(tree.root) == "" || strings.TrimSpace(tree.branch) == "" || startSha == "" { - return false - } - if strings.TrimSpace(before) != startSha { - return false - } - defer lockGitRoot(tree.place, tree.root)() - tip, err := git(tree.root, "rev-parse", "--verify", "-q", "refs/heads/"+tree.branch) - if err != nil { - return false - } - tip, from := strings.TrimSpace(tip), taskGroundCommit(tree, startSha) - if ahead, err := git(tree.root, "rev-list", from+".."+tip); err != nil || strings.TrimSpace(ahead) != "" { - return false - } - if _, err := git(tree.root, "diff", "--quiet", from, tip); err != nil { - return false - } - _, err = git(tree.root, "branch", "-D", tree.branch) - return err == nil -} - -// taskGroundCommit is the commit of the person's own a task's copy counts its -// work from: the parent of the commit the ground ladder sealed the person's -// uncommitted edits into, when it made one ([taskTree.replayOwnWork] takes that -// commit back out), and the copy's start otherwise. -func taskGroundCommit(tree taskTree, startSha string) string { - if strings.TrimSpace(tree.base) == "" { - return startSha - } - parent, err := git(tree.root, "rev-parse", "--verify", "-q", tree.base+"^") - if err != nil || strings.TrimSpace(parent) == "" { - return startSha - } - return strings.TrimSpace(parent) -} - // deliverBeltRunLanding writes the run's digest into the conversation record. // A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED. func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { @@ -1610,33 +1425,23 @@ func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, // much of it, or the refusal that says why it did not. It is empty only when // there is nothing to say — a landing with no branch and no refusal. // -// A BRANCH-ONLY LANDING SAYS NOTHING WAS MERGED, WHERE, AND HOW TO BRING IT IN. -// This line is the one account of a landing the conversation's model is given, -// and it read `landed on task/x: 2 files`, the shape of a run whose work is -// already in the person's folder: the model had no way to know that nothing was -// merged, which repository held the branch, or what brings it in, and would tell -// the person their folder held the work. The folder is quoted for a shell the -// way every path this package hands one is ([shellQuoted]). -// -// WORK NOT BUILT ON EVERYTHING ITS BRANCH HELD IS SAID HERE AS ON THE PAGE -// ([landingUnrelatedWarning]), because this line is what the chat's model reads -// before it runs the merge it offers. +// A PROGRAM'S LANDING SAYS ITSELF ([RunLanding.Line]): where its work is, that +// its branch is checked out in the person's folder, and the two commands that +// go back to their own branch and bring the work in. This line is the one +// account of a landing the conversation's model is given, and a model told +// only `landed on task/x: 2 files` would tell the person a thing about their +// folder that nobody checked. func beltLandingLine(landing RunLanding) string { + if landing.Line != "" { + return landing.Line + } if landing.Refused != "" { return landing.Refused } if landing.Branch == "" { return "" } - line := fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) - if landing.Home == mergeKept && landing.Root != "" { - line = fmt.Sprintf("its work is on the branch %s in %s, %s; nothing was merged into your checkout, and `git -C %s merge %s` brings it in", - landing.Branch, landing.Root, fileCount(len(landing.Changed)), shellQuoted(landing.Root), landing.Branch) - } - if landing.Unrelated { - line += "; " + landingUnrelatedWarning - } - return line + return fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) } // fileCount is a count of files in words, `1 file` and `2 files`, so every diff --git a/internal/session/task_run_clock.go b/internal/session/task_run_clock.go index d1111fe6e..0eb70939d 100644 --- a/internal/session/task_run_clock.go +++ b/internal/session/task_run_clock.go @@ -73,7 +73,7 @@ func beltRunProgram(run *beltRun) delegate.ProgramRecord { // road that never drove one — ends now, which is the reading it always had. // // IT IS NEVER THE INSTANT THE ROW SETTLES. The row used to be stamped when it -// was published, which is after the squash, the commit, the homecoming and a +// was published, which is after the landing's commit, the homecoming and a // summary refresh that may wait six seconds for a model: none of that is the // run's work, and all of it was counted as though it were. func (a *Agent) beltRunEndedAt(run *beltRun) time.Time { diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go index 9572b2b6e..678267601 100644 --- a/internal/session/task_run_clock_test.go +++ b/internal/session/task_run_clock_test.go @@ -242,8 +242,8 @@ func TestARunRowKeepsItsEndingBranchAndSpanAcrossAReopen(t *testing.T) { } // A PROGRAM'S RUN A LIMIT ENDED READS ENDED BEFORE ITS WORK LANDS. The engine -// leaves such a store open, and the run's task was ended only after the squash -// and the commit, so the page went on reading `running` over a program that +// leaves such a store open, and the run's task was ended only after its work +// was committed, so the page went on reading `running` over a program that // had exited — for the two limit endings alone. func TestALimitEndedProgramRunIsEndedBeforeItsWorkLands(t *testing.T) { double := newBeltRunDouble("") @@ -277,8 +277,8 @@ func TestALimitEndedProgramRunIsEndedBeforeItsWorkLands(t *testing.T) { if root == nil || root.Status != plandb.StatusFailed || len(notes) == 0 { t.Fatalf("the run's task = %+v with notes %+v, want it failed with the landing noted", root, notes) } - if !strings.HasPrefix(notes[0].Body, "landed on ") { - t.Fatalf("the first note on the run = %q, want the landing's own", notes[0].Body) + if !strings.Contains(notes[0].Body, "its work is on the branch ") { + t.Fatalf("the first note on the run = %q, want the one that says where its work is", notes[0].Body) } if !root.CompletedAt.Before(notes[0].At) { t.Fatalf("the run's task ended at %v and its work landed at %v: it read running while its work landed", root.CompletedAt, notes[0].At) diff --git a/internal/session/task_run_orphan_test.go b/internal/session/task_run_orphan_test.go index 1ccc68cf5..d1f9c8563 100644 --- a/internal/session/task_run_orphan_test.go +++ b/internal/session/task_run_orphan_test.go @@ -186,10 +186,13 @@ func TestClosingUnderAProgramsRunEndsItInItsStoreFirst(t *testing.T) { t.Fatalf("the closed run's page row = %+v (%v), want the plain sentence beside it", page.Row, ok) } + // THE SECOND HAND-OFF IS ON ANOTHER FOLDER: the first program is still in + // its grace in this process, and it holds its own folder until it has + // gone (programfolder.go's one run per folder). second := newBeltRunDouble("") registerBeltRunEngine(t, second) again, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { - config.Workspace = workspace + config.Workspace = newTestRepo(t) config.Place = Place{Dir: place} config.AskConsent = false config.Delegates = testPrograms("fake") diff --git a/internal/session/task_run_settle_test.go b/internal/session/task_run_settle_test.go index 23a8c9a99..c45b129b4 100644 --- a/internal/session/task_run_settle_test.go +++ b/internal/session/task_run_settle_test.go @@ -148,7 +148,7 @@ func TestAReopenedRunEndsAtItsProgramsRecordedExit(t *testing.T) { // A PROGRAM THAT HAD ALREADY EXITED IS NOT SAID TO HAVE BEEN RUNNING. codeaf // closed while the worker was settling the program's receipts, after the // program was gone: the run is ended where the program ended, in a sentence -// that says the program had ended and its work was never brought in. +// that says the program had ended before codeaf could say where its work is. func TestClosingAfterTheProgramExitedSaysItHadEnded(t *testing.T) { place := t.TempDir() double := newBeltRunDouble("") @@ -176,7 +176,7 @@ func TestClosingAfterTheProgramExitedSaysItHadEnded(t *testing.T) { kept := beltRunStoreAt(t, place) root := kept.Task(rootID) _ = kept.Close() - if root.Error != "fake had ended; codeaf closed before its work was brought in" || !root.CompletedAt.Equal(exited) { + if root.Error != "fake had ended; codeaf closed before it could say where its work is" || !root.CompletedAt.Equal(exited) { t.Fatalf("after Close the run's task = %s (%q, ended %v), want it ended at the program's exit %v in a true sentence", root.Status, root.Error, root.CompletedAt, exited) } close(double.release) @@ -194,7 +194,7 @@ func TestAReopenedRunWhoseProgramHadExitedSaysItHadEnded(t *testing.T) { } }) row := reopenedRow(t, agent, id) - if row.Report != "fake had ended; codeaf closed before its work was brought in" || !row.EndedAt.Equal(exited) || row.Ending != TaskEndingProgram { + if row.Report != "fake had ended; codeaf closed before it could say where its work is" || !row.EndedAt.Equal(exited) || row.Ending != TaskEndingProgram { t.Fatalf("the run came back as %+v, want it ended at the program's exit %v in a true sentence", row, exited) } } diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index e6798dc82..de39ba765 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -380,22 +380,49 @@ func saidGround(said, workspace string) taskStand { // the whole home folder; senior-dev, finding no git history there, began to // snapshot all of it and died on the first folder macOS keeps to itself // (`open /Users/…/.Trash: operation not permitted`). So a program's placement -// is its own ([delegateStand]) and `where` is not read for it. +// is its own (programfolder.go) and `where` is not read for it. +// +// THE FOLDER IS READ BEFORE THE CARD, as the run will read it: snapped to its +// repository's root, a folder not there yet taken when it can be made, and +// refused for what would refuse the run — the home folder, a checkout with +// changes that are not committed or a merge half done, another program's run +// already in it — so nobody is asked to approve work that cannot start. func programGround(spec taskSpec, workspace string, program delegate.Delegate) taskStand { - stand := taskStand{dir: workspace, rung: taskGroundHere} + dir, rung := workspace, taskGroundHere if said := strings.TrimSpace(spec.ground); said != "" { - if stand = saidGround(said, workspace); stand.refusal != "" { - return stand + resolved, err := resolveTaskWhere(said, workspace) + if err != nil { + return taskStand{refusal: "this task names a folder it cannot work in: " + said} } + dir, rung = canonicalPath(resolved), taskGroundSaid } - if refusal := programHomeRefusal(program, stand.dir, "say which folder the work is in, as ground"); refusal != "" { + if refusal := programGroundRefusal(program, &dir); refusal != "" { return taskStand{refusal: refusal} } - placed := delegateStand(stand.dir, program) - placed.rung = stand.rung + placed := delegateStand(dir) + placed.rung = rung return placed } +// programGroundRefusal reads the folder a program's proposal names the way +// [PrepareProgramFolder] will, and answers what would refuse it, "" when +// nothing would. It moves dir to the folder the program would work in, and it +// changes nothing on disk. +func programGroundRefusal(program delegate.Delegate, dir *string) string { + folder, repo, _, refusal := programFolderAt(program, *dir, "say which folder the work is in, as ground") + if refusal != "" || !program.LandsTree() { + return refusal + } + *dir = folder + if holder := programFolderHolder(canonicalPath(folder)); holder != "" { + return programFolderBusy(folder, holder) + } + if repo { + return programCheckoutInTheWay(folder, program.Notes) + } + return "" +} + // groundPlainlyNamedByBrief reports the one ground that holds every existing // absolute place the contract writes down: the repository they are all inside, // or, where none is in a repository, the one named folder that holds them all. From 8b6f34a18547d98e001746f8b03fa9b279eb9d8d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:39:31 -0400 Subject: [PATCH 119/195] manual: a program's refusals name both roads, and a moved HEAD reads as one sentence The page said only the program-run form of "work is already underway"; a program proposed while a task of codeaf's own is running is refused with the copy that task works in, and the page now says so. The sentence a moved HEAD is reported with was broken across a line inside its quote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 3 ++- internal/manual/chat/senior-dev.md | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index e1da529de..c038ce286 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -152,7 +152,8 @@ program run at a time; ask again when that run has ended`. **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 <folder>; -<name> runs alone, so propose it again when that work has ended`. +<name> runs alone, so propose it again when that work has ended` (`in a copy of +<folder>` when the work underway is a task of codeaf's own). A name your build does not carry is refused with the ones it does: `this codeaf carries no program called <name>; it carries …`. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 6253a97af..87e0be043 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -265,10 +265,10 @@ names `git -C '<folder>' switch --detach <commit>`. 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 <folder> on the branch -<other> instead of its own branch <branch>, so codeaf changed nothing there: nothing was -committed and nothing was switched; <branch> holds N files` (or `on no branch, at -<commit>`). Look at that branch before you commit anything there. +says where HEAD is: `senior-dev left <folder> on the branch <other> instead of its own +branch <branch>, so codeaf changed nothing there: nothing was committed and nothing was +switched; <branch> holds N files` (or `on no branch, at <commit>`). Look at that branch +before you commit anything there. ## senior-dev refused: changes that are not committed — a dirty checkout, uncommitted changes, a merge in progress From d3189ea2666f362a0d221f8d75caf6d70ba64d27 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:47:14 -0400 Subject: [PATCH 120/195] codeaf, manual: the merged lanes build together, and a landing question still reaches its page The shell road's folder test called a program's Step with the record's old two strings, which the actions lane replaced with one record; and the new senior-dev sections outranked how-tasks-run for "codeaf committed to dev", so that page's landing heading now carries the asker's words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried_folder_test.go | 2 +- internal/manual/chat/how-tasks-run.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/carried_folder_test.go b/cmd/codeaf/carried_folder_test.go index 1e4d48df0..06650379d 100644 --- a/cmd/codeaf/carried_folder_test.go +++ b/cmd/codeaf/carried_folder_test.go @@ -47,7 +47,7 @@ func fakeFolderProgram() delegate.Delegate { if *inPlace { mode = "in place" } - host.Step("folder", mode) + 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 } diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index f7d188a20..d1b2aa223 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -456,7 +456,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 From d451c5758eba3cbe8581834019b286c895986a44 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:49:52 -0400 Subject: [PATCH 121/195] tui3, manual: a program's card no longer says its work starts from unsaved edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's branch-point line, `from your folder as it stands — unsaved edits included`, was the copy's sentence and was drawn on senior-dev's card too. senior-dev now works in the folder itself on a branch cut from the checkout's commit, and a checkout with work not committed is refused before its card, so a program's card leaves the line out; its where: line names the folder and its branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/tasks.md | 4 +++- internal/tui3/task.go | 9 ++++++++- internal/tui3/taskbranch_test.go | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index ffc8ea588..a41c463dc 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -542,7 +542,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 <full id> · 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. diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 5feaf9478..595f9a46e 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -1933,10 +1933,17 @@ func (a *app) taskCardRows(card *taskCard, width int, sel bool) []string { if card.where != "" { out = append(out, stem+a.pal.dim(fit("where: "+card.where, room))) } - if point := a.taskBranchPoint(); point != "" { + if point := a.taskBranchPoint(); point != "" && card.program == "" { // The branch point is the last of the facts about the work, and it is the // one thing on the card a person cannot find out afterwards without // reading a merge. + // + // A PROGRAM'S CARD HAS NONE. A program works in the folder itself, on a + // branch of its own cut from the commit the checkout is on, and a checkout + // with work not committed is refused before any card goes up + // (internal/session's programfolder.go): `unsaved edits included` was the + // copy's sentence, and on a program's card it was false twice over. Its + // `where:` line above already says the folder and its branch. out = append(out, stem+a.pal.dim(fit(point, room))) } if meta := a.taskMetaWord(card, room); meta != "" { diff --git a/internal/tui3/taskbranch_test.go b/internal/tui3/taskbranch_test.go index bfff05b9c..39804321a 100644 --- a/internal/tui3/taskbranch_test.go +++ b/internal/tui3/taskbranch_test.go @@ -55,3 +55,19 @@ func TestASettledProposalDropsTheBranchPoint(t *testing.T) { t.Fatalf("a settled card is still naming its branch point:\n%s", text) } } + +// A PROGRAM'S CARD NAMES NO BRANCH POINT. senior-dev works in the folder itself, +// on a branch of its own cut from the checkout's commit, and a checkout with +// work not committed is refused before its card goes up: the copy's sentence, +// `unsaved edits included`, was on its card and was false. +func TestAProgramsProposalNamesNoBranchPoint(t *testing.T) { + a, _, _ := taskApp(t) + a.branch = "work" + ev := proposal(a, 7, 4*time.Second) + ev.Task.Program = "senior-dev" + drive(t, a, streamEventMsg{gen: a.gen, ev: ev}) + + if text := taskText(a); strings.Contains(text, taskBranchPointWord) { + t.Fatalf("a program's proposal names the copy's branch point:\n%s", text) + } +} From 5569e016091d4c570d1939b8a72fae691c797ad9 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:00:50 -0400 Subject: [PATCH 122/195] seniordev: a refused submit leaves the steps after it where they were MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step classifier took any submit that did not fail as accepted. A refused submit does not fail: the submit tool tells its model why and lets it keep working, so the call settles as completed exactly as an accepted one does. One early refusal (an unchanged tree, no checklist yet) therefore filed the rest of the run under `submit`: the checklist, every edit, the tests and the real hand-in, with the task's row reading `senior-dev: submit` throughout. Now only the freeze's own `submit · frozen` stage record, which senior-dev writes once it has captured the tree and at no other time, marks a submit accepted; the submit call itself moves nothing. What the model sees, the freeze and the submit tool's result are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/seniordev/app/events.go | 8 +- internal/seniordev/app/solo.go | 2 +- internal/seniordev/app/step_ids.go | 30 +++++- internal/seniordev/app/step_ids_test.go | 136 +++++++++++++++++++++++- 4 files changed, 165 insertions(+), 11 deletions(-) diff --git a/internal/seniordev/app/events.go b/internal/seniordev/app/events.go index 4d1375309..9acac6ab7 100644 --- a/internal/seniordev/app/events.go +++ b/internal/seniordev/app/events.go @@ -79,7 +79,8 @@ type eventWriter struct { 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. + // accepted. It is read and moved under mu, in the order the calls finish + // and the freeze's stage record is written. progress stepProgress } @@ -122,6 +123,11 @@ func (writer *eventWriter) emit(value event) { } 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), diff --git a/internal/seniordev/app/solo.go b/internal/seniordev/app/solo.go index ca64aa9b3..5d7884ed6 100644 --- a/internal/seniordev/app/solo.go +++ b/internal/seniordev/app/solo.go @@ -646,7 +646,7 @@ func (runner *pipeline) soloFreezeWithContext( // 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("submit", "frozen", map[string]any{ + runner.events.stage(frozenStage, frozenStatus, map[string]any{ "reason": submission.Reason, "evidence": submission.Evidence, "checklist_satisfied": submission.ChecklistSatisfied, "checklist_items": checklist.items, diff --git a/internal/seniordev/app/step_ids.go b/internal/seniordev/app/step_ids.go index fe93c03b8..d7eae80c3 100644 --- a/internal/seniordev/app/step_ids.go +++ b/internal/seniordev/app/step_ids.go @@ -57,12 +57,31 @@ const ( // 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. +// 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. @@ -83,7 +102,11 @@ var editTools = map[string]bool{"edit": true, "write": true, "apply_patch": true // // - 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. +// - 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 @@ -97,9 +120,6 @@ func stepOf(action stepAction, progress stepProgress) (string, stepProgress) { return StepSubmit, progress } if action.tool == "submit" { - if !action.failed { - progress.submitted = true - } return StepSubmit, progress } if record := seniorDevRecordStep(action); record != "" { diff --git a/internal/seniordev/app/step_ids_test.go b/internal/seniordev/app/step_ids_test.go index 55e38e32e..88c6cf933 100644 --- a/internal/seniordev/app/step_ids_test.go +++ b/internal/seniordev/app/step_ids_test.go @@ -2,13 +2,25 @@ package app -import "testing" +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. +// 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} @@ -41,8 +53,9 @@ func TestAToolCallNamesTheStepItServed(t *testing.T) { {"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 refused submit", stepAction{tool: "submit", failed: true}, changed, StepSubmit, changed}, - {"an accepted submit", stepAction{tool: "submit"}, changed, StepSubmit, submitted}, + {"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) { @@ -54,6 +67,29 @@ func TestAToolCallNamesTheStepItServed(t *testing.T) { } } +// 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) { @@ -70,3 +106,95 @@ func TestEveryStepIdIsInTheOneList(t *testing.T) { } } } + +// 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) + } + } +} From 5002e5ef2eef102aab16dd8aeca2b52d98130b26 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:02:34 -0400 Subject: [PATCH 123/195] tui3, manual: a long run from before the action log says how many calls its page leaves out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program's page of a run logged before its actions were kept is drawn from the newest 200 calls the store carries, and the only fold count it drew was the count of earlier actions, which such a run never has. So a 350-call run's page put call 151 straight under the brief, as though the run began there; the same held for a live run read off a --host engine too old to send its actions. The page drew `…150 earlier calls` there before the actions view replaced the dialogue. It does again: a page with no actions counts the calls it leaves out at the same edge, and a page drawn from its action log still counts only its actions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/worker-harness.md | 4 +++- internal/tui3/taskconversation.go | 12 +++++++++- internal/tui3/taskconversation_test.go | 33 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 46f11827c..988c764d6 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -250,7 +250,9 @@ rewrote its history as a summary, `switched to <model>` when another model start answering its work (with the program's reason after it when it gave one), `codeaf refused a call · <why>` and `a call to its model failed · <why>`. 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`. +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 diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go index 456083c57..f97ceb168 100644 --- a/internal/tui3/taskconversation.go +++ b/internal/tui3/taskconversation.go @@ -315,8 +315,18 @@ func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull b } // THE ACTIONS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, spelled // the way every fold line on this surface is ([bandFoldWord]). - if program.EarlierActions > 0 { + // + // A PAGE DRAWN FROM ITS CALLS COUNTS THE CALLS IT LEAVES OUT THERE INSTEAD. A + // run from before the action log — or one read off a --host engine too old to + // send its actions — has no actions to count, and its page is the newest calls + // the store carries ([actFromCalls]); without the count its first kept call + // stood straight under the brief, and the page read as though the run began + // there. + switch { + case program.EarlierActions > 0: out = append(out, pal.dim(fit(glyphMore+itoa(program.EarlierActions)+" "+actEarlierWord, width))) + case len(program.Actions) == 0 && program.Earlier > 0: + out = append(out, pal.dim(fit(glyphMore+itoa(program.Earlier)+" "+convEarlierWord, width))) } current := "" for _, line := range lines { diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index 6ad48df28..b5084d65c 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -487,6 +487,39 @@ func TestARunFromBeforeTheActionLogIsDrawnFromItsCalls(t *testing.T) { } } +// A LONG RUN FROM BEFORE THE ACTION LOG SAYS HOW MANY CALLS ITS PAGE LEAVES OUT. +// Its page is drawn from the newest calls the store carries, so the count of the +// ones it cut is a count of calls, and it stands between the brief and the +// first action drawn from them — without it the page read as though the run +// began at the first call it kept. A page whose actions were logged counts its +// actions there and never its calls. +func TestALongRunFromBeforeTheActionLogSaysHowManyCallsItLeavesOut(t *testing.T) { + page := programPage(programRow(), programTurns()) + page.Program.Actions, page.Program.Earlier, page.Program.Calls = nil, 150, 153 + page.Row.Status = "done" + a, _ := programPageApp(t, page, 80, 40) + lines := programPageLines(a) + text := strings.Join(lines, "\n") + fold, first := -1, -1 + for i, line := range lines { + if fold < 0 && strings.Contains(line, "150 "+convEarlierWord) { + fold = i + } + if first < 0 && strings.Contains(line, "read internal/auth/middleware.go") { + first = i + } + } + if fold < 0 || first < 0 || fold > first || strings.Contains(text, actEarlierWord) { + t.Fatalf("a run drawn from its calls does not count the 150 calls it leaves out above its first action:\n%s", text) + } + logged := programPage(programRow(), programTurns()) + logged.Program.Earlier = 150 + b, _ := programPageApp(t, logged, 80, 40) + if text := strings.Join(programPageLines(b), "\n"); strings.Contains(text, convEarlierWord) { + t.Fatalf("a page drawn from its action log counts the calls it leaves out:\n%s", text) + } +} + // THE RAW CALLS ARE ONE KEY AWAY. The key row names the key; the key turns the // page to the dialogue between the program and its model — the model named, its // words and its calls — and the key row then names the way back; the same key From a1536bb37469b706e53ee118917dfe37fc1a8938 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:06:50 -0400 Subject: [PATCH 124/195] tui3: a page onto another conversation's task wears that task's badge, never a local task's of the same number A guest page's header asked nodeProgram for its badge, which fell back to this window's own plan rows by the bare task id; ids restart with every conversation, so another conversation's ordinary task 7 read `[senior-dev]` beside this conversation's senior-dev task 7. And the guest node never took a program at all, from the row it opened from or from the owner's notices, so another conversation's senior-dev work opened with no badge. Now only a node in this window's own graph is looked up in its plan rows. A guest node takes the program its row names, the row another window has out keeps the program of the index row for the same work (as it keeps the family), and the owner's notice sets it; a notice naming none takes nothing away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/tui3/programbadge.go | 10 +++ internal/tui3/programbadge_test.go | 99 ++++++++++++++++++++++++++++++ internal/tui3/taskowner.go | 11 ++++ internal/tui3/tasksplace.go | 6 +- 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/internal/tui3/programbadge.go b/internal/tui3/programbadge.go index f08c5a1d7..adc8039ae 100644 --- a/internal/tui3/programbadge.go +++ b/internal/tui3/programbadge.go @@ -176,6 +176,13 @@ func pageProgram(page session.PlanTaskPage) string { // engine that sent them predates the field, the name the run's own plan row // carries ([app.railProgramRow]), which is a row the surface already holds and // never a read made while drawing. +// +// ONLY THIS WINDOW'S OWN NODE IS LOOKED UP IN THIS WINDOW'S PLAN ROWS. Those +// rows are the conversation in front's, found by the bare number, and task ids +// restart with every conversation — so a guest page's node (taskowner.go's +// [taskGuestNode]), standing for another conversation's task 7, would take the +// badge of this conversation's own task 7 and wear `[senior-dev]` over work no +// program had. Such a node answers with its own fact and nothing else. func (a *app) nodeProgram(node *taskNode) string { if node == nil { return "" @@ -183,6 +190,9 @@ func (a *app) nodeProgram(node *taskNode) string { if node.program != "" { return node.program } + if a.tasks[node.id] != node { + return "" + } if row, ok := a.railProgramRow(node); ok { return strings.TrimSpace(row.Program) } diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go index 3c1291fc2..bba6a3457 100644 --- a/internal/tui3/programbadge_test.go +++ b/internal/tui3/programbadge_test.go @@ -280,6 +280,105 @@ func TestTheRoomPanelAndItsTitleWearTheBadge(t *testing.T) { } } +// A PAGE ONTO ANOTHER CONVERSATION'S WORK WEARS THAT WORK'S BADGE, AND NEVER THE +// ONE A LOCAL TASK OF THE SAME NUMBER WEARS. Task ids restart with every +// conversation, so the page onto their task 7 stands beside this window's own +// task 7 — here a senior-dev run, whose plan row the surface holds. The page's +// header used to fall through to that row by the bare number and draw +// `[senior-dev]` over work no program had; and when the owner said its own work +// was a program's, the page dropped it. +func TestAGuestPageWearsItsOwnWorksBadgeAndNeverTheLocalTasks(t *testing.T) { + a, _ := planAppWith(t, []session.PlanTaskRow{programRow()}, nil) + a.width, a.height = 120, 30 + a.taskUpdate(update(7, programTitle, session.TaskRunning, session.TaskNotice{})) + if drawn := plain(strings.Join(a.railRows(a.viewHeight()), "\n")); !strings.Contains(drawn, "[senior-dev]") { + t.Fatalf("this window's own task 7 wears no badge, so this would prove nothing:\n%s", drawn) + } + door := &guestDoor{} + door.watching() + a.openTaskOwner = door.open + a.away = elsewhereCache{read: true, at: a.now(), held: session.NewElsewhere(a.now(), + map[string]string{"the-other-window": "docs pass"}, + window("the-other-window", session.PresenceTask{ + ID: "7", Title: "Port the parser", State: string(session.TaskRunning)}))} + enterAway(t, a) + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser") || strings.Contains(title, "[") { + t.Fatalf("another conversation's ordinary task 7 reads %q, wearing the badge of this window's own task 7", title) + } + // AND WHEN ITS OWNER SAYS ITS WORK IS A PROGRAM'S, THE PAGE WEARS THAT BADGE. + ownerSays(t, a, session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 7, Title: "Port the parser", State: session.TaskRunning, Program: "doc-writer"}}) + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser [doc-writer]") { + t.Fatalf("the owner said its task 7 is doc-writer's and the page reads %q", title) + } + if node := a.tasks[7]; node == nil || node.program != "" { + t.Fatalf("the owner's notice named a program on this window's own task 7: %+v", node) + } +} + +// ANOTHER CONVERSATION'S PROGRAM WORK OPENS WEARING ITS BADGE, off the row the +// page was opened from and before its owner has said anything: the project's +// index names the program, and the row of that work another window has out +// keeps it, as it keeps the family it belongs to. +func TestAnotherConversationsProgramWorkOpensWearingItsBadge(t *testing.T) { + a, door := guestLab(t) + door.watching() + theirs := theirLiveSession + theirs.Tasks = session.TaskRollup{Rows: []session.TaskIndexEntry{{ + ID: "7", SessionID: theirs.ID, Label: "Port the parser", Title: "Port the parser", + Status: string(session.TaskRunning), Program: "senior-dev", + }}} + if !openTaskPlaceWithRows(a) { + t.Fatal("the tasks place opened with no rows on it") + } + // The walk of the disk, as this fixture's machine would have answered it, + // read the way the place's own rebuild reads it ([tasksPlace.regroup]). + p := &a.taskSheet + p.world = session.World{Projects: []session.Project{{Sessions: []session.SessionRow{theirs}}}} + p.reading = readTasks(p.world, p.mine, p.reading.win, p.order, p.reading.seen, p.reading.now) + r := a.tasksFiltered() + width, _ := a.size() + lines := r.lay(width) + found := false + for at := range lines { + if item, ok := r.at(lines, at); ok && item.away { + if item.entry.Program != "senior-dev" { + t.Fatalf("the row another window has out lost the program its index row names: %+v", item.entry) + } + a.taskSheet.cursor, found = at, true + break + } + } + if !found { + t.Fatalf("no row on the page belongs to another window:\n%s", taskSheetText(a)) + } + cmd := a.taskSheetEnter() + if cmd == nil { + t.Fatal("enter over another window's running work did nothing at all") + } + msg, ok := cmd().(taskOwnerMsg) + if !ok { + t.Fatalf("enter did not ask the engine for the owner: %T", cmd()) + } + a.tookTaskOwner(msg) + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser [senior-dev]") { + t.Fatalf("another conversation's senior-dev task opens reading %q, want its badge", title) + } + // AND AN OWNER'S NOTICE THAT NAMES NO PROGRAM — an engine older than the + // field — takes nothing away. + ownerSays(t, a, session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 7, Title: "Port the parser", State: session.TaskRunning}}) + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser [senior-dev]") { + t.Fatalf("a notice naming no program took the badge off the page: %q", title) + } +} + // THE CARD A PERSON APPROVES NAMES THE PROGRAM: the block in the transcript // wears the badge beside the name, and the question above the box says it in // words — the same sentence the engine's own question object says. diff --git a/internal/tui3/taskowner.go b/internal/tui3/taskowner.go index 2f4d66f1d..022220999 100644 --- a/internal/tui3/taskowner.go +++ b/internal/tui3/taskowner.go @@ -453,6 +453,12 @@ func (a *app) tookGuestNotice(msg taskGuestNoticeMsg) tea.Cmd { if title := strings.TrimSpace(notice.Title); title != "" { guest.node.title = title } + // THE PROGRAM THE OWNER NAMES IS THE PAGE'S BADGE, and a notice naming + // none — from an engine older than the field — takes nothing away, the + // rule the rail keeps for this window's own nodes. + if program := strings.TrimSpace(notice.Program); program != "" { + guest.node.program = program + } if notice.Elapsed > 0 { guest.node.elapsed = notice.Elapsed } @@ -963,6 +969,10 @@ func (a *app) taskGuestTrail(item tasksItem) []string { // THE MODEL IS EMPTY BECAUSE NOBODY HERE KNOWS IT. The presence file the row was // minted from carries a title and a state, not a model, and the emptiness law // says an unknown draws as nothing rather than as this conversation's own. +// +// THE PROGRAM IS THE ROW'S OWN, and the only place the page's badge may come +// from until the owner says otherwise ([app.tookGuestNotice]): this window's +// plan rows are another conversation's numbering ([app.nodeProgram]). func taskGuestNode(item tasksItem) *taskNode { id := taskSheetEntryID(item.entry.ID) title := strings.TrimSpace(item.entry.Title) @@ -974,6 +984,7 @@ func taskGuestNode(item tasksItem) *taskNode { ident: identFor(id), title: title, label: strings.TrimSpace(item.entry.Label), + program: strings.TrimSpace(item.entry.Program), state: session.TaskState(strings.TrimSpace(item.entry.Status)), ended: item.entry.EndedAt, met: item.entry.EndedAt, diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 250d8728b..5cbb4c83e 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -330,7 +330,11 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by Status: task.Task.State, SessionID: task.SessionID, StartedAt: task.Task.StartedAt, } key := tasksKeyOf(entry) - entry.Parent = held[key].entry.Parent + // THE FAMILY AND THE PROGRAM COME OFF THE INDEX ROW OF THE SAME WORK, where + // there is one: presence carries neither, and a row that dropped the + // program would open a page with no badge over a program's work + // ([taskGuestNode]). + entry.Parent, entry.Program = held[key].entry.Parent, held[key].entry.Program put(key, tasksItem{ entry: entry, row: tasksRowFor(world, mine, entry), runs: true, away: true, window: task.Session, here: mine.here[strings.TrimSpace(task.SessionID)], From 33320cbc98869885d956f5087c098b73e0bdadc3 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:14:50 -0400 Subject: [PATCH 125/195] manual: a page onto another conversation's task wears that task's own badge The badge section said a task's page wears its badge beside the title and nothing about a page opened onto another conversation's work, whose badge is now its own work's and never the one a task of the same number in this conversation wears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 87e0be043..05c6d1f3a 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -111,7 +111,9 @@ A task handed to senior-dev wears its name as a badge wherever a task is named: room for the whole badge and the number. - **The card you answer** asks `wants to start a [senior-dev] task: <title>`, and the card's top line wears the badge beside the task's name. -- **The task's own page** wears it beside the title. +- **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's list of work** wear `[senior-dev]`. From f90024f20928b9b52f17659adf86e5ee513f2855 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:16:29 -0400 Subject: [PATCH 126/195] tui3, manual: home's needs and since-you-left rows and the @ list pay for a program's badge out of the title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home's needs you rows, its since you left lines and the @ list wrote a program's badge onto the end of the title as plain text, and each of them cuts a title from its right to keep the age at its edge, so the badge was the first thing lost: a senior-dev landing with a 56-cell name read `…session store [seni… 1h` on a two-column home and wore no badge on a three-column one, and the @ list lost it under about seventy columns. Now a home cell carries the program beside its title and measures the badge as part of it, and the badge keeps its long spelling while the title keeps twelve cells, its short one after that, the rule every other list of the work keeps. A since you left line wears the badge between the name and what the work came to, which gives way first. The @ list fits its label to the room its row will give it before the row cuts it; the row's own label budget is one function now and both read it. Ordinary rows are measured and cut exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 5 +- internal/tui3/files.go | 3 +- internal/tui3/homecell.go | 68 +++++++++++++++++--- internal/tui3/homegrid.go | 9 +++ internal/tui3/homepanel_left.go | 37 ++++++++--- internal/tui3/homepanel_needs.go | 11 ++-- internal/tui3/palette.go | 75 ++++++++++++++-------- internal/tui3/programbadge.go | 12 ---- internal/tui3/programbadge_test.go | 99 +++++++++++++++++++++++++++++- internal/tui3/taskmention.go | 22 ++++++- 10 files changed, 273 insertions(+), 68 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 05c6d1f3a..328804042 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -116,7 +116,10 @@ A task handed to senior-dev wears its name as a badge wherever a task is named: 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's list of work** wear `[senior-dev]`. +- **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 diff --git a/internal/tui3/files.go b/internal/tui3/files.go index b8041935b..6521c69a1 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -521,7 +521,8 @@ func (c *completion) rows(width, n int, pal palette, hover int) []string { case line.header != "": ok = fill.plain(pal.dim(" " + fit(line.header, width-2))) case line.task >= 0: - ok = fill.add(at, taskRowLabel(c.taskHits[line.task], pal), c.lineNote(at), at == c.selLine(), false) + note := c.lineNote(at) + ok = fill.add(at, taskRowLabel(c.taskHits[line.task], note, width, pal), note, at == c.selLine(), false) default: ok = fill.add(at, c.all[line.file], c.lineNote(at), at == c.selLine(), false) } diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index e35c7fe4a..9edc8c89e 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -569,11 +569,16 @@ func (a *app) homeCellLead(cell *homeCell, at int, pal palette) string { // right-hand word, the age or the door word, is the last thing to go: only where // the title would keep fewer than [homeCellTitleFloor] cells beside it, and a // held word not even then. +// +// A PROGRAM'S BADGE IS MEASURED AS PART OF THE TITLE AND PAID FOR OUT OF IT +// ([homeCellWears]), so the note, the tag and the age give way to it exactly as +// they give way to the title, and a cut takes the title's tail and never the +// badge. A row with no program is measured and cut as it always was. func homeCellBody(cell *homeCell, width int, pal palette, lit bool) string { if width < 1 { return "" } - title, note, tag, right := cell.title, cell.note, cell.tag, cell.right + title, note, tag, right := cell.measured(), cell.note, cell.tag, cell.right pad := cell.pad if cell.path { title, pad = homeCellPathTitle(cell, width) @@ -588,13 +593,19 @@ func homeCellBody(cell *homeCell, width int, pal palette, lit bool) string { width < homeCellTitleFloor+homeCellWidth("", 0, "", "", right) { right = "" } - if over := homeCellWidth(title, pad, note, tag, right) - width; over > 0 { - keep := max(1, ansi.StringWidth(title)-over) - if cell.panel == panelRecent || cell.panel == panelSessions { - title = fitConversationTitle(title, keep) - } else { - title = fit(title, keep) - } + room := ansi.StringWidth(title) + over := homeCellWidth(title, pad, note, tag, right) - width + if over > 0 { + room = max(1, room-over) + } + wears, after := "", "" + switch badge := programBadge(cell.program); { + case badge.known(): + title, wears, after = homeCellWears(cell.title, badge, cell.after, room) + case over > 0 && (cell.panel == panelRecent || cell.panel == panelSessions): + title = fitConversationTitle(title, room) + case over > 0: + title = fit(title, room) } titleInk, factInk := pal.ink, pal.dim if cell.bold || lit { @@ -611,6 +622,14 @@ func homeCellBody(cell *homeCell, width int, pal palette, lit bool) string { line = pal.underline(line) } used := ansi.StringWidth(title) + if wears != "" { + line += pal.programAfter(wears) + used += programCells(wears) + } + if after != "" { + line += titleInk(after) + used += ansi.StringWidth(after) + } if note != "" { gap := max(0, pad-used) + len(homeCellGap) line += strings.Repeat(" ", gap) + factInk(note) @@ -637,6 +656,37 @@ func homeCellPathTitle(cell *homeCell, width int) (string, int) { return fit(cell.title, room), min(cell.pad, room) } +// measured is a row's title as the cell measures it: the title, its program's +// badge in the long spelling, and the words that follow the badge — which is +// the title alone on every row no program had. +func (c *homeCell) measured() string { + full := programBadge(c.program).full + if full == "" { + return c.title + c.after + } + return c.title + " " + full + c.after +} + +// homeCellWears fits a program's row into room cells: the title, the badge +// spelling it keeps, and the words after the badge, each as drawn. +// +// THE WORDS AFTER THE BADGE GIVE WAY FIRST, cut while more of them than their +// separator and a letter survives, because they say what the work came to and +// the badge says whose it was. THEN THE BADGE IS PAID FOR OUT OF THE TITLE +// ([programSpelling]): the long spelling while the title keeps +// [homeCellTitleFloor] cells beside it, the short one after that, none below +// it — the rule every other list of the work keeps ([palette.programTitled]). +func homeCellWears(title string, badge rowField, after string, room int) (string, string, string) { + if after != "" { + left := room - ansi.StringWidth(title) - programCells(badge.full) + if left >= min(ansi.StringWidth(after), ansi.StringWidth(rowSep)+2) { + return title, badge.full, fit(after, left) + } + } + spelling := programSpelling(badge, title, room, homeCellTitleFloor) + return fit(title, room-programCells(spelling)), spelling, "" +} + // homeCellDoor is the row UNDER THE CURSOR growing its held word into the door // it offers — `another window · enter brings it here` — where the whole title // still fits beside the whole clause, and never while a question this window @@ -648,7 +698,7 @@ func (a *app) homeCellDoor(cell *homeCell, at, width int) *homeCell { } grown := *cell grown.right = cell.door - if homeCellWidth(grown.title, grown.pad, "", grown.tag, grown.right) > width { + if homeCellWidth(grown.measured(), grown.pad, "", grown.tag, grown.right) > width { return cell } return &grown diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 1977b9785..6c0cf3fc0 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -445,6 +445,15 @@ type homeCell struct { panel homePanelID mark homeCellMark title string + // program is the program a row's work was handed to — senior-dev — and "" + // for every other row. Its badge follows the title and is PAID FOR OUT OF + // THE TITLE ([homeCellWears]): the cell cuts a title from its right, and a + // badge written onto the end of the title was the first thing that cut took. + program string + // after is the words that follow a program's badge — what a landed task came + // to, its separator in front ([ledgerTaskParts]) — which a cut takes before + // it takes the badge. A row with no program carries them in its title. + after string // pad is the cells the title is padded to before the note, so a panel's // notes stand in one column (the projects panel's counts). pad int diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 5966eefac..4ec9bcf0e 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -53,9 +53,18 @@ func (leftPanel) rows(in *homeGridInput) homePanelRows { // panel — and both are the row's description now, drawn under the cursor // ([switcherRow.note]); the edge reads `3h`, `1h`, like every row of the field // (owner, 2026-09-15). +// +// A PROGRAM'S LANDING WEARS ITS BADGE BETWEEN ITS NAME AND WHAT IT CAME TO, so +// the cell cuts the outcome before the badge and pays for the badge out of the +// name ([homeCellWears]); every other line is its title whole, as it was. func leftLine(row switcherRow, now time.Time) homeLine { cell := &homeCell{panel: panelLeft, title: row.title, right: sinceAt(row.at, now), row: &row, sub: strings.TrimSpace(row.note), grows: strings.TrimSpace(row.note) != ""} + if row.task != nil { + if label, after, program := ledgerTaskParts(*row.task, row.session); program != "" { + cell.title, cell.after, cell.program = label, after, program + } + } return homeLine{kind: homeLedger, project: row.place, dir: leftKey(row), view: row.item, item: row.item.Item, cell: cell} } @@ -118,6 +127,15 @@ func ledgerLanded(world session.World, seen time.Time) []switcherRow { } // ledgerTaskLine is a landed task in one line: its label and what it came to. +func ledgerTaskLine(entry session.TaskIndexEntry, row session.SessionRow) string { + label, after, _ := ledgerTaskParts(entry, row) + return label + after +} + +// ledgerTaskParts is that line in the pieces a cell draws it in: the label, +// what the work came to with the separator in front of it ("" when there is +// nothing to say), and the program the work was handed to, whose badge stands +// between the two ([leftLine]). // // A TASK THAT STOPPED INCOMPLETE SAYS WHY IN THE OUTCOME'S PLACE. The first // sentence of a report is what the work came to only when the work finished; @@ -125,27 +143,26 @@ func ledgerLanded(world session.World, seen time.Time) []switcherRow { // news, in the rail's own words for it ([endingWord]). // // A ROW WITH NO NAME OF ITS OWN IS NAMED FOR ITS CONVERSATION rather than drawn -// as a bare outcome, so every line still says whose work it was. -func ledgerTaskLine(entry session.TaskIndexEntry, row session.SessionRow) string { - label := strings.TrimSpace(entry.Label) +// as a bare outcome, so every line still says whose work it was — and it wears +// no badge, because the name is the conversation's and not the work's. +func ledgerTaskParts(entry session.TaskIndexEntry, row session.SessionRow) (label, after, program string) { + label = strings.TrimSpace(entry.Label) if label == "" { label = strings.TrimSpace(entry.Title) } - // A PROGRAM'S WORK SAYS WHOSE IT IS, with its badge after its name - // (programbadge.go) — the brackets alone, because this line is measured and - // painted whole by the cell that draws it. - label = programText(label, entry.Program) if label == "" { label = homeName(row) + } else { + program = strings.TrimSpace(entry.Program) } outcome := endingWord(entry.Ending) if outcome == "" { outcome = switcherFirstLine(entry.Outcome) } - if outcome == "" { - return label + if outcome != "" { + after = rowSep + outcome } - return label + rowSep + outcome + return label, after, program } // ledgerMade is a line per file a conversation made since the look stamp: diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 4b65e4ae3..e3be91537 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -327,9 +327,12 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta title = strings.TrimSpace(entry.Title) } // A PROGRAM'S WORK SAYS WHOSE IT IS, with its badge after its name - // (programbadge.go) — the brackets alone, because the cell measures and - // paints its title whole. - title = programText(title, entry.Program) + // (programbadge.go), which the cell pays for out of the title + // ([homeCellWears]) — a name with no words gets none. + program := "" + if title != "" { + program = strings.TrimSpace(entry.Program) + } asked := needsCallAt(entry) // A LANDING WEARS NO MARK (law 8). The amber `?` means a thing has stopped // and will not move until somebody answers it; a landing has already @@ -338,7 +341,7 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta // THE THREAD IT BELONGS TO HEADS THE DESCRIPTION (owner, 2026-09-17), // spelled as `threads` spells the same conversation ([homeName]); under // that title line come the files and what the work came to. - cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: title, right: sinceAt(asked, now), + cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: title, program: program, right: sinceAt(asked, now), key: needsCallKey + entry.ID, thread: homeName(row), grows: true, sub: rowClauses(needsCallFiles(entry), needsCallSub(entry, status)), answers: needsCallAnswers(status)} line := homeLine{kind: homeSession, row: row, project: project.Name, diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 10ed60dde..f368a6e33 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1192,6 +1192,53 @@ func overlayNoteRoom(label string, width int) int { return room - floor - rowGutter } +// overlayRowRoom is the cells a one-line row gives its label, and the note as +// the row draws it beside that label ([overlayRowCore] is the row). +// +// THE NOTE IS CUT TO THE ROW BEFORE THE ROW IS BUDGETED AROUND IT. The label +// absorbs whatever the note leaves and the gap clamps at one cell, so a note +// longer than the terminal used to be appended WHOLE to an empty label — the +// row ran past the edge by however long the note was, and no amount of +// squeezing the label could pull it back. What it may take is everything but +// the lead and the gutter. Settings' `tool exceptions` is the row that found +// it: a value naming ten tools is 141 cells against a 60-cell terminal, which +// is LAW 1 (a place takes exactly the frame) broken by a value a person chose. +func overlayRowRoom(label, note string, width int) (int, string) { + room := width - 2 + if note != "" { + // AND THE LABEL KEEPS A FLOOR UNDER IT. The label used to absorb + // whatever the note left, which on a long note left it NOTHING: the row + // drew a full-width value with no name in front of it, and a person + // reading down the column could not tell which setting they were + // looking at. So the note may take the row's second half and no more — + // or all of it but the label's own width, when the label is the shorter + // of the two — and the label gives way only inside what is left. + // + // EVERY LIST THAT RANKS ITS FACTS HANDS US A NOTE THAT ALREADY FITS + // (rowfit.go drops whole facts rather than cutting one in half), so this + // is the floor under the lists that pass a note they did not budget. + note = fit(note, overlayNoteRoom(label, width)) + } + if note != "" { + room -= ansi.StringWidth(note) + rowGutter + } + return room, note +} + +// overlayLabelRoom is the cells a row's label is given in whichever shape the +// row is drawn: the line under the lead where the note takes a line of its own +// at [tierPhone] ([overlayLinesCore]), and what the note leaves it otherwise +// ([overlayRowRoom]). A list that pays for part of its label out of the rest — +// a program's badge out of a task's title (taskmention.go's [taskRowLabel]) — +// fits the label to this first, so the row's own cut never reaches that part. +func overlayLabelRoom(label, note string, width int) int { + if overlayItemLines(width, note) > 1 { + return width - 2 + } + room, _ := overlayRowRoom(label, note, width) + return room +} + // overlayMeasure is HOW WIDE A LABEL/TAIL PAIR IS LAID OUT, however wide the // frame is. It is a reading measure the way [teachMeasure] is one for prose, and // it is wider because a row carries structure a paragraph does not. @@ -1361,33 +1408,7 @@ func overlayRowHitTinted(label, note string, hit []int, tint noteInk, oncursor b // for the ones that are. hit is the search's emphasis, nil for none. func overlayRowCore(label, note string, hit []int, tint noteInk, oncursor bool, marked rowMark, hovered bool, width int, pal palette) string { lead := overlayLead(oncursor, hovered, pal) - // THE NOTE IS CUT TO THE ROW BEFORE THE ROW IS BUDGETED AROUND IT. The label - // absorbs whatever the note leaves and the gap below clamps at one cell, so a - // note longer than the terminal used to be appended WHOLE to an empty label — - // the row ran past the edge by however long the note was, and no amount of - // squeezing the label could pull it back. What it may take is everything but - // the lead and the gutter. Settings' `tool exceptions` is the row - // that found it: a value naming ten tools is 141 cells against a 60-cell - // terminal, which is LAW 1 (a place takes exactly the frame) broken by a - // value a person chose. - room := width - 2 - if note != "" { - // AND THE LABEL KEEPS A FLOOR UNDER IT. The label used to absorb - // whatever the note left, which on a long note left it NOTHING: the row - // drew a full-width value with no name in front of it, and a person - // reading down the column could not tell which setting they were - // looking at. So the note may take the row's second half and no more — - // or all of it but the label's own width, when the label is the shorter - // of the two — and the label gives way only inside what is left. - // - // EVERY LIST THAT RANKS ITS FACTS HANDS US A NOTE THAT ALREADY FITS - // (rowfit.go drops whole facts rather than cutting one in half), so this - // is the floor under the lists that pass a note they did not budget. - note = fit(note, overlayNoteRoom(label, width)) - } - if note != "" { - room -= ansi.StringWidth(note) + rowGutter - } + room, note := overlayRowRoom(label, note, width) label = fit(label, room) // lifted is whether this row wears a ground at all, which is the one thing diff --git a/internal/tui3/programbadge.go b/internal/tui3/programbadge.go index adc8039ae..f18c22e50 100644 --- a/internal/tui3/programbadge.go +++ b/internal/tui3/programbadge.go @@ -146,18 +146,6 @@ func (p palette) programLabel(title, program string, room int, ink func(string) } } -// programText is a title with its program's badge after it, as plain words, for -// a row whose title is measured and painted as one piece of text by a renderer -// of its own (home's cells). The brackets carry the badge there on their own, -// which is the reason they are always drawn. -func programText(title, program string) string { - badge := session.ProgramBadge(program) - if badge == "" || strings.TrimSpace(title) == "" { - return title - } - return title + " " + badge -} - // pageProgram is the program a stored page's task was handed to: the name the // program's own record gives it, and the name its row carries while that record // has not reached the disk — "" for every page no program was handed. diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go index bba6a3457..6a9ba5bdc 100644 --- a/internal/tui3/programbadge_test.go +++ b/internal/tui3/programbadge_test.go @@ -437,7 +437,7 @@ func TestTheOtherListsOfTheWorkWearTheBadge(t *testing.T) { } entry := session.TaskIndexEntry{ID: "7", Label: programTitle, Title: programTitle, Status: "running", Program: "senior-dev"} - if label := plain(taskRowLabel(entry, a.pal)); !strings.HasSuffix(label, programTitle+" [senior-dev]") { + if label := plain(taskRowLabel(entry, "3m", 120, a.pal)); !strings.HasSuffix(label, programTitle+" [senior-dev]") { t.Fatalf("the @ list's row is %q", label) } if block := taskPointerBlock(entry); !strings.Contains(block, "· via senior-dev") { @@ -445,7 +445,7 @@ func TestTheOtherListsOfTheWorkWearTheBadge(t *testing.T) { } ordinary := entry ordinary.Program = "" - if label := plain(taskRowLabel(ordinary, a.pal)); strings.Contains(label, "[") { + if label := plain(taskRowLabel(ordinary, "3m", 120, a.pal)); strings.Contains(label, "[") { t.Fatalf("an ordinary @ row is %q", label) } @@ -470,6 +470,101 @@ func TestTheOtherListsOfTheWorkWearTheBadge(t *testing.T) { } } +// programLongLabel is a program's task named as long as the project's index +// names one (session's taskLabelLimit, fifty-six cells), which is the label a +// home cell or an `@` row is handed. +const programLongLabel = "Rewrite the auth middleware to use the new session store" + +// HOME'S `needs you` ROWS AND ITS `since you left` LINES PAY FOR THE BADGE OUT +// OF THE TITLE, as every other list of the work does. The cell cuts a title +// from its right to keep the row's age, and a badge written onto the end of the +// title was the first thing it took: a senior-dev landing with a long name read +// `…session store [seni… 1h` on a two-column home and wore no badge at all on a +// three-column one. A landed line keeps the badge between the name and what the +// work came to, so the outcome gives way before the badge does. +func TestHomesRowsPayForTheBadgeOutOfTheTitle(t *testing.T) { + pal := newTestPalette() + now := taskFixtureNow + entry := session.TaskIndexEntry{ + ID: "7", SessionID: "chat-1", Label: programLongLabel, Title: programLongLabel, + Status: string(session.TaskDone), Program: "senior-dev", + Outcome: "The middleware reads the new store.", EndedAt: now.Add(-time.Hour), + } + row := session.SessionRow{ID: "chat-1", Title: "the run", Tasks: session.TaskRollup{Rows: []session.TaskIndexEntry{entry}}} + needs := needsCall(session.Project{}, row, entry, session.TaskStatus{}, now).line.cell + landed := ledgerLanded(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}}, now.Add(-2*time.Hour)) + if len(landed) != 1 { + t.Fatalf("the landing is not a line of `since you left`: %+v", landed) + } + ledger := leftLine(landed[0], now).cell + if wide := plain(homeCellBody(ledger, 140, pal, false)); !strings.Contains(wide, programLongLabel+" [senior-dev] · The middleware reads the new store.") { + t.Fatalf("a wide landed line reads %q, want the badge between the name and the outcome", wide) + } + for _, probe := range []struct { + width int + want string + }{ + {80, "[senior-dev]"}, {66, "[senior-dev]"}, {55, "[senior-dev]"}, {40, "[senior-dev]"}, {21, "[sd]"}, + } { + for name, cell := range map[string]*homeCell{"needs you": needs, "since you left": ledger} { + drawn := plain(homeCellBody(cell, probe.width, pal, false)) + t.Logf("%s at %d: %q", name, probe.width, drawn) + if !strings.HasPrefix(drawn, "Rewrite the") || !strings.Contains(drawn, probe.want) || !strings.HasSuffix(drawn, " 1h") { + t.Fatalf("%s's row at %d cells reads %q, want the title, %s and the age", name, probe.width, drawn, probe.want) + } + if cells := ansi.StringWidth(drawn); cells > probe.width { + t.Fatalf("%s's row is %d cells in %d: %q", name, cells, probe.width, drawn) + } + } + } + // AN ORDINARY LANDING DRAWS WHAT IT ALWAYS DREW: its name and what it came to, + // cut from the right, and nothing bracketed. + entry.Program = "" + row.Tasks.Rows[0] = entry + plainNeeds := needsCall(session.Project{}, row, entry, session.TaskStatus{}, now).line.cell + plainLanded := ledgerLanded(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}}, now.Add(-2*time.Hour)) + for _, width := range []int{140, 66, 40} { + if drawn := plain(homeCellBody(plainNeeds, width, pal, false)); strings.Contains(drawn, "[") { + t.Fatalf("an ordinary landing's needs row reads %q", drawn) + } + if drawn := plain(homeCellBody(leftLine(plainLanded[0], now).cell, width, pal, false)); strings.Contains(drawn, "[") { + t.Fatalf("an ordinary landed line reads %q", drawn) + } + } + if drawn := plain(homeCellBody(leftLine(plainLanded[0], now).cell, 140, pal, false)); !strings.HasPrefix(drawn, programLongLabel+" · The middleware") { + t.Fatalf("an ordinary landed line reads %q", drawn) + } +} + +// THE `@` LIST PAYS FOR THE BADGE OUT OF THE TITLE TOO. Its row cuts a label +// from the right to keep the age at its edge, so a badge written onto the end +// of the label was gone under about seventy columns. +func TestTheMentionListPaysForTheBadgeOutOfTheTitle(t *testing.T) { + a, _, _ := taskApp(t) + entry := pastTask("7", "rewrite-the-auth-middleware", programLongLabel, time.Hour) + entry.Program = "senior-dev" + a.comp.tasks = []session.TaskIndexEntry{entry} + drive(t, a, key("@"), key("r"), key("e")) + drive(t, a, filesLoadedMsg{}) + for _, probe := range []struct { + width int + want string + }{ + {100, "[senior-dev]"}, {60, "[senior-dev]"}, {45, "[senior-dev]"}, {28, "[sd]"}, + } { + found := "" + for _, row := range a.comp.rows(probe.width, completeRows, a.pal, -1) { + if line := plain(row); strings.Contains(line, "Rewrite") { + found = line + } + } + t.Logf("at %d: %q", probe.width, found) + if !strings.Contains(found, probe.want) || ansi.StringWidth(found) > probe.width { + t.Fatalf("the @ list's row at %d cells reads %q, want %s inside the frame", probe.width, found, probe.want) + } + } +} + // THE BADGE NEVER CUTS A TITLE BELOW ITS FLOOR. It falls to its short spelling // first, and where even that would leave the title less than the floor the row // draws no badge at all rather than a badge standing for a name nobody can read. diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index 706a3b1f9..5e9167f39 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -46,6 +46,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/session" ) @@ -416,14 +417,31 @@ const ( // it IS — under the cursor, hovered, dim — and a word carrying colour of its own // would fight that paint, so the badge says itself in the row's ink like every // other word on it. -func taskRowLabel(entry session.TaskIndexEntry, pal palette) string { +// +// AND IT IS PAID FOR OUT OF THE WORDS. The row cuts a label from its right to +// keep the note at its edge, so the label is fitted here first, to the room the +// row will give it beside note in width cells ([overlayLabelRoom]): the badge +// keeps its long spelling while the words keep [railTitleFloor] cells, its short +// one after that, and the words are cut into what is left. An ordinary task's +// row is handed over exactly as it always was. +func taskRowLabel(entry session.TaskIndexEntry, note string, width int, pal palette) string { // Label is the title already cut to a row's width (session.taskLabel), and // the uncut title stands in for a row written before that field existed. words := entry.Label if words == "" { words = entry.Title } - return taskStatusGlyph(entry, pal) + " " + mentionMark(pal.ascii) + " " + programText(words, entry.Program) + lead := taskStatusGlyph(entry, pal) + " " + mentionMark(pal.ascii) + " " + badge := programBadge(entry.Program) + if !badge.known() || strings.TrimSpace(words) == "" { + return lead + words + } + room := overlayLabelRoom(lead+words+" "+badge.full, note, width) - ansi.StringWidth(lead) + spelling := programSpelling(badge, words, room, railTitleFloor) + if spelling == "" { + return lead + words + } + return lead + fit(words, room-programCells(spelling)) + " " + spelling } func mentionMark(ascii bool) string { From c63b4bfbb7f8181e65513a0c81cb2bf7df29c215 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:07:07 -0400 Subject: [PATCH 127/195] session, manual: a proposal leaving out the program the person named passes only after the model has read the bounce The bounce was once per message of the person's, and the mark was spent by whichever proposal took the lock first. "fix issues #31 and #32 with senior-dev" is two proposals in one reply, staged before the model reads either result, so the first was turned back and the second passed as though the bounce had been read: it went up with no `via` and its countdown admitted it to codeaf's own worker against the person's ask. A bounce withdrawn with a cut reply also kept its mark, so the next proposal passed unread. Now the mark carries the step that made it: every proposal without a `via` in that step is turned back, a proposal from a later step (after the model has read the bounce) passes as it is, and a withdrawn bounce takes its mark back. The programs and senior-dev pages say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 7 +- internal/manual/chat/senior-dev.md | 8 +- internal/session/delegate_asked.go | 95 ++++++++++--- internal/session/delegate_asked_test.go | 171 +++++++++++++++++++++--- internal/session/session.go | 12 +- internal/session/spawnfloor.go | 20 ++- internal/session/task.go | 4 +- 7 files changed, 261 insertions(+), 56 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index c038ce286..15f721066 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -69,10 +69,11 @@ 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: +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``. -The next proposal for the same message passes as it is, so "don't use senior-dev for -this" is kept too. +A proposal the model makes after reading that passes as it is, so "don't use senior-dev +for this" is kept too. **An ask for a program is never too small.** A one-file fix or a single command otherwise stays in the conversation, but "fix this file with senior-dev" goes to senior-dev. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 328804042..f28f3a053 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -32,12 +32,12 @@ 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 and tells it you named senior-dev. That holds even for a one-file fix, which -otherwise stays in the conversation. +back once, with any others in the same reply, and tells it you named senior-dev. That +holds even for a one-file fix, which otherwise stays in the conversation. **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 proposes it again as it was, and the -second proposal for the same message passes. +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 <brief>`** 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 diff --git a/internal/session/delegate_asked.go b/internal/session/delegate_asked.go index 95b37eaeb..1186c6c4d 100644 --- a/internal/session/delegate_asked.go +++ b/internal/session/delegate_asked.go @@ -12,10 +12,10 @@ package session // // - A proposal with no `via` is turned back ONCE for the message that named // a program, with a sentence saying which program was named and both ways -// to answer it. The next proposal for that message passes as it is, which -// is how "fix it with senior-dev" and "don't use senior-dev for this" both -// come out right: which of the two the person said is read by the model, -// from words code cannot weigh. +// to answer it. The next proposal the model makes after reading that +// passes as it is, which is how "fix it with senior-dev" and "don't use +// senior-dev for this" both come out right: which of the two the person +// said is read by the model, from words code cannot weigh. // - A proposal whose `via` is the program the person named is never too // small. The spawn floor (spawnfloor.go) keeps a one-file fix in the // conversation, and a person who typed "fix this file with senior-dev" has @@ -26,6 +26,7 @@ package session // bounce there would be a round trip that ends where it began. import ( + "context" "slices" "strings" ) @@ -48,31 +49,93 @@ func (a *Agent) mayHandToProgram() bool { return !a.config.InTask && chatRunEngine != nil } -// programAskBounce is the once-per-message refusal of a proposal that left -// out the program the person named, or "" when this proposal is not turned -// back. +// programAskBounce is the once-per-message refusal of a proposal that left out +// the program the person named, or nil when this proposal is not turned back. // // ONCE IS COUNTED PER MESSAGE OF THE PERSON'S. [Agent.personSeq] numbers what // they have typed, steering included, so a new message of theirs that names // the program again earns one more bounce, and a woken turn, which types -// nothing, inherits the count of the message it is still answering. The check -// and the mark are made under one lock, so a batch of proposals staged side by -// side cannot both be the first. -func (a *Agent) programAskBounce(spec taskSpec) string { +// nothing, inherits the count of the message it is still answering. +// +// AND A PROPOSAL PASSES ONLY ONCE THE MODEL HAS READ THE BOUNCE, which is never +// in the step that made it. A model reads a result in the request after the +// batch that returned it, and every proposal of one message is staged before +// any of their results is read: side by side in the batch, or while the +// message is still arriving. "fix issues #31 and #32 with senior-dev" is two +// proposals in one message, and the second used to pass as though the first +// one's bounce had been read, go up with no `via`, and be admitted to codeaf's +// own worker by its countdown. So the mark carries the step that made it +// ([Agent.stepSeq]), every proposal without a `via` in that step is turned back +// too, and it is a proposal from a later step that passes as it is. The check +// and the mark are made under one lock. +func (a *Agent) programAskBounce(spec taskSpec) *askBounce { if spec.via != "" || !a.mayHandToProgram() || len(a.config.Delegates) == 0 { - return "" + return nil } + step := a.stepSeq.Load() a.mu.Lock() defer a.mu.Unlock() - if a.personSeq == 0 || a.programBounced == a.personSeq { - return "" + if a.personSeq == 0 { + return nil + } + if a.programBounced.seq == a.personSeq && a.programBounced.step < step { + return nil } name := a.config.programNamedIn(a.personAsk) if name == "" { + return nil + } + bounce := &askBounce{agent: a, name: name, prior: a.programBounced, mark: bounceMark{seq: a.personSeq, step: step}} + a.programBounced = bounce.mark + return bounce +} + +// bounceMark is where a proposal was last turned back for leaving out the +// program the person named: the [Agent.personSeq] of the message that named +// it, and the [Agent.stepSeq] of the request whose proposals were turned back. +type bounceMark struct { + seq uint64 + step uint64 +} + +// askBounce is one proposal turned back by [Agent.programAskBounce], as the +// staged call it is (task.go's [Agent.stageTask]). It is its own [bare.Staged] +// rather than a settled refusal because it leaves something behind: the mark +// that lets the next proposal through. +type askBounce struct { + agent *Agent + name string + // mark is what this bounce wrote on [Agent.programBounced], and prior is + // what was there before it. + mark, prior bounceMark +} + +// Commit hands the bounce over as the call's result. +func (b *askBounce) Commit(context.Context) (string, bool, error) { + return programNamedSentence(b.name), true, nil +} + +// Withdraw takes the mark back. A call withdrawn before it went ahead is one +// the model never reads — the reply carrying it was cut, or the turn ended — +// so the bounce it carried was never read either, and the next proposal for +// the message has to be turned back in its place. A sibling from the same +// step wrote the same mark over this one, so the mark is put back only while +// it is still this bounce's own, and the siblings withdrawn in any order leave +// what was there before the first of them. +func (b *askBounce) Withdraw() { + b.agent.mu.Lock() + defer b.agent.mu.Unlock() + if b.agent.programBounced == b.mark { + b.agent.programBounced = b.prior + } +} + +// text is the sentence the bounce hands over, and "" for no bounce. +func (b *askBounce) text() string { + if b == nil { return "" } - a.programBounced = a.personSeq - return programNamedSentence(name) + return programNamedSentence(b.name) } // askedForProgram says `via` names a program this build carries and the diff --git a/internal/session/delegate_asked_test.go b/internal/session/delegate_asked_test.go index 01bee1893..4243243b5 100644 --- a/internal/session/delegate_asked_test.go +++ b/internal/session/delegate_asked_test.go @@ -3,9 +3,12 @@ package session import ( "context" "encoding/json" + "strconv" "strings" "testing" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/manual" ) @@ -48,9 +51,9 @@ func proposeOutputs(events []Event) []string { // A PROPOSAL THAT LEAVES OUT THE PROGRAM THE PERSON NAMED IS TURNED BACK ONCE. // The person asked for senior-dev and the model proposed the work for its own // worker, which is the proposal a model writes by habit: it is told who was -// named and both ways to answer. The next proposal for the same message -// passes as it is, because "don't use senior-dev" is an ask too, and only the -// model can read which one it was. +// named and both ways to answer. The next proposal for the same message, made +// after the model has read that, passes as it is, because "don't use +// senior-dev" is an ask too, and only the model can read which one it was. func TestAProposalLeavingOutTheProgramThePersonNamedIsTurnedBackOnce(t *testing.T) { registerBeltRunEngine(t, newBeltRunDouble("unused")) completer := &routedCompleter{parent: []step{ @@ -87,6 +90,72 @@ func TestAProposalLeavingOutTheProgramThePersonNamedIsTurnedBackOnce(t *testing. } } +// proposalsInOneMessage is the model sending several proposals in one message, +// which is how the hand-off page tells it to work in parallel. Each call has an +// id of its own, as a provider's calls in one message do. +func proposalsInOneMessage(titles ...string) step { + return func(context.Context, []ai.Message) (*ai.Response, error) { + calls := make([]ai.ToolCall, len(titles)) + for index, title := range titles { + arguments, _ := json.Marshal(taskArguments{ + Title: title, + Summary: "two lines the person reads", + Brief: title + "\n" + taskBriefMark, + Deliverable: "the fix, on the branch the run leaves", + Acceptance: "the issue's own reproduction passes", + }) + calls[index] = ai.ToolCall{ + ID: "call-task-" + strconv.Itoa(index), + Type: "function", + Function: ai.ToolCallFunction{Name: "propose_task", Arguments: string(arguments)}, + } + } + return callsResponse(calls...), nil + } +} + +// EVERY PROPOSAL OF ONE MESSAGE IS TURNED BACK. "fix issues #31 and #32 with +// senior-dev" is two proposals in one message, sent together before the model +// has read either result. Only the first used to be turned back: the second +// passed as though the model had read the bounce, went up with no `via`, and +// its countdown admitted it to codeaf's own worker against the person's ask. +// Both are turned back, and the proposals of the message the model writes +// after reading them pass as they are. +func TestEveryProposalOfOneMessageIsTurnedBack(t *testing.T) { + registerBeltRunEngine(t, newBeltRunDouble("unused")) + completer := &routedCompleter{parent: []step{ + proposalsInOneMessage("Fix issue #31", "Fix issue #32"), + proposalsInOneMessage("Fix issue #31", "Fix issue #32"), + finalText("started"), + }} + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.Delegates = testPrograms("senior-dev") + }) + nodes := make(ranNodes, 4) + graph := stubbedGraph(agent, func(node *TaskNode) { nodes <- node }) + + outputs := proposeOutputs(collect(t, mustSubmit(t, agent, "fix issues #31 and #32 with senior-dev"))) + + if len(outputs) != 4 { + t.Fatalf("want four proposal results, got %d: %q", len(outputs), outputs) + } + for _, output := range outputs[:2] { + if output != programNamedSentence("senior-dev") { + t.Fatalf("a proposal sent beside the one turned back read %q, want the bounce too: %q", output, outputs[:2]) + } + } + for _, output := range outputs[2:] { + if !strings.HasPrefix(output, "task ") { + t.Fatalf("a proposal written after the bounce was read was not let through: %q", output) + } + } + nodes.await(t) + nodes.await(t) + if admitted(graph) != 2 { + t.Fatalf("the graph admitted %d nodes, want the two proposals of the second message", admitted(graph)) + } +} + // THE NAME IS HEARD HOWEVER A PERSON TYPES IT: as its command, in any case, // with a space or nothing where it has a hyphen. And a word that only shares // part of it is not the name. @@ -118,7 +187,7 @@ func TestTheProgramIsHeardHoweverThePersonSpellsIt(t *testing.T) { for _, asked := range []string{"/senior-dev fix the retry", "fix the retry with senior dev", "Senior-Dev, fix the retry"} { agent := programConversation(t, nil) heard(agent, asked) - if bounce := agent.programAskBounce(taskSpec{title: "t"}); bounce != programNamedSentence("senior-dev") { + if bounce := agent.programAskBounce(taskSpec{title: "t"}).text(); bounce != programNamedSentence("senior-dev") { t.Errorf("%q: the proposal without via read %q, want the bounce", asked, bounce) } } @@ -132,55 +201,116 @@ func TestTheProgramIsHeardHoweverThePersonSpellsIt(t *testing.T) { func TestNoBounceWhereNothingWasNamedOrNoProgramCouldBe(t *testing.T) { plain := programConversation(t, nil) heard(plain, "the scheduler drops retries under load; fix it") - if bounce := plain.programAskBounce(taskSpec{}); bounce != "" { + if bounce := plain.programAskBounce(taskSpec{}).text(); bounce != "" { t.Fatalf("a message naming no program was bounced: %q", bounce) } named := "the scheduler drops retries under load; fix it with senior-dev" inTask := programConversation(t, func(config *Config) { config.InTask = true }) heard(inTask, named) - if bounce := inTask.programAskBounce(taskSpec{}); bounce != "" { + if bounce := inTask.programAskBounce(taskSpec{}).text(); bounce != "" { t.Fatalf("a task node was bounced toward a program it cannot name: %q", bounce) } noPrograms := programConversation(t, func(config *Config) { config.Delegates = nil }) heard(noPrograms, named) - if bounce := noPrograms.programAskBounce(taskSpec{}); bounce != "" { + if bounce := noPrograms.programAskBounce(taskSpec{}).text(); bounce != "" { t.Fatalf("a build carrying no program was bounced: %q", bounce) } withVia := programConversation(t, nil) heard(withVia, named) - if bounce := withVia.programAskBounce(taskSpec{via: "senior-dev"}); bounce != "" { + if bounce := withVia.programAskBounce(taskSpec{via: "senior-dev"}).text(); bounce != "" { t.Fatalf("a proposal naming the program was bounced: %q", bounce) } registerBeltRunEngine(t, nil) noRoad, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.Delegates = testPrograms("senior-dev") }) heard(noRoad, named) - if bounce := noRoad.programAskBounce(taskSpec{}); bounce != "" { + if bounce := noRoad.programAskBounce(taskSpec{}).text(); bounce != "" { t.Fatalf("a build with no run road was bounced: %q", bounce) } } +// modelReadTheResults is the turn sending its next request, which is the one +// moment a model reads what its last batch returned ([episode.decisionBegins]). +func modelReadTheResults(agent *Agent) { agent.stepSeq.Add(1) } + +// refusedWith is what a proposal of spec reads back from the door refusals, +// and "" when none of them turns it around. +func refusedWith(agent *Agent, spec taskSpec) string { + refusal := agent.refuseProposedTask(spec) + if refusal == nil { + return "" + } + text, _, _ := refusal.Commit(context.Background()) + return text +} + // ONCE IS PER MESSAGE. A second message of the person's that names the program -// again is a new ask and earns its own bounce; a second proposal for the same -// message does not. +// again is a new ask and earns its own bounce; a proposal for the same message +// made after the model has read the bounce does not. func TestTheBounceIsOncePerMessageOfThePersons(t *testing.T) { agent := programConversation(t, nil) heard(agent, "fix the flaky retry with senior-dev") - if agent.programAskBounce(taskSpec{}) == "" { + if agent.programAskBounce(taskSpec{}) == nil { t.Fatal("the first proposal for the message was not bounced") } - if bounce := agent.programAskBounce(taskSpec{}); bounce != "" { - t.Fatalf("the second proposal for the same message was bounced again: %q", bounce) + modelReadTheResults(agent) + if bounce := agent.programAskBounce(taskSpec{}).text(); bounce != "" { + t.Fatalf("the proposal made after the bounce was read was bounced again: %q", bounce) } heard(agent, "no really, give it to senior-dev") - if agent.programAskBounce(taskSpec{}) == "" { + if agent.programAskBounce(taskSpec{}) == nil { t.Fatal("a new message naming the program again was not bounced") } } +// AND NOT BEFORE THE MODEL HAS READ IT. Every proposal of one message is +// staged before any of their results is read, so a second proposal from the +// same step is turned back beside the first rather than passing as though the +// bounce had been read. And a bounce withdrawn before it went ahead (the reply +// carrying it was cut) was never read at all: it takes its mark back, and the +// next proposal is turned back in its place. +func TestTheBounceIsNotSpentUntilTheModelHasReadIt(t *testing.T) { + agent := programConversation(t, nil) + heard(agent, "fix issues #31 and #32 with senior-dev") + first, second := agent.programAskBounce(taskSpec{}), agent.programAskBounce(taskSpec{}) + if first == nil || second == nil { + t.Fatalf("two proposals of one step were not both bounced: %q, %q", first.text(), second.text()) + } + + first.Withdraw() + second.Withdraw() + modelReadTheResults(agent) + if agent.programAskBounce(taskSpec{}) == nil { + t.Fatal("the proposal after a withdrawn bounce passed, and the model never read that bounce") + } + + withdrawnLate := programConversation(t, nil) + heard(withdrawnLate, "fix issues #31 and #32 with senior-dev") + first, second = withdrawnLate.programAskBounce(taskSpec{}), withdrawnLate.programAskBounce(taskSpec{}) + second.Withdraw() + first.Withdraw() + modelReadTheResults(withdrawnLate) + if withdrawnLate.programAskBounce(taskSpec{}) == nil { + t.Fatal("siblings withdrawn in the other order left a mark behind") + } + + arguments, _ := json.Marshal(taskArguments{Title: "t", Summary: "s", Brief: "b", Deliverable: "d", Acceptance: "a"}) + throughTheDoor := programConversation(t, nil) + heard(throughTheDoor, "fix issues #31 and #32 with senior-dev") + hold := bare.NewHold() + hold.Withdraw() + if _, _, err := throughTheDoor.proposeTask(bare.WithHold(context.Background(), hold), arguments); err != nil { + t.Fatalf("the withdrawn proposal errored the turn: %v", err) + } + modelReadTheResults(throughTheDoor) + if bounce := refusedWith(throughTheDoor, taskSpec{}); bounce != programNamedSentence("senior-dev") { + t.Fatalf("after a proposal withdrawn from the door, the next one read %q, want the bounce", bounce) + } +} + // AN ASK FOR A PROGRAM IS NEVER TOO SMALL. "fix this file with senior-dev" is // on the spawn floor as a one-file fix, and the floor ran before `via` was // read, so the person who asked for senior-dev by name was refused with "do it @@ -194,22 +324,23 @@ func TestAnAskForAProgramIsNeverTooSmall(t *testing.T) { } agent := programConversation(t, nil) heard(agent, asked) - if refusal := agent.refuseProposedTask(taskSpec{via: "senior-dev"}); refusal != "" { + if refusal := refusedWith(agent, taskSpec{via: "senior-dev"}); refusal != "" { t.Fatalf("the proposal naming the program the person asked for was refused: %q", refusal) } - if refusal := agent.refuseProposedTask(taskSpec{}); refusal != programNamedSentence("senior-dev") { + if refusal := refusedWith(agent, taskSpec{}); refusal != programNamedSentence("senior-dev") { t.Fatalf("the proposal leaving the program out read %q, want the bounce", refusal) } - if refusal := agent.refuseProposedTask(taskSpec{}); refusal != spawnFloorRefusal { + modelReadTheResults(agent) + if refusal := refusedWith(agent, taskSpec{}); refusal != spawnFloorRefusal { t.Fatalf("the second proposal leaving the program out read %q, want the floor", refusal) } unasked := programConversation(t, nil) heard(unasked, "fix this file") - if refusal := unasked.refuseProposedTask(taskSpec{via: "senior-dev"}); refusal != spawnFloorRefusal { + if refusal := refusedWith(unasked, taskSpec{via: "senior-dev"}); refusal != spawnFloorRefusal { t.Fatalf("a program nobody asked for lifted the floor: %q", refusal) } - if refusal := unasked.refuseProposedTask(taskSpec{via: "nosuch"}); refusal != spawnFloorRefusal { + if refusal := refusedWith(unasked, taskSpec{via: "nosuch"}); refusal != spawnFloorRefusal { t.Fatalf("a program this build does not carry lifted the floor: %q", refusal) } } diff --git a/internal/session/session.go b/internal/session/session.go index d9342d364..ed26ba651 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2645,11 +2645,13 @@ type Agent struct { // person is not currently saying under their live authority // (task_forward.go). personHeard uint64 - // programBounced is the [Agent.personSeq] of the person's message a - // proposal was last turned back for, because that message named a program - // and the proposal did not (delegate_asked.go). It is what makes the bounce - // once per message: the next proposal for the same message passes as it is. - programBounced uint64 + // programBounced is where a proposal was last turned back because the + // person's message named a program and the proposal did not + // (delegate_asked.go): that message, and the step of the turn whose + // proposals were turned back. It is what makes the bounce once per message: + // a proposal for the same message from a later step, after the model has + // read the bounce, passes as it is. + programBounced bounceMark // callOutcomes is whether a finished call came back a failure, by call occurrence // (admission_compile.go). It is recorded at the batch's own fan-out because // the flag the tool returned does not survive into the transcript, and it is diff --git a/internal/session/spawnfloor.go b/internal/session/spawnfloor.go index e6eb045d8..d8cde07c7 100644 --- a/internal/session/spawnfloor.go +++ b/internal/session/spawnfloor.go @@ -29,7 +29,11 @@ package session // commit is how the commit is lost. The matcher is a closed set and nothing // else. -import "strings" +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/exec/bare" +) // spawnFloorRefusal is what propose_task reads back when the person's words // are a trivial ask. It names the fix the model can act on: do the command @@ -48,13 +52,17 @@ const spawnFloorRefusal = "this ask is one command — do it here. A commit, an // to name it, and the one that names it is not refused for being small // (delegate_asked.go). A proposal naming a program the person did not ask // for meets the floor as any proposal does. -func (a *Agent) refuseProposedTask(spec taskSpec) string { - if bounce := a.programAskBounce(spec); bounce != "" { +// +// IT ANSWERS A STAGED CALL, nil for none, because the bounce is not a settled +// refusal: it marks the message it was made for, and a call withdrawn before +// it went ahead takes that mark back ([askBounce.Withdraw]). +func (a *Agent) refuseProposedTask(spec taskSpec) bare.Staged { + if bounce := a.programAskBounce(spec); bounce != nil { return bounce } if !a.config.InTask { if asked := a.taskRequest(); trivialAsk(asked) && !a.config.askedForProgram(asked, spec.via) { - return spawnFloorRefusal + return bare.Settled(spawnFloorRefusal, true) } } if missing, failed := a.graph().doomedDependencies(spec.dependsOn); len(missing)+len(failed) > 0 { @@ -62,10 +70,10 @@ func (a *Agent) refuseProposedTask(spec taskSpec) string { missing = a.missingRunDependencies(missing) } if len(missing)+len(failed) > 0 { - return dependencyRefusal(missing, failed) + return bare.Settled(dependencyRefusal(missing, failed), true) } } - return "" + return nil } // spawnFloorWide is the words that mean the ask has MORE THAN ONE piece of diff --git a/internal/session/task.go b/internal/session/task.go index c97875d92..6029a04e4 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -653,8 +653,8 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged // resolve are all "do not start this"; they live in one helper so this // road does not grow another ending (complexity_test.go's ratchet on this // function). - if refusal := a.refuseProposedTask(spec); refusal != "" { - return bare.Settled(refusal, true) + if refusal := a.refuseProposedTask(spec); refusal != nil { + return refusal } // A DELEGATE IS RESOLVED BEFORE THE CARD, so a name this machine has no // delegate for is answered with the names it has and nobody is asked to From cecfb5f09bd008e8893011d185e605393da617ba Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:10:33 -0400 Subject: [PATCH 128/195] session, manual: a correction typed into the turn does not unsay the program the person named The bounce and the floor's lift read only the newest thing the person typed, and a steer is a new message. "fix the dropped-retries issue with senior-dev" followed by "the failing test is TestRetryUnderLoad" left nothing that named senior-dev, so a proposal without `via` went to codeaf's own worker, and a trivial steer such as "fix this file only" held senior-dev on the floor. Now every message typed into a turn is read for the programs this build carries, where the person's words are recorded: the newest one named, the message that named it, and every one named. The bounce is counted against the message that named the program, so a steer naming nothing earns no second one; the floor lifts for any program named in the turn; a woken turn keeps the reading it was woken under; and the next message that opens a turn starts it again. The programs page has a section of its own for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 18 +++-- internal/manual/chat/senior-dev.md | 3 +- internal/manual/chat_test.go | 2 + internal/session/delegate_asked.go | 89 ++++++++++++++++++++----- internal/session/delegate_asked_test.go | 58 ++++++++++++++++ internal/session/session.go | 6 ++ internal/session/spawnfloor.go | 2 +- internal/session/task_brief.go | 5 ++ 8 files changed, 160 insertions(+), 23 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 15f721066..e57cd1b99 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -73,15 +73,25 @@ anyway, codeaf turns that proposal back once, along with every other proposal wi 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. - -**An ask for a program is never too small.** A one-file fix or a single command otherwise -stays in the conversation, but "fix this file with senior-dev" goes to 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. `/<name> <brief>` starts the program at once, with no card. +## Naming a program in a small ask or a correction — fix this file with senior-dev, 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 command otherwise +stays in the conversation, but "fix this file with senior-dev" goes to senior-dev. + +**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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index f28f3a053..87d55252a 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -33,7 +33,8 @@ countdown. A change you would make in a few steps it still makes itself. 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, which otherwise stays in the conversation. +holds even for a one-file fix, which otherwise stays in the conversation, and through a +correction you type while it works that does not name senior-dev again. **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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index bb09c5147..dd61a2497 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -956,6 +956,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"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 diff --git a/internal/session/delegate_asked.go b/internal/session/delegate_asked.go index 1186c6c4d..31e09e5e1 100644 --- a/internal/session/delegate_asked.go +++ b/internal/session/delegate_asked.go @@ -21,6 +21,10 @@ package session // conversation, and a person who typed "fix this file with senior-dev" has // overruled it already, exactly as a person who typed `/task` has. // +// AND THE PERSON'S WORDS ARE EVERY MESSAGE OF THE TURN, not the newest one +// ([programsHeard]): a steer typed while the model reads the code names +// nothing, and it does not unsay the program the turn opened by asking for. +// // ONLY WHERE A `via` COULD BE HONOURED. Inside a task, with no run road, or // with no program carried, a `via` is refused anyway ([Agent.stageTask]), and a // bounce there would be a round trip that ends where it began. @@ -52,10 +56,13 @@ func (a *Agent) mayHandToProgram() bool { // programAskBounce is the once-per-message refusal of a proposal that left out // the program the person named, or nil when this proposal is not turned back. // -// ONCE IS COUNTED PER MESSAGE OF THE PERSON'S. [Agent.personSeq] numbers what -// they have typed, steering included, so a new message of theirs that names -// the program again earns one more bounce, and a woken turn, which types -// nothing, inherits the count of the message it is still answering. +// ONCE IS COUNTED PER MESSAGE THAT NAMED THE PROGRAM. [Agent.personSeq] +// numbers what the person has typed, steering included, and the bounce is +// counted against the newest message of the turn that named one +// ([programsHeard]): a new message of theirs that names the program again +// earns one more bounce, a steer that names nothing earns none and does not +// unsay the one before it, and a woken turn, which types nothing, inherits the +// count of the message it is still answering. // // AND A PROPOSAL PASSES ONLY ONCE THE MODEL HAS READ THE BOUNCE, which is never // in the step that made it. A model reads a result in the request after the @@ -75,17 +82,14 @@ func (a *Agent) programAskBounce(spec taskSpec) *askBounce { step := a.stepSeq.Load() a.mu.Lock() defer a.mu.Unlock() - if a.personSeq == 0 { - return nil - } - if a.programBounced.seq == a.personSeq && a.programBounced.step < step { + heard := a.programsHeard + if heard.named == "" { return nil } - name := a.config.programNamedIn(a.personAsk) - if name == "" { + if a.programBounced.seq == heard.seq && a.programBounced.step < step { return nil } - bounce := &askBounce{agent: a, name: name, prior: a.programBounced, mark: bounceMark{seq: a.personSeq, step: step}} + bounce := &askBounce{agent: a, name: heard.named, prior: a.programBounced, mark: bounceMark{seq: heard.seq, step: step}} a.programBounced = bounce.mark return bounce } @@ -138,14 +142,65 @@ func (b *askBounce) text() string { return programNamedSentence(b.name) } -// askedForProgram says `via` names a program this build carries and the -// person's words named that same program, which is the one thing that lifts -// the spawn floor for a proposal ([Agent.refuseProposedTask]). -func (c Config) askedForProgram(asked, via string) bool { - if via == "" || !slices.Contains(c.delegateNames(), via) { +// askedForProgram says `via` names a program the person's words named in the +// turn they last spoke in, which is the one thing that lifts the spawn floor +// for a proposal ([Agent.refuseProposedTask]). A name this build does not +// carry is never heard ([Agent.hearProgramsLocked]), so it lifts nothing. +func (a *Agent) askedForProgram(via string) bool { + if via == "" { return false } - return namesProgram(normalizedWords(asked), via) + a.mu.Lock() + defer a.mu.Unlock() + return slices.Contains(a.programsHeard.asked, via) +} + +// programsHeard is what the person's messages of one turn said about the +// programs this build carries. +// +// A TURN'S ASK IS EVERY MESSAGE TYPED INTO IT, not the newest one. A person +// names senior-dev as the turn opens and steers with a detail while the model +// reads the code, which is exactly when a model that has forgotten the name +// proposes without `via`, and the steer names nothing. [Agent.personAsk] is +// that steer by then, and reading it alone sent the work to codeaf's own +// worker and held "fix this file only" on the spawn floor although the turn +// had asked for senior-dev. So this is kept from the message that opens a turn +// to the last one steered into it, and started again by the next message that +// opens a turn. A woken turn types nothing and leaves it where it was, so it +// still answers the ask it was woken under. +type programsHeard struct { + // turn is the [Agent.turnSeq] the messages were typed into. + turn uint64 + // named is the program the newest message that named one named, and seq is + // that message's [Agent.personSeq], which the bounce is counted against. + named string + seq uint64 + // asked is every program a message of the turn named, which is what lifts + // the floor for a proposal whose `via` is one of them. + asked []string +} + +// hearProgramsLocked reads one message of the person's for the programs this +// build carries, into [Agent.programsHeard]. [Agent.rememberAskLocked] is the +// one caller, after the message is numbered, so every message they type is +// read here exactly once and nothing the session wrote itself is. The caller +// holds a.mu. +func (a *Agent) hearProgramsLocked(text string) { + if len(a.config.Delegates) == 0 { + return + } + if a.programsHeard.turn != a.turnSeq { + a.programsHeard = programsHeard{turn: a.turnSeq} + } + words := normalizedWords(text) + for _, name := range a.config.delegateNames() { + if namesProgram(words, name) && !slices.Contains(a.programsHeard.asked, name) { + a.programsHeard.asked = append(a.programsHeard.asked, name) + } + } + if name := a.config.programNamedIn(text); name != "" { + a.programsHeard.named, a.programsHeard.seq = name, a.personSeq + } } // programNamedIn is the first program, by name, that the person's words name, diff --git a/internal/session/delegate_asked_test.go b/internal/session/delegate_asked_test.go index 4243243b5..4a71b144a 100644 --- a/internal/session/delegate_asked_test.go +++ b/internal/session/delegate_asked_test.go @@ -311,6 +311,64 @@ func TestTheBounceIsNotSpentUntilTheModelHasReadIt(t *testing.T) { } } +// heardInANewTurn records text as the message that opens a turn of its own, +// rather than one steered into the turn running ([Agent.startTurnLocked]). +func heardInANewTurn(agent *Agent, text string) { + agent.mu.Lock() + defer agent.mu.Unlock() + agent.turnSeq++ + agent.rememberAskLocked(userText(text)) +} + +// A STEER DOES NOT UNSAY THE PROGRAM. The person named senior-dev when the turn +// opened, then steered with a detail while the model read the code. The steer +// is the newest thing they typed and names no program, and it used to be all +// the bounce and the floor read: a proposal without `via` went to codeaf's own +// worker, and "fix this file only" steered after the ask held senior-dev on the +// floor. The ask stands for the rest of the turn it was typed into, and a woken +// turn still answering it; the steer earns no bounce of its own, because it +// named nothing new; and a message that opens a turn of its own is a new ask. +func TestASteerDoesNotUnsayTheProgramThePersonNamed(t *testing.T) { + named := "fix the dropped-retries issue with senior-dev" + steered := programConversation(t, nil) + heard(steered, named) + heard(steered, "the failing test is TestRetryUnderLoad") + if bounce := refusedWith(steered, taskSpec{}); bounce != programNamedSentence("senior-dev") { + t.Fatalf("after a steer naming nothing, the proposal without via read %q, want the bounce", bounce) + } + modelReadTheResults(steered) + heard(steered, "and keep the old retry budget") + if bounce := refusedWith(steered, taskSpec{}); bounce != "" { + t.Fatalf("a steer naming nothing new earned a second bounce: %q", bounce) + } + + floored := programConversation(t, nil) + heard(floored, named) + heard(floored, "fix this file only") + if !trivialAsk("fix this file only") { + t.Fatal("the steer is off the floor, so this would prove nothing") + } + if refusal := refusedWith(floored, taskSpec{via: "senior-dev"}); refusal != "" { + t.Fatalf("a trivial steer held the program the person asked for on the floor: %q", refusal) + } + + woken := programConversation(t, nil) + heard(woken, named) + woken.mu.Lock() + woken.turnSeq++ + woken.mu.Unlock() + if bounce := refusedWith(woken, taskSpec{}); bounce != programNamedSentence("senior-dev") { + t.Fatalf("a woken turn still answering the ask read %q, want the bounce", bounce) + } + + nextTurn := programConversation(t, nil) + heard(nextTurn, named) + heardInANewTurn(nextTurn, "now tidy the changelog") + if bounce := refusedWith(nextTurn, taskSpec{}); bounce != "" { + t.Fatalf("a message opening a turn of its own was read with the last turn's program: %q", bounce) + } +} + // AN ASK FOR A PROGRAM IS NEVER TOO SMALL. "fix this file with senior-dev" is // on the spawn floor as a one-file fix, and the floor ran before `via` was // read, so the person who asked for senior-dev by name was refused with "do it diff --git a/internal/session/session.go b/internal/session/session.go index ed26ba651..d7e112138 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2652,6 +2652,12 @@ type Agent struct { // a proposal for the same message from a later step, after the model has // read the bounce, passes as it is. programBounced bounceMark + // programsHeard is what the person's messages of the turn they last spoke + // in said about the programs this build carries: the newest one named, + // the message that named it, and every one named (delegate_asked.go). It is + // written where their words are recorded ([Agent.rememberAskLocked]), so a + // steer that names nothing does not unsay what the turn opened by asking. + programsHeard programsHeard // callOutcomes is whether a finished call came back a failure, by call occurrence // (admission_compile.go). It is recorded at the batch's own fan-out because // the flag the tool returned does not survive into the transcript, and it is diff --git a/internal/session/spawnfloor.go b/internal/session/spawnfloor.go index d8cde07c7..5b7f1f13b 100644 --- a/internal/session/spawnfloor.go +++ b/internal/session/spawnfloor.go @@ -61,7 +61,7 @@ func (a *Agent) refuseProposedTask(spec taskSpec) bare.Staged { return bounce } if !a.config.InTask { - if asked := a.taskRequest(); trivialAsk(asked) && !a.config.askedForProgram(asked, spec.via) { + if trivialAsk(a.taskRequest()) && !a.askedForProgram(spec.via) { return bare.Settled(spawnFloorRefusal, true) } } diff --git a/internal/session/task_brief.go b/internal/session/task_brief.go index 0ac730b99..977bdd8ce 100644 --- a/internal/session/task_brief.go +++ b/internal/session/task_brief.go @@ -1042,6 +1042,11 @@ func (a *Agent) rememberAskLocked(user userMessage) { // the transcript, where their words and the session's own notes are // both user-role. a.rememberPersonTurnLocked(user.message, text) + // AND WHICH PROGRAM THEY NAMED, read from every message of the turn + // rather than the newest, so a steer does not unsay it + // (delegate_asked.go). It is read after the line above has numbered the + // message, because the bounce is counted against that number. + a.hearProgramsLocked(text) // AND THE SESSION'S GOAL OWNER IS TOLD THE SAME THING, in the same // place, on the same test (principal.go). It is one writer rather than // two for the reason stated directly below: a second recorder of the From f605e81ccb3768b47e514b8bfb133ea43245b480 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:15:54 -0400 Subject: [PATCH 129/195] session, manual: a commit, undo or revert that names a program stays in the conversation, and only an ask for the program lifts the floor The floor lifted whenever the person's words contained the program's name. "revert senior-dev's commit" and "commit senior-dev's changes" are trivial asks that name senior-dev in passing, so a proposal with `via` passed the floor, and one without was first bounced toward senior-dev: a billed run on a branch of its own, which can never do the revert or the commit the person asked for. Now a commit, an undo or a revert is held on the floor whatever `via` says, before the bounce, since a program never moves the person's branch. For a one-file fix or a single read the floor lifts only where the words ask for the program: its command typed as a word of its own, its name first in the message, or its name right after a word that hands it the work (with, via, using, use, give, hand, to, have, let, ask, get, want), never as a possessive. A trivial ask that only mentions a program meets the floor without a bounce. The bounce itself still hears any mention, since the model can answer it. The programs, senior-dev and tasks pages say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 17 +++- internal/manual/chat/senior-dev.md | 7 +- internal/manual/chat/tasks.md | 8 +- internal/manual/chat_test.go | 1 + internal/session/delegate_asked.go | 128 +++++++++++++++++++++--- internal/session/delegate_asked_test.go | 81 +++++++++++++++ internal/session/session.go | 2 +- internal/session/spawnfloor.go | 78 +++++++++++---- 8 files changed, 277 insertions(+), 45 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index e57cd1b99..e2b145001 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -79,10 +79,19 @@ for this" is kept too. The next section says what else counts. looked like work, goes to codeaf's own worker and never to a program. A task never hands its work to a program. `/<name> <brief>` starts the program at once, with no card. -## Naming a program in a small ask or a correction — fix this file with senior-dev, 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 command otherwise -stays in the conversation, but "fix this file with senior-dev" goes to senior-dev. +## 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 <brief>` 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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 87d55252a..75d017389 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -33,8 +33,11 @@ countdown. A change you would make in a few steps it still makes itself. 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, which otherwise stays in the conversation, and through a -correction you type while it works that does not name senior-dev again. +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 diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index a41c463dc..e57030198 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -1202,9 +1202,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. Naming 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). +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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index dd61a2497..cc50df1de 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -958,6 +958,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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 diff --git a/internal/session/delegate_asked.go b/internal/session/delegate_asked.go index 31e09e5e1..7f69239d0 100644 --- a/internal/session/delegate_asked.go +++ b/internal/session/delegate_asked.go @@ -16,11 +16,19 @@ package session // passes as it is, which is how "fix it with senior-dev" and "don't use // senior-dev for this" both come out right: which of the two the person // said is read by the model, from words code cannot weigh. -// - A proposal whose `via` is the program the person named is never too +// - A proposal whose `via` is the program the person asked for is never too // small. The spawn floor (spawnfloor.go) keeps a one-file fix in the // conversation, and a person who typed "fix this file with senior-dev" has // overruled it already, exactly as a person who typed `/task` has. // +// THE TWO READ THE WORDS DIFFERENTLY, because they are wrong at different +// prices. A bounce the person did not mean costs one round trip, and the model +// that reads it can answer "did not mean the program", so it hears any mention +// of the name ([Config.programNamedIn]). A floor lifted that the person did not +// mean starts a billed run that cannot do what they asked, and nothing reads +// it again, so it hears only the words that ask for the program +// ([Config.programsAskedIn]), and never lifts a commit, an undo or a revert. +// // AND THE PERSON'S WORDS ARE EVERY MESSAGE OF THE TURN, not the newest one // ([programsHeard]): a steer typed while the model reads the code names // nothing, and it does not unsay the program the turn opened by asking for. @@ -142,16 +150,22 @@ func (b *askBounce) text() string { return programNamedSentence(b.name) } -// askedForProgram says `via` names a program the person's words named in the -// turn they last spoke in, which is the one thing that lifts the spawn floor -// for a proposal ([Agent.refuseProposedTask]). A name this build does not +// programMayLiftFloor says a proposal made on a trivial ask whose verb is verb +// may pass the spawn floor ([Agent.refuseProposedTask]): the verb is not work +// on the person's own branch ([yourBranchVerbs]), and the person asked for a +// program in the turn they last spoke in — for a `via`, that program. A +// proposal with no `via` may pass only as far as the bounce that asks it to +// name the program, and meets the floor after that. A name this build does not // carry is never heard ([Agent.hearProgramsLocked]), so it lifts nothing. -func (a *Agent) askedForProgram(via string) bool { - if via == "" { +func (a *Agent) programMayLiftFloor(verb, via string) bool { + if yourBranchVerbs[verb] { return false } a.mu.Lock() defer a.mu.Unlock() + if via == "" { + return len(a.programsHeard.asked) > 0 + } return slices.Contains(a.programsHeard.asked, via) } @@ -175,8 +189,9 @@ type programsHeard struct { // that message's [Agent.personSeq], which the bounce is counted against. named string seq uint64 - // asked is every program a message of the turn named, which is what lifts - // the floor for a proposal whose `via` is one of them. + // asked is every program a message of the turn asked to do the work + // ([Config.programsAskedIn]), which is what lifts the floor for a proposal + // whose `via` is one of them. asked []string } @@ -192,9 +207,8 @@ func (a *Agent) hearProgramsLocked(text string) { if a.programsHeard.turn != a.turnSeq { a.programsHeard = programsHeard{turn: a.turnSeq} } - words := normalizedWords(text) - for _, name := range a.config.delegateNames() { - if namesProgram(words, name) && !slices.Contains(a.programsHeard.asked, name) { + for _, name := range a.config.programsAskedIn(text) { + if !slices.Contains(a.programsHeard.asked, name) { a.programsHeard.asked = append(a.programsHeard.asked, name) } } @@ -222,14 +236,98 @@ func (c Config) programNamedIn(asked string) string { // the name is a run of whole words, or those words written as one. func namesProgram(words []string, name string) bool { parts := normalizedWords(name) - if len(parts) == 0 { - return false + for at := range words { + if nameWidthAt(words, at, parts) > 0 { + return true + } + } + return false +} + +// nameWidthAt is how many of the words the name (split as [normalizedWords] +// splits it) takes starting at words[at]: one when it is written as one word, +// all of its parts when it is spelled out, and zero when it is not there. +func nameWidthAt(words []string, at int, parts []string) int { + switch { + case len(parts) == 0: + return 0 + case words[at] == strings.Join(parts, ""): + return 1 + case slices.Equal(words[at:min(at+len(parts), len(words))], parts): + return len(parts) } - joined := strings.Join(parts, "") + return 0 +} + +// programAskWords are the words that, right before a program's name, hand it +// the work: "fix this file with senior-dev", "give it to senior-dev", "have +// senior-dev fix it". A word that only points at the program ("the senior-dev +// run", "what senior-dev did") is not one. +var programAskWords = map[string]bool{ + "with": true, + "via": true, + "using": true, + "use": true, + "give": true, + "hand": true, + "to": true, + "have": true, + "let": true, + "ask": true, + "get": true, + "want": true, +} + +// programsAskedIn is every program the person's words ask to do the work, +// which is stricter than naming it ([Config.programNamedIn]): the program's +// command typed as a word of its own (`/senior-dev`), or its name first in the +// message, as the one addressed, or right after one of [programAskWords]. A +// possessive is never an ask ("revert senior-dev's commit"), and neither is +// the name anywhere else ("undo what senior-dev did"). +func (c Config) programsAskedIn(text string) []string { + words := normalizedWords(text) + var asked []string + for _, name := range c.delegateNames() { + if typedAsCommand(text, name) || addressedByName(words, normalizedWords(name)) { + asked = append(asked, name) + } + } + return asked +} + +// addressedByName says the name stands in the words as the one asked to do +// the work: first in them, or right after a word that hands it the work, and +// not followed by the "s" a possessive leaves once its apostrophe is gone. +func addressedByName(words, parts []string) bool { for at := range words { - if words[at] == joined || slices.Equal(words[at:min(at+len(parts), len(words))], parts) { + width := nameWidthAt(words, at, parts) + if width == 0 || (at+width < len(words) && words[at+width] == "s") { + continue + } + if at == 0 || programAskWords[words[at-1]] { + return true + } + } + return false +} + +// typedAsCommand says the text holds the program's command, `/name`, as a word +// of its own. The same letters inside a path ("internal/senior-dev/main.go") +// are not one, so the slash must open a word and the name must end one, on the +// same reading of a name's bytes the brief's path guard uses ([pathByte]). +func typedAsCommand(text, name string) bool { + lower, command := strings.ToLower(text), "/"+name + for from := 0; from < len(lower); { + at := strings.Index(lower[from:], command) + if at < 0 { + return false + } + at += from + end := at + len(command) + if (at == 0 || !pathByte(lower[at-1])) && (end == len(lower) || !pathByte(lower[end])) { return true } + from = end } return false } diff --git a/internal/session/delegate_asked_test.go b/internal/session/delegate_asked_test.go index 4a71b144a..f4040303a 100644 --- a/internal/session/delegate_asked_test.go +++ b/internal/session/delegate_asked_test.go @@ -3,6 +3,7 @@ package session import ( "context" "encoding/json" + "slices" "strconv" "strings" "testing" @@ -403,6 +404,86 @@ func TestAnAskForAProgramIsNeverTooSmall(t *testing.T) { } } +// A COMMIT, AN UNDO OR A REVERT STAYS HERE, WHATEVER IT NAMES. "revert +// senior-dev's commit" is on the floor as a revert, and it names senior-dev, +// which used to lift the floor: the proposal without `via` was bounced toward +// senior-dev, and the one with it went up and started a billed run on a branch +// of its own, where the revert the person asked for can never land on their +// branch. And a name said in passing is no ask for the program: "fix +// senior-dev's typo in this file" is a one-file fix, and stays one without a +// bounce toward a program the floor would then refuse. +func TestAnAskThatOnlyMentionsAProgramStaysOnTheFloor(t *testing.T) { + for _, asked := range []string{ + "revert senior-dev's commit", + "commit senior-dev's changes", + "undo what senior-dev did", + "git revert the senior-dev commit", + "revert this commit with senior-dev", + "fix senior-dev's typo in this file", + "fix the line senior-dev changed in this file", + } { + if !trivialAsk(asked) { + t.Fatalf("%q is off the floor, so this test would prove nothing", asked) + } + for _, spec := range []taskSpec{{via: "senior-dev"}, {}} { + agent := programConversation(t, nil) + heard(agent, asked) + if refusal := refusedWith(agent, spec); refusal != spawnFloorRefusal { + t.Errorf("%q with via %q read %q, want the floor", asked, spec.via, refusal) + } + } + } +} + +// THE NAME LIFTS THE FLOOR WHERE IT ASKS FOR THE PROGRAM: typed as its command, +// first in the message, or right after a word that hands it the work. The +// same name as a possessive, or after a word that only points at it, is a +// mention, and the looser reading that turns a proposal back +// ([Config.programNamedIn]) still hears it. +func TestTheProgramIsAskedForOnlyWhereTheWordsAskForIt(t *testing.T) { + config := Config{Delegates: testPrograms("senior-dev")} + for _, asked := range []string{ + "fix this file with senior-dev", + "fix this one line using senior dev", + "fix this file, give it to senior-dev", + "fix this file via /senior-dev", + "edit this file, have seniordev do it", + "fix this file /senior-dev", + "senior-dev, fix this file", + "Senior Dev should take this one", + "let senior-dev do it", + "ask senior-dev to fix the retry", + "I want senior-dev on this", + } { + if got := config.programsAskedIn(asked); !slices.Equal(got, []string{"senior-dev"}) { + t.Errorf("%q asked for %q, want senior-dev", asked, got) + } + } + for _, asked := range []string{ + "revert senior-dev's commit", + "undo what senior-dev did", + "fix the line senior-dev changed in this file", + "senior-dev's branch broke the build", + "the senior-dev run left a typo", + "fix the typo in internal/senior-dev/main.go", + "fix this file", + } { + if got := config.programsAskedIn(asked); len(got) != 0 { + t.Errorf("%q asked for %q, want nothing", asked, got) + } + } + if config.programNamedIn("revert senior-dev's commit") != "senior-dev" { + t.Fatal("the reading that turns a proposal back no longer hears a mention") + } + for _, asked := range []string{"fix this file with senior-dev", "fix this file /senior-dev"} { + agent := programConversation(t, nil) + heard(agent, asked) + if refusal := refusedWith(agent, taskSpec{via: "senior-dev"}); refusal != "" { + t.Errorf("%q: the proposal naming the program asked for was refused: %q", asked, refusal) + } + } +} + // THE PROGRAMS PAGE QUOTES THE BOUNCE AS THE MODEL READS IT, so a person // asking why their proposal came back, and the chat answering from the page, // both read the sentence that was actually sent. diff --git a/internal/session/session.go b/internal/session/session.go index d7e112138..d6ba00ee5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2654,7 +2654,7 @@ type Agent struct { programBounced bounceMark // programsHeard is what the person's messages of the turn they last spoke // in said about the programs this build carries: the newest one named, - // the message that named it, and every one named (delegate_asked.go). It is + // the message that named it, and every one asked for (delegate_asked.go). It is // written where their words are recorded ([Agent.rememberAskLocked]), so a // steer that names nothing does not unsay what the turn opened by asking. programsHeard programsHeard diff --git a/internal/session/spawnfloor.go b/internal/session/spawnfloor.go index 5b7f1f13b..727dc4929 100644 --- a/internal/session/spawnfloor.go +++ b/internal/session/spawnfloor.go @@ -41,29 +41,44 @@ import ( const spawnFloorRefusal = "this ask is one command — do it here. A commit, an undo, a one-file edit or a single read stays in the conversation; handing it to a task is how the work gets dropped." // refuseProposedTask is every check that can turn a propose_task call around -// BEFORE a card is raised or a slot is taken: a proposal that left out the -// program the person named, the spawn floor, then a depends_on that can never -// resolve. They used to live as endings of [Agent.proposeTask]; they are here -// so that road does not grow (the complexity ratchet holds it at 16). +// BEFORE a card is raised or a slot is taken: the spawn floor where no program +// could lift it, a proposal that left out the program the person named, the +// floor again for a proposal that still leaves it out, then a depends_on that +// can never resolve. They used to live as endings of [Agent.proposeTask]; they +// are here so that road does not grow (the complexity ratchet holds it at 16). // -// THE PROGRAM THE PERSON NAMED COMES FIRST, and it lifts the floor. "fix this -// file with senior-dev" is a one-file fix on the floor's reading and an ask -// for a program on the person's: the proposal without `via` is turned back -// to name it, and the one that names it is not refused for being small +// THE PROGRAM THE PERSON ASKED FOR LIFTS THE FLOOR. "fix this file with +// senior-dev" is a one-file fix on the floor's reading and an ask for a +// program on the person's: the proposal without `via` is turned back to name +// it, and the one that names it is not refused for being small // (delegate_asked.go). A proposal naming a program the person did not ask // for meets the floor as any proposal does. // +// AND THE FLOOR COMES FIRST WHERE NOTHING COULD LIFT IT, so nothing there is +// pushed toward a program the floor would then refuse. A commit, an undo or a +// revert is never lifted: a program works on a branch of its own and never +// moves the person's, so "revert senior-dev's commit" handed to senior-dev is +// a billed run that cannot do the revert, which is F26 again. And a name said +// in passing ("fix senior-dev's typo in this file") asks nothing of the +// program, so only the words that ask for it lift anything +// ([Config.programsAskedIn]). +// // IT ANSWERS A STAGED CALL, nil for none, because the bounce is not a settled // refusal: it marks the message it was made for, and a call withdrawn before // it went ahead takes that mark back ([askBounce.Withdraw]). func (a *Agent) refuseProposedTask(spec taskSpec) bare.Staged { + verb := "" + if !a.config.InTask { + verb = trivialVerb(a.taskRequest()) + } + if verb != "" && !a.programMayLiftFloor(verb, spec.via) { + return bare.Settled(spawnFloorRefusal, true) + } if bounce := a.programAskBounce(spec); bounce != nil { return bounce } - if !a.config.InTask { - if trivialAsk(a.taskRequest()) && !a.askedForProgram(spec.via) { - return bare.Settled(spawnFloorRefusal, true) - } + if verb != "" && spec.via == "" { + return bare.Settled(spawnFloorRefusal, true) } if missing, failed := a.graph().doomedDependencies(spec.dependsOn); len(missing)+len(failed) > 0 { if bashBeltAsked() { @@ -76,6 +91,16 @@ func (a *Agent) refuseProposedTask(spec taskSpec) bare.Staged { return nil } +// yourBranchVerbs are the trivial asks that are work on the person's own +// branch: a commit, an undo, a revert. Naming a program never lifts the floor +// for them ([Agent.programMayLiftFloor]), because a program works on a branch +// of its own and never moves the person's, so it could not do them at all. +var yourBranchVerbs = map[string]bool{ + "commit": true, + "undo": true, + "revert": true, +} + // spawnFloorWide is the words that mean the ask has MORE THAN ONE piece of // work in it. An "and" or a sweep lifts the floor: "commit everything and // rewrite the tests" is two jobs, and the other gates may still convert it. @@ -96,9 +121,17 @@ var spawnFloorWide = map[string]bool{ // staged five files is still a commit, and converting it is how F26 lost // the commit. Breadth of what the turn has touched does not lift the floor. func trivialAsk(asked string) bool { + return trivialVerb(asked) != "" +} + +// trivialVerb is the verb that puts the person's words on the floor, with any +// "git" in front of it dropped, or "" when they are not a trivial ask. It is +// [trivialAsk]'s one reading, and the verb is kept because what can lift the +// floor depends on it ([yourBranchVerbs]). +func trivialVerb(asked string) string { words := dropAskLeadIn(normalizedWords(asked)) if len(words) == 0 { - return false + return "" } // An explicit ask for a task lifts the floor. "as a task", "make this a // task", "spin it off", "hand it to a task" is the person OVER RULING the @@ -106,24 +139,29 @@ func trivialAsk(asked string) bool { // the one-file bug" ran inline and "continue task N" had no task to // continue (R1). if wantsTask(normalizedWords(asked)) { - return false + return "" } if hasWideSignal(words) { - return false + return "" } head, rest := words[0], words[1:] if head == "git" && len(rest) > 0 { head, rest = rest[0], rest[1:] } + if yourBranchVerbs[head] { + return head + } switch head { - case "commit", "undo", "revert": - return true case "read": - return isSingleRead(rest) + if isSingleRead(rest) { + return head + } case "fix", "edit", "change", "patch": - return isOneFileOrLineEdit(words) + if isOneFileOrLineEdit(words) { + return head + } } - return false + return "" } // dropAskLeadIn strips the politeness a person puts in front of a command From a12f60750dbe951de21ec838a2630eea2dc43417 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:33:00 -0400 Subject: [PATCH 130/195] session, manual: a program's folder is read again before codeaf says the person's branch is safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeaf's two switches in a program's folder ran with the repository's hooks, and a post-checkout hook that failed made `git switch -c` exit non-zero after HEAD had moved: the refusal said nothing was changed while the person's checkout sat on an orphan branch, and a switch back that git reported badly left the empty branch behind under a sentence saying it could not go back. A lock git could not take after it made the branch left a stray `task/…` branch. The ending said "your branch X is as it was" without reading it, so commits a program's shell put there were reported as nothing, and a run that changed nothing switched the checkout onto them. And the finishing commit named the notes folder in an exclude pathspec, which makes `git add` exit 1 whenever `.senior-dev/` is there and ignored, so every repository with notes from an earlier run failed its finishing commit and kept its empty branch. Now both switches run with core.hooksPath at the null device, and a failed switch is read again: a cut that happened is carried on with, a stray branch is deleted, and a switch back that arrived goes on to drop the empty branch. The person's branch is read before the receipt and the ending promise anything, and one that moved is said to have, from where to where; a run that changed nothing stays on its empty branch rather than switch onto it. The leftovers are staged whole and the notes' path reset out of the index, and a merge the program's shell left half done is never committed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 3 +- internal/manual/chat/senior-dev.md | 23 +- internal/manual/chat_test.go | 1 + internal/session/delegate_door.go | 13 +- internal/session/programfolder.go | 211 ++++++++++++++--- internal/session/programfolder_git_test.go | 262 +++++++++++++++++++++ 6 files changed, 470 insertions(+), 43 deletions(-) create mode 100644 internal/session/programfolder_git_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index e2b145001..2470dbfbb 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -185,7 +185,8 @@ your folder and checks it out, and the program works there; its own commits (sen `wip(edit): …`) stay on that branch, and nothing squashes them. When it ends, however 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. The task's page +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 <branch> in <folder>, N files, and that branch is checked out there; your branch <yours> is as it was: `git -C '<folder>' switch <yours>` goes back to it, and `git -C '<folder>' merge <branch>` from there brings diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 75d017389..0e52ecf95 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -257,27 +257,38 @@ project's build and tests (`senior-dev observed: …`). Read the second for "did **A run you stop keeps its work the same way**: the stop says `its work so far stays on its branch <branch>, checked out in <folder>` at once, and the page then says where it is -in the words above. +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 (<folder> 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 <folder> is back on your branch <yours> and its branch <branch> was deleted`. -## Does senior-dev change my branch — your branch never moves, going back, a HEAD it moved +## 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 the run never writes to it, resets it or +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 '<folder>' switch <yours>` goes back, and the page names the exact command. From no branch it -names `git -C '<folder>' switch --detach <commit>`. +names `git -C '<folder>' switch --detach <commit>`. 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 <folder> on the branch <other> instead of its own branch <branch>, so codeaf changed nothing there: nothing was committed and nothing was -switched; <branch> holds N files` (or `on no branch, at <commit>`). Look at that branch -before you commit anything there. +switched; <branch> holds N files` (or `on no branch, at <commit>`). + +**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 <yours> moved during the run, from <commit> +to <commit>, 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 <yours> moved during the run, from <commit> to <commit>, so codeaf did not switch +back to it: its empty branch <branch> is still checked out in <folder>`. A branch deleted +meanwhile reads `your branch <yours> is gone: it was at <commit> 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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index cc50df1de..ef6acb841 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -967,6 +967,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"can I run senior-dev in a folder that is not a git repo", "senior-dev"}, diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 57d485c2d..6a4f8d7eb 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -197,8 +197,19 @@ func delegateReceipt(ground string, via delegate.Delegate, record *TaskCopyRecor return "It is " + via.Name + "'s: it works alone in " + ground + " itself, which has no git history, so its changes are there as it makes them." } folder := ProgramFolder{Home: record.Home, Start: record.HomeSha} + stays := folder.homeWords() + " does not move" + // THE PROMISE IS READ BEFORE IT IS MADE ([ProgramFolder.homeMoved]): a + // branch of the person's that has already moved from where the run was + // cut is said to have, rather than promised to stay. One that cannot be + // read at all, or a record that never wrote down where it stood, is a + // branch this receipt cannot hold up against anything, and keeps the + // promise the run itself keeps. + if tip := branchCommit(ground, record.Home); tip != "" && record.HomeSha != "" && tip != record.HomeSha { + stays = "your branch " + record.Home + " has already moved, from " + shortSha(record.HomeSha) + " to " + shortSha(tip) + + ", and codeaf does not move it" + } return "It is " + via.Name + "'s: it works alone in " + ground + " itself, on a new branch " + record.Branch + "; " + - folder.homeWords() + " does not move, and when it ends " + record.Branch + " stays checked out there with its work." + stays + ", and when it ends " + record.Branch + " stays checked out there with its work." } // runRowCopy is the record a run's row was published with as it started diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index 99bb75905..ce6e1d006 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -235,11 +235,62 @@ func (f *ProgramFolder) cutBranch() error { // away between the two leaves a record a later one can finish from rather // than a branch nothing knows about. f.write() - if out, err := git(f.Dir, "switch", "-q", "-c", f.Branch); err != nil { - _ = os.Remove(programFolderRecord(f.key)) - return fmt.Errorf("could not cut %s's branch in %s: %s", f.Program, f.Dir, firstLine(out)) + out, err := git(f.Dir, append(switchWithoutHooks(), "-c", f.Branch)...) + if err == nil { + return nil + } + // A SWITCH THAT FAILED IS READ AGAIN BEFORE IT IS ANSWERED. git's exit says + // the command failed, not that nothing happened: a lock it could not take + // after the branch was made leaves the branch behind, and whatever else can + // go wrong once HEAD has moved leaves the checkout on it. A cut that did + // happen is carried on with, because the record written above is exactly + // what it needs; a branch made without the checkout following is deleted, + // so nothing is left in the person's repository that nothing knows about. + if currentBranch(f.Dir) == f.Branch { + return nil + } + if tip := branchCommit(f.Dir, f.Branch); tip != "" && tip == f.Start { + _, _ = git(f.Dir, "branch", "-q", "-D", f.Branch) + } + _ = os.Remove(programFolderRecord(f.key)) + refusal := fmt.Sprintf("could not cut %s's branch in %s: %s", f.Program, f.Dir, firstLine(out)) + if !f.onHome() { + refusal += "; the checkout is now on " + checkoutWords(f.Dir) + ", not " + f.homeWords() + } + return errors.New(refusal) +} + +// switchWithoutHooks is the head of every `git switch` codeaf runs in a +// program's folder: quiet, and with the repository's hooks turned off. +// +// BOTH SWITCHES GO BETWEEN TWO NAMES FOR ONE COMMIT — the program's branch cut +// where the checkout stands, and the person's own checked out again over a +// branch that holds nothing past it — so there is no checkout work a hook +// could have to do. And a hook that fails is the one way a switch that moved +// HEAD still exits non-zero: an LFS post-checkout hook with no git-lfs on +// codeaf's PATH left the checkout on a branch the refusal said was never cut. +func switchWithoutHooks() []string { + return []string{"-c", "core.hooksPath=" + os.DevNull, "switch", "-q"} +} + +// onHome says the checkout is where the person had it before the run: on +// their branch, or at the commit when it was on none. +func (f *ProgramFolder) onHome() bool { + head := currentBranch(f.Dir) + if f.Home != "" { + return head == f.Home } - return nil + at, err := git(f.Dir, "rev-parse", "--verify", "-q", "HEAD") + return head == "" && err == nil && strings.TrimSpace(at) == f.Start +} + +// checkoutWords names where a checkout is now, as a person reads it: the +// branch, or the commit when it is on none. +func checkoutWords(dir string) string { + if head := currentBranch(dir); head != "" { + return "the branch " + head + } + return "no branch, at " + shortCommit(dir, "HEAD") } // programFolderAt is the folder a program asked to work in asked works in, @@ -298,6 +349,30 @@ func programFolderOf(asked string) (dir string, repo bool, outer string, refusal // file back to its last commit. So nothing starts until the person has put it // somewhere of their own. func programCheckoutInTheWay(dir, notes string) string { + if half := halfDone(dir); half != "" { + return dir + " is in the middle of a " + half + "; finish it or abort it, then ask again" + } + out, err := git(dir, "status", "--porcelain", "--untracked-files=all", "-z") + if err != nil { + return "git could not read " + dir + ": " + firstLine(out) + } + var paths []string + for _, path := range porcelainZPaths(out) { + if notes != "" && (path == notes || strings.HasPrefix(path, strings.TrimSuffix(notes, "/")+"/")) { + continue + } + paths = append(paths, path) + } + if len(paths) == 0 { + return "" + } + return dir + " has changes that are not committed (" + namedFew(paths, programFolderShown) + "); commit or stash them, then ask again" +} + +// halfDone is the git operation a checkout is in the middle of — a merge, a +// rebase, a cherry-pick or a revert — in the word a person uses for it, "" when +// it is in the middle of none. +func halfDone(dir string) string { for _, half := range []struct{ path, what string }{ {"MERGE_HEAD", "merge"}, {"rebase-merge", "rebase"}, @@ -314,24 +389,10 @@ func programCheckoutInTheWay(dir, notes string) string { path = filepath.Join(dir, path) } if _, err := os.Lstat(path); err == nil { - return dir + " is in the middle of a " + half.what + "; finish it or abort it, then ask again" + return half.what } } - out, err := git(dir, "status", "--porcelain", "--untracked-files=all", "-z") - if err != nil { - return "git could not read " + dir + ": " + firstLine(out) - } - var paths []string - for _, path := range porcelainZPaths(out) { - if notes != "" && (path == notes || strings.HasPrefix(path, strings.TrimSuffix(notes, "/")+"/")) { - continue - } - paths = append(paths, path) - } - if len(paths) == 0 { - return "" - } - return dir + " has changes that are not committed (" + namedFew(paths, programFolderShown) + "); commit or stash them, then ask again" + return "" } // porcelainZPaths is every path `git status --porcelain -z` names, a rename @@ -376,6 +437,12 @@ type ProgramFolderEnd struct { Moved bool HeadOn string At string + // HomeMoved says the person's own branch no longer points where it did + // when the run began — something committed on it, reset it or deleted it + // while the program worked — and HomeAt is the commit it points at now, + // empty when it is gone. codeaf moves it back no more than it moved it. + HomeMoved bool + HomeAt string // Refused is git's own line when what the program left could not be // committed, or the checkout could not be put back. Refused string @@ -422,10 +489,18 @@ func (f *ProgramFolder) settle(result string) ProgramFolderEnd { } return end } + end.HomeMoved, end.HomeAt = f.homeMoved() end.Refused = f.commitLeftovers(result) head, _ := git(f.Dir, "rev-parse", "--verify", "HEAD") end.Changed = changedSince(f.Dir, f.Start) if end.Refused == "" && strings.TrimSpace(head) == f.Start { + if end.HomeMoved { + // A BRANCH OF THE PERSON'S THAT MOVED IS NOT SWITCHED TO. Going back + // would check out commits nobody here made or read, under a sentence + // saying the run changed nothing; the empty branch stays checked out, + // and the sentence says why. + return end + } // A RUN THAT CHANGED NOTHING LEAVES NOTHING: no branch holding nothing // in the person's repository, and their own branch checked out again. if refused := f.goBack(); refused != "" { @@ -439,22 +514,57 @@ func (f *ProgramFolder) settle(result string) ProgramFolderEnd { return end } +// homeMoved reads the person's own branch again, the one the run was cut +// from, and answers whether it no longer points at the commit the run began +// on, and where it points now ("" when it is gone). A checkout that was on no +// branch has nothing that can move: a commit is where it is. +// +// NOTHING SAYS "AS IT WAS" WITHOUT LOOKING. The program never writes the +// person's branch, but its shell can — a checkout of it, a commit there, a +// switch back — and the sentence the person relies on before they push is +// the one that must not repeat a promise nobody checked. +func (f *ProgramFolder) homeMoved() (bool, string) { + if f.Home == "" { + return false, "" + } + tip := branchCommit(f.Dir, f.Home) + return tip != f.Start, tip +} + // commitLeftovers commits everything the program left uncommitted in its // folder onto its branch, in one commit whose subject is the run's title and // whose body is result, and answers git's line when it would not go. // // IT IS THE PROGRAM'S FOLDER, SO IT IS ALL OF IT. The checkout was clean when -// the branch was cut ([programCheckoutInTheWay]), so everything in it now that -// is not committed is the run's. The notes folder is left out by name as well, -// for a folder whose notes were there before the run and are not ignored. +// the branch was cut ([programCheckoutInTheWay]), and nothing else of codeaf's +// writes there while the run holds it ([programHoldGuard]), so everything in +// it now that is not committed is the run's. It is only ever asked of a run +// whose end this process saw: a run whose process went away is settled +// without a commit ([ProgramFolder.settleGone]). +// +// THE NOTES ARE TAKEN BACK OUT OF THE INDEX, NOT LEFT OUT OF THE ADD. A +// pathspec that excludes `.senior-dev` makes `git add` exit 1 whenever that +// folder is there and ignored — and senior-dev ignores it in every repository +// it works in — so a notes folder that was there before the run, or would not +// move, failed every finishing commit. The whole folder is staged and the +// notes' own path reset to what HEAD holds, which git does whatever its +// ignore rules say, the way [sealGroundWork] does it. +// +// A CHECKOUT IN THE MIDDLE OF A MERGE IS NOT COMMITTED. The program's shell can +// start one, and a commit now would conclude it, conflict markers and all, +// under codeaf's name; the work is left as it is and the ending says why. func (f *ProgramFolder) commitLeftovers(result string) string { - add := []string{"add", "-A", "--", "."} - if f.Notes != "" { - add = append(add, ":(exclude)"+f.Notes) + if half := halfDone(f.Dir); half != "" { + return f.Dir + " is in the middle of a " + half } - if out, err := git(f.Dir, add...); err != nil { + if out, err := git(f.Dir, "add", "-A", "--", "."); err != nil { return "git add: " + firstLine(out) } + if f.Notes != "" { + if out, err := git(f.Dir, "reset", "-q", "--", f.Notes); err != nil { + return "git reset: " + firstLine(out) + } + } if _, err := git(f.Dir, "diff", "--cached", "--quiet"); err == nil { return "" } @@ -478,12 +588,17 @@ func (f *ProgramFolder) commitLeftovers(result string) string { // goBack checks out the person's own branch again (or the commit their // checkout was on) and deletes the program's empty branch, answering git's // line when either would not go. +// +// A SWITCH THAT FAILED AND STILL ARRIVED IS AN ARRIVAL. The checkout is read +// again after a failure ([ProgramFolder.onHome]), so a switch git reported +// badly after it had moved HEAD goes on to delete the empty branch rather +// than telling the person their folder could not be put back while it was. func (f *ProgramFolder) goBack() string { - back := []string{"switch", "-q", f.Home} + back := append(switchWithoutHooks(), f.Home) if f.Home == "" { - back = []string{"switch", "-q", "--detach", f.Start} + back = append(switchWithoutHooks(), "--detach", f.Start) } - if out, err := git(f.Dir, back...); err != nil { + if out, err := git(f.Dir, back...); err != nil && !f.onHome() { return firstLine(out) } if out, err := git(f.Dir, "branch", "-q", "-D", f.Branch); err != nil { @@ -588,15 +703,18 @@ func (e ProgramFolderEnd) Sentence() string { } case e.Dropped: said = "it changed nothing, so " + f.Dir + " is back on " + f.homeWords() + " and its branch " + f.Branch + " was deleted" + case e.HomeMoved && !e.Kept && e.Refused == "": + said = "it changed nothing, but " + e.homeMovedWords() + ", so codeaf did not switch back to it: its empty branch " + + f.Branch + " is still checked out in " + f.Dir case e.Refused != "" && !e.Kept: said = "it changed nothing, but " + f.Dir + " could not be put back on " + f.homeWords() + " (" + e.Refused + "), so its empty branch " + f.Branch + " is still checked out there" case e.Refused != "": said = "its branch " + f.Branch + " is checked out in " + f.Dir + ", but what it left uncommitted could not be committed (" + - e.Refused + "), so those changes are in the folder, uncommitted; " + f.goBackWords() + e.Refused + "), so those changes are in the folder, uncommitted; " + e.goBackWords() default: said = "its work is on the branch " + f.Branch + " in " + f.Dir + ", " + fileCount(len(e.Changed)) + - ", and that branch is checked out there; " + f.goBackWords() + ", and that branch is checked out there; " + e.goBackWords() } if e.Notes != "" { said += "; " + e.Notes @@ -616,14 +734,37 @@ func (f ProgramFolder) homeWords() string { // branch wants: the one that goes back to their own branch, and the one that // brings the work in from there. THE FOLDER IS QUOTED FOR A SHELL the way // every path this package hands one is ([shellQuoted]). -func (f ProgramFolder) goBackWords() string { +// +// AND IT SAYS "AS IT WAS" ONLY WHEN IT IS ([ProgramFolder.homeMoved]): a branch +// of the person's that moved during the run is said to have moved, from where +// to where, before anybody is told how to merge onto it. +func (e ProgramFolderEnd) goBackWords() string { + f := e.Folder folder := shellQuoted(f.Dir) if f.Home == "" { return "your checkout was on no branch, at " + shortSha(f.Start) + ", and `git -C " + folder + " switch --detach " + shortSha(f.Start) + "` goes back to it" } - return "your branch " + f.Home + " is as it was: `git -C " + folder + " switch " + f.Home + - "` goes back to it, and `git -C " + folder + " merge " + f.Branch + "` from there brings the work in" + back := "`git -C " + folder + " switch " + f.Home + "` goes back to it, and `git -C " + folder + " merge " + + f.Branch + "` from there brings the work in" + switch { + case e.HomeMoved && e.HomeAt == "": + return e.homeMovedWords() + case e.HomeMoved: + return e.homeMovedWords() + ", and codeaf did not move it: look at it before you push or merge it; " + back + } + return "your branch " + f.Home + " is as it was: " + back +} + +// homeMovedWords says how the person's own branch moved during the run +// ([ProgramFolderEnd.HomeMoved]). +func (e ProgramFolderEnd) homeMovedWords() string { + f := e.Folder + if e.HomeAt == "" { + return "your branch " + f.Home + " is gone: it was at " + shortSha(f.Start) + + " when the run began, and codeaf did not make it again" + } + return "your branch " + f.Home + " moved during the run, from " + shortSha(f.Start) + " to " + shortSha(e.HomeAt) } // landing is a finished folder as the run's landing: the program's branch diff --git a/internal/session/programfolder_git_test.go b/internal/session/programfolder_git_test.go new file mode 100644 index 000000000..7968f2efb --- /dev/null +++ b/internal/session/programfolder_git_test.go @@ -0,0 +1,262 @@ +package session + +// THE PERSON'S BRANCH AND CHECKOUT ARE NEVER CLAIMED SAFE WITHOUT BEING READ +// (programfolder.go), in real git in temporary repositories: a switch runs with +// the repository's hooks off and is read again when it fails, the ending looks +// at the person's branch before it says it is as it was, and the commit that +// finishes a run goes whatever the program's notes folder is. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// notesProgram is the fake program with a notes folder of its own, the way +// senior-dev keeps `.senior-dev/`. +func notesProgram() delegate.Delegate { + program := testPrograms("fake")[0] + program.Notes = ".fake-notes" + return program +} + +// prepareIn readies repo for a run of program, failing the test on a refusal. +func prepareIn(t *testing.T, program delegate.Delegate, repo, title string) *ProgramFolder { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: program, Dir: repo, Title: title, Holder: "task 7 (" + title + ")", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return folder +} + +// failingHook installs a post-checkout hook in hooks that fails and leaves a +// mark, the way an LFS hook does on a PATH with no git-lfs, and answers where +// the mark would be. +func failingHook(t *testing.T, hooks string) string { + t.Helper() + mark := filepath.Join(t.TempDir(), "the-hook-ran") + writeFile(t, filepath.Join(hooks, "post-checkout"), "#!/bin/sh\necho ran > '"+mark+"'\nexit 2\n") + if err := os.Chmod(filepath.Join(hooks, "post-checkout"), 0o755); err != nil { + t.Fatal(err) + } + return mark +} + +// A SWITCH RUNS WITH THE REPOSITORY'S HOOKS OFF. A post-checkout hook that +// fails used to make `git switch -c` exit non-zero after HEAD had moved, and +// the refusal then said nothing was changed while the person's checkout sat +// on an orphan branch; the switch back of a run that changed nothing failed +// the same way. Both switches go between two names for one commit, so no hook +// has anything to do — neither in .git/hooks nor where core.hooksPath points. +func TestAProgramsSwitchesRunWithTheRepositorysHooksOff(t *testing.T) { + for _, where := range []string{"git-hooks", "hooks-path"} { + t.Run(where, func(t *testing.T) { + repo := newTestRepo(t) + hooks := filepath.Join(repo, ".git", "hooks") + if where == "hooks-path" { + hooks = filepath.Join(t.TempDir(), "husky") + mustGit(t, repo, "config", "core.hooksPath", hooks) + } + mark := failingHook(t, hooks) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + if head := currentBranch(repo); head != folder.Branch { + t.Fatalf("the checkout is on %q after the cut, want %q", head, folder.Branch) + } + end := folder.Finish("") + if !end.Dropped || end.Refused != "" { + t.Fatalf("a run that changed nothing ended %+v, want its branch dropped", end) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q after a run that changed nothing, want the person's branch", head) + } + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("the empty branch was left behind: %q", branches) + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("the repository's hook ran on codeaf's switch: %v", err) + } + }) + } +} + +// A CUT THAT FAILED IS READ AGAIN BEFORE IT IS ANSWERED. A lock git could not +// take after it had made the branch left a `task/…` branch in the person's +// repository that nothing knew about; now the stray branch is deleted, the +// checkout is where it was, and nothing is owed or held. +func TestACutThatFailedLeavesNoBranchBehind(t *testing.T) { + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, ".git", "HEAD.lock"), "") + _, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "Fix the parser", Holder: "task 7 (Fix the parser)", Keep: t.TempDir()}) + if err == nil || !strings.HasPrefix(err.Error(), "could not cut fake's branch in "+repo+": ") { + t.Fatalf("PrepareProgramFolder = %v, want the cut refused", err) + } + if strings.Contains(err.Error(), "the checkout is now on") { + t.Fatalf("the refusal says the checkout moved when it did not: %v", err) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q, want the person's branch", head) + } + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("the failed cut left a branch behind: %q", branches) + } + if record, ok := readProgramFolder(canonicalPath(repo)); ok && record.Ended == "" { + t.Fatalf("the failed cut left a run owed: %+v", record) + } + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the failed cut still holds the folder: %q", holder) + } +} + +// moveBranch puts a commit of nobody's on branch without checking it out, the +// way a program's shell that checked the person's branch out, committed there +// and switched back leaves it, and answers the commit. +func moveBranch(t *testing.T, repo, branch string) string { + t.Helper() + tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", branch)) + moved := strings.TrimSpace(gitOut(t, repo, "-c", "user.name=s", "-c", "user.email=s@s", "commit-tree", tip+"^{tree}", "-p", tip, "-m", "a commit nobody read")) + mustGit(t, repo, "update-ref", "refs/heads/"+branch, moved) + return moved +} + +// THE ENDING LOOKS AT THE PERSON'S BRANCH BEFORE IT SAYS IT IS AS IT WAS. A +// program's shell that committed on the person's branch mid-run was reported +// as `your branch work is as it was`; a run that changed nothing then switched +// the checkout onto commits nobody had read and said it changed nothing. +func TestTheEndingSaysThePersonsBranchMovedDuringTheRun(t *testing.T) { + t.Run("with work", func(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + moved := moveBranch(t, repo, "work") + said := folder.Finish("done").Sentence() + want := "your branch work moved during the run, from " + shortSha(start) + " to " + shortSha(moved) + + ", and codeaf did not move it: look at it before you push or merge it; `git -C " + if !strings.Contains(said, want) || strings.Contains(said, "as it was") { + t.Fatalf("the ending = %q, want it to say %q", said, want) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")); tip != moved { + t.Fatalf("codeaf moved the person's branch to %s", tip) + } + }) + t.Run("with nothing", func(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + moved := moveBranch(t, repo, "work") + end := folder.Finish("") + if end.Dropped || currentBranch(repo) != folder.Branch { + t.Fatalf("a run whose person's branch moved was dropped onto it: %+v, on %q", end, currentBranch(repo)) + } + want := "it changed nothing, but your branch work moved during the run, from " + shortSha(start) + " to " + shortSha(moved) + + ", so codeaf did not switch back to it: its empty branch " + folder.Branch + " is still checked out in " + repo + if said := end.Sentence(); said != want { + t.Fatalf("the ending = %q, want %q", said, want) + } + }) + t.Run("gone", func(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + mustGit(t, repo, "branch", "-D", "work") + said := folder.Finish("done").Sentence() + want := "your branch work is gone: it was at " + shortSha(start) + " when the run began, and codeaf did not make it again" + if !strings.HasSuffix(said, want) { + t.Fatalf("the ending = %q, want it to end %q", said, want) + } + }) +} + +// AND THE RECEIPT READS IT TOO before it promises the branch does not move. +func TestTheReceiptSaysWhenThePersonsBranchHasAlreadyMoved(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + moved := moveBranch(t, repo, "work") + record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "work", HomeSha: start} + want := "It is fake's: it works alone in " + repo + " itself, on a new branch task/pong-abc123; your branch work has already moved, from " + + shortSha(start) + " to " + shortSha(moved) + ", and codeaf does not move it, and when it ends task/pong-abc123 stays checked out there with its work." + if got := delegateReceipt(repo, testPrograms("fake")[0], record); got != want { + t.Fatalf("the receipt = %q, want %q", got, want) + } +} + +// THE COMMIT THAT FINISHES A RUN GOES WHATEVER THE NOTES FOLDER IS. A notes +// folder named by an exclude pathspec made `git add` exit 1 whenever it was +// there and ignored — senior-dev ignores its own in every repository — so a +// folder whose notes predated the run never had its leftovers committed and +// never had an empty branch dropped. +func TestTheLeftoversAreCommittedWhateverTheNotesFolderIs(t *testing.T) { + for _, tc := range []struct { + name string + ready func(t *testing.T, repo string) + }{ + {"there and ignored", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, ".fake-notes", "old.md"), "an earlier run's checklist\n") + writeFile(t, filepath.Join(repo, ".git", "info", "exclude"), ".fake-notes/\n") + }}, + {"there and not ignored", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, ".fake-notes", "old.md"), "an earlier run's checklist\n") + }}, + {"not there", func(*testing.T, string) {}}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newTestRepo(t) + tc.ready(t, repo) + folder := prepareIn(t, notesProgram(), repo, "Fix the parser") + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + writeFile(t, filepath.Join(repo, ".fake-notes", "checklist.md"), "- [x] fix\n") + end := folder.Finish("done") + if end.Refused != "" || !end.Kept { + t.Fatalf("the run's leftovers were not committed: %+v", end) + } + if files := strings.Fields(gitOut(t, repo, "ls-tree", "-r", "--name-only", folder.Branch)); strings.Join(files, " ") != "fix.go shared.txt" { + t.Fatalf("the branch holds %q, want the work and none of the notes", files) + } + if staged := strings.TrimSpace(gitOut(t, repo, "diff", "--cached", "--name-only")); staged != "" { + t.Fatalf("the notes were left staged: %q", staged) + } + }) + t.Run(tc.name+", changing nothing", func(t *testing.T) { + repo := newTestRepo(t) + tc.ready(t, repo) + folder := prepareIn(t, notesProgram(), repo, "Fix the parser") + writeFile(t, filepath.Join(repo, ".fake-notes", "checklist.md"), "- [ ] fix\n") + if end := folder.Finish(""); !end.Dropped || end.Refused != "" { + t.Fatalf("a run that changed nothing ended %+v, want its branch dropped", end) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q, want the person's branch", head) + } + }) + } +} + +// A CHECKOUT THE PROGRAM LEFT IN THE MIDDLE OF A MERGE IS NOT COMMITTED: a +// commit then would conclude the merge, conflict markers and all, under +// codeaf's name. +func TestAMergeTheProgramLeftHalfDoneIsNotCommitted(t *testing.T) { + repo := newTestRepo(t) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + mustGit(t, repo, "checkout", "-q", "-b", "other", "work") + writeFile(t, filepath.Join(repo, "shared.txt"), "theirs\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "theirs") + mustGit(t, repo, "checkout", "-q", folder.Branch) + writeFile(t, filepath.Join(repo, "shared.txt"), "ours\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "ours") + if _, err := git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "merge", "other"); err == nil { + t.Fatal("the merge did not stop on its conflict") + } + head := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + end := folder.Finish("done") + if end.Refused != repo+" is in the middle of a merge" { + t.Fatalf("the ending = %+v, want the merge named and nothing committed", end) + } + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); after != head { + t.Fatalf("a half-done merge was committed: HEAD moved from %s to %s", head, after) + } +} From bfe3df2cbe9b6260c14a502cf4134d20a04d0d79 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:37:35 -0400 Subject: [PATCH 131/195] session, manual: a program's run whose process went away is settled without a commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run whose process went away — codeaf closed, crashed or killed, a shell run's second ctrl-c — was finished by the next codeaf that found it with `git add -A` and a commit under codeaf's name with the hooks skipped, before the dirty-checkout check. The folder had been the person's since the process died, so a day of their own edits on the run's branch, untracked files and all, went into that commit, a merge they were resolving was concluded with its conflict markers, and the next run was cut on top; the reopen did the same without even the repository's git lock. Now codeaf commits leftovers only for a run whose end it saw. A run found owed, at a conversation's reopen or by the next run in that folder, is settled without a git write: its notes are moved into its record folder, and its record ends with where its work is — its branch, checked out as it was left, and how many files are not committed, read with git's optional locks off. The next run then meets those changes like anybody's, refused, and is told they may be the earlier run's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 7 +- internal/manual/chat/senior-dev.md | 34 +++--- internal/manual/chat_test.go | 1 + internal/session/programfolder.go | 153 +++++++++++++++++++++---- internal/session/programfolder_test.go | 116 +++++++++++++++---- internal/session/task_run_belt.go | 18 +-- 6 files changed, 258 insertions(+), 71 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 2470dbfbb..28dc0b13c 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -154,8 +154,9 @@ program itself checked is reported in its result, kept apart from what its model 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 <name> was running` where it was last seen working, or `<name> had ended; codeaf closed before it could say where its -work is` at the program's exit; the next codeaf to find the run commits what it left on -its branch. Nothing carries it on; the next hand-off starts a run of its own. +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 @@ -182,7 +183,7 @@ A name your build does not carry is refused with the ones it does: In a git repository codeaf cuts the program a branch of its own (`task/<title>-<id>`) 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, however it ends, codeaf commits what +`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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 0e52ecf95..13f89da1d 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -244,16 +244,17 @@ how it keeps a record to restore from; they stay there, and nothing squashes the 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, 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 +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 <branch> in <folder>, N files, and that branch is checked out there; your branch <yours> is as it was: `git -C '<folder>' switch <yours>` goes back to it, and `git -C '<folder>' merge <branch>` 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 when it -submitted (`senior-dev's model said: …`) and what senior-dev itself saw when it ran the -project's build and tests (`senior-dev observed: …`). Read the second for "did it work". +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 <branch>, checked out in <folder>` at once, and the page then says where it is @@ -515,14 +516,14 @@ A run ends in one of these ways, and the task's ending says which: - `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 committed what was left + 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 +## 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 (closing it, `ctrl+c`, a closed terminal) only detaches: senior-dev keeps working. @@ -532,10 +533,16 @@ row read `incomplete` with `codeaf closed while senior-dev was running` beside i stage, nothing waiting on you, and no fault. If senior-dev had already exited, it reads `senior-dev had ended; codeaf closed before it could say where its work is`. -**Its folder is finished by the next codeaf that finds the run**: the one that opens that -conversation, hands work off in it, or starts a run in that folder. What senior-dev left -uncommitted is committed on its branch, which stays checked out, its notes are moved out, -and the page adds where the work is, as a run that ended would say it. +**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 <branch> in <folder>, 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 <yours>`. 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 <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 @@ -543,9 +550,8 @@ time and spend do not count the hours codeaf was closed. An orderly close writes 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` in that conversation starts a run of its -own, under its own task number, with its own brief and its own page. The old run's page -stays as the record of what it did. +**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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index ef6acb841..201b339c5 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -934,6 +934,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"make senior-dev use my crew models", "senior-dev"}, {"how long did the senior-dev run take", "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"}, {"can my other window see the senior-dev run", "senior-dev"}, // Its page is the actions it took, each under the step of its process, // asked the ways somebody watching it would ask. diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index ce6e1d006..b0ad76e0f 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -28,16 +28,20 @@ package session // senior-dev's `--in-place`). codeaf passes them whenever it decided so, // because the program's own reading of a folder climbs to any repository // around it. -// 4. WHEN IT ENDS, however it ends — done, not finished, stopped, crashed, or -// found at a reopen with nothing driving it: in a repository, what the -// program left uncommitted is committed onto its branch in one commit (the -// task's title, the result under it) and the branch is LEFT CHECKED OUT, so -// the person sees the work in their folder. A run that changed nothing is +// 4. WHEN IT ENDS — done, not finished, stopped, or crashed, an end the +// process holding the run saw — in a repository, what the program left +// uncommitted is committed onto its branch in one commit (the task's +// title, the result under it) and the branch is LEFT CHECKED OUT, so the +// person sees the work in their folder. A run that changed nothing is // undone: the person's branch is checked out again and the empty branch // deleted. A HEAD the program's shell moved off its branch is left exactly -// where it is, and said. In either kind of folder the program's notes -// ([delegate.Delegate.Notes]) are moved into the run's record folder -// unless they were there before the run. +// where it is, and said, and so is a branch of the person's that moved. +// A RUN WHOSE PROCESS WENT AWAY — codeaf closed, crashed or killed — is +// settled by the next codeaf that finds it WITHOUT A SINGLE GIT WRITE +// ([ProgramFolder.settleGone]): its work stays as it left it, and the +// person is told where and in what state. In either kind of folder the +// program's notes ([delegate.Delegate.Notes]) are moved into the run's +// record folder unless they were there before the run. // 5. ONE RUN PER FOLDER. codeaf starts and stops the run and keeps its money, // its time and its screen, and nothing else. A second program run on a // folder one is working in — from any conversation, any window, or a @@ -115,7 +119,7 @@ type ProgramFolderOrder struct { } // ProgramFolder is one program run's folder as [PrepareProgramFolder] readied -// it. It is also the record a later process finishes the run's folder from +// it. It is also the record a later process settles the run's folder from // when the process that started it went away first // ([settleOwedProgramFolder]), which is why its fields are written down. type ProgramFolder struct { @@ -184,16 +188,21 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { return nil, errors.New(programFolderBusy(dir, holder)) } folder.lock = lock - // A RUN THAT WENT AWAY IN THIS FOLDER IS FINISHED BEFORE THE NEXT ONE - // STARTS: its leftovers committed on its branch and its notes moved, so - // they are neither refused as the person's changes nor handed to the next - // run as its own. Nothing else holds the folder, because this does — and - // on a filesystem that takes no locks nothing can say so, so an owed run - // there is left for its own conversation's reopen. + // A RUN THAT WENT AWAY IN THIS FOLDER IS SETTLED BEFORE THE NEXT ONE STARTS, + // AND NOTHING OF IT IS COMMITTED ([ProgramFolder.settleGone]): its record + // ended with where its work is and its notes moved, so the next run is + // never handed the last one's checklist as its own. What it left + // uncommitted stays exactly where it was, and the next run meets it the way + // it meets anybody's changes — refused, and told whose they may be. Nothing + // else holds the folder, because this does; on a filesystem that takes no + // locks nothing can say so, and an owed run there is left alone. + var earlier *ProgramFolderEnd if owed, ok := readProgramFolder(folder.key); ok && owed.Ended == "" && lock != nil { - owed.key, owed.place = folder.key, order.Place - owed.Ended = owed.settle("").Sentence() + owed.key = folder.key + end := owed.settleGone() + owed.Ended = end.Sentence() owed.write() + earlier = &end } if _, err := os.Stat(dir); os.IsNotExist(err) { if err := os.Mkdir(dir, 0o755); err != nil { @@ -211,6 +220,13 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { } if err := folder.cutBranch(); err != nil { folder.release() + if earlier != nil && earlier.Folder.Branch != "" && !earlier.Moved { + // AND THE REFUSAL SAYS WHOSE THE CHANGES MAY BE. codeaf cannot tell a + // run's last edits from the person's own made on its branch since, so + // it commits neither and says both. + return nil, fmt.Errorf("%w; they may be an earlier %s run's, which codeaf could not finish: its branch %s is checked out there", + err, earlier.Folder.Program, earlier.Folder.Branch) + } return nil, err } return folder, nil @@ -443,6 +459,12 @@ type ProgramFolderEnd struct { // empty when it is gone. codeaf moves it back no more than it moved it. HomeMoved bool HomeAt string + // Gone says the run's process went away before it could end the run + // itself, so codeaf settled its folder without writing to git at all + // ([ProgramFolder.settleGone]), and Uncommitted is how many files it found + // there that are not committed. + Gone bool + Uncommitted int // Refused is git's own line when what the program left could not be // committed, or the checkout could not be put back. Refused string @@ -514,6 +536,61 @@ func (f *ProgramFolder) settle(result string) ProgramFolderEnd { return end } +// settleGone settles the folder of a run whose process went away before it +// could end the run itself — a crash, a kill, codeaf closed — and it WRITES +// NOTHING TO GIT: no add, no commit, no switch, no branch deleted. It reads +// where the checkout is, what the program's branch holds and how many files +// are not committed, moves the program's notes into the run's record folder, +// and answers the ending that says so ([ProgramFolderEnd.Gone]). +// +// ONLY AN END CODEAF SAW IS FINISHED WITH A COMMIT. Once the process that +// held the folder is gone, the folder is the person's again, and what is +// uncommitted in it may be the run's last edits or their own made on its +// branch since — codeaf cannot tell the two apart. A commit here once swept a +// person's day of edits, and a merge they were resolving, into a commit under +// codeaf's name with their hooks skipped. The read is made with git's optional +// locks off, so not even the index is refreshed. +func (f *ProgramFolder) settleGone() ProgramFolderEnd { + end := ProgramFolderEnd{Folder: *f, Gone: true} + end.Notes = f.keepNotes() + if f.Branch == "" { + return end + } + if head := currentBranch(f.Dir); head != f.Branch { + end.Moved, end.HeadOn = true, head + if head == "" { + end.At = shortCommit(f.Dir, "HEAD") + } + } + if tip := branchCommit(f.Dir, f.Branch); tip != "" { + end.Changed = changedBetween(f.Dir, f.Start, tip) + end.Kept = tip != f.Start + } + if !end.Moved { + end.Uncommitted = uncommittedCount(f.Dir, f.Notes) + end.HomeMoved, end.HomeAt = f.homeMoved() + } + return end +} + +// uncommittedCount is how many files in a checkout are not committed, the +// program's notes left out, read without taking or writing any of git's locks; +// zero when git cannot say. +func uncommittedCount(dir, notes string) int { + out, err := git(dir, "--no-optional-locks", "status", "--porcelain", "--untracked-files=all", "-z") + if err != nil { + return 0 + } + count := 0 + for _, path := range porcelainZPaths(out) { + if notes != "" && (path == notes || strings.HasPrefix(path, strings.TrimSuffix(notes, "/")+"/")) { + continue + } + count++ + } + return count +} + // homeMoved reads the person's own branch again, the one the run was cut // from, and answers whether it no longer points at the commit the run began // on, and where it points now ("" when it is gone). A checkout that was on no @@ -686,6 +763,11 @@ func (e ProgramFolderEnd) Sentence() string { f := e.Folder var said string switch { + case e.Gone && f.Branch == "" && f.Outer != "": + said = "its work so far is in " + f.Dir + ", as it left it; the git repository around it is at " + f.Outer + + ", which holds your home folder, so codeaf cut no branch there and committed nothing" + case e.Gone && f.Branch == "": + said = "its work so far is in " + f.Dir + ", which has no git history, as it left it" case f.Branch == "" && f.Outer != "": said = "its work is in " + f.Dir + "; the git repository around it is at " + f.Outer + ", which holds your home folder, so codeaf cut no branch there and committed nothing" @@ -701,6 +783,8 @@ func (e ProgramFolderEnd) Sentence() string { if e.Kept { said += "; " + f.Branch + " holds " + fileCount(len(e.Changed)) } + case e.Gone: + said = e.goneWords() case e.Dropped: said = "it changed nothing, so " + f.Dir + " is back on " + f.homeWords() + " and its branch " + f.Branch + " was deleted" case e.HomeMoved && !e.Kept && e.Refused == "": @@ -722,6 +806,24 @@ func (e ProgramFolderEnd) Sentence() string { return said } +// goneWords is where a run whose process went away left its work in a +// repository, still on its own branch ([ProgramFolder.settleGone]): the branch, +// that it is checked out as the run left it, how many files are not committed, +// and the way back — which, while something is uncommitted, starts with +// putting that somewhere, because a switch would carry it along. +func (e ProgramFolderEnd) goneWords() string { + f := e.Folder + said := "its work so far is on its branch " + f.Branch + " in " + f.Dir + ", which is checked out there, as it left it" + if e.Uncommitted == 0 { + return said + "; " + e.goBackWords() + } + said += ", with " + fileCount(e.Uncommitted) + " not committed" + if e.HomeMoved { + said += "; " + e.homeMovedWords() + } + return said + "; commit or stash them there before you go back to " + f.homeWords() +} + // homeWords names where the person's checkout was before the run. func (f ProgramFolder) homeWords() string { if f.Home != "" { @@ -923,9 +1025,9 @@ func readProgramFolderAt(path string) (*ProgramFolder, bool) { return &folder, true } -// settleOwedProgramFolder finishes the folder of the run whose record folder -// is keep, when that run's process went away before it could: the reopen of -// its conversation, or the next hand-off in it, comes here +// settleOwedProgramFolder settles the folder of the run whose record folder +// is keep, when that run's process went away before it could finish it: the +// reopen of its conversation, or the next hand-off in it, comes here // ([endOrphanedProgramRun]). It answers how the folder was left, and false // when nothing was owed or somebody else holds the folder now. func settleOwedProgramFolder(keep string) (ProgramFolderEnd, bool) { @@ -938,14 +1040,21 @@ func settleOwedProgramFolder(keep string) (ProgramFolderEnd, bool) { if !ok || owed.Ended != "" || filepath.Clean(owed.Keep) != filepath.Clean(keep) { continue } - lock, holder := claimProgramFolder(owed.key, owed.Program+", finishing a run codeaf closed under") + lock, holder := claimProgramFolder(owed.key, owed.Program+", settling a run codeaf closed under") if holder != "" || lock == nil { // A HOLD SOMEBODY ELSE HAS, or one nobody can take, is a folder this // reopen cannot know is idle: it is left for the next codeaf that can. return ProgramFolderEnd{}, false } owed.lock = lock - return owed.Finish(""), true + // NOTHING IS COMMITTED FOR A RUN WHOSE END NOBODY SAW + // ([ProgramFolder.settleGone]): the folder has been the person's since + // the process went away, however long ago that was. + end := owed.settleGone() + owed.Ended = end.Sentence() + owed.write() + owed.release() + return end, true } return ProgramFolderEnd{}, false } diff --git a/internal/session/programfolder_test.go b/internal/session/programfolder_test.go index d50fc1383..3a0217336 100644 --- a/internal/session/programfolder_test.go +++ b/internal/session/programfolder_test.go @@ -3,8 +3,8 @@ package session // THE CONTRACT OF A PROGRAM'S FOLDER (programfolder.go), in real git in // temporary repositories: which folder, a branch in a repository and nothing // of git anywhere else, a checkout that is in the way refused before anything -// starts, one run per folder, and a run whose process went away finished by -// the next codeaf that finds it. +// starts, one run per folder, and a run whose process went away settled by the +// next codeaf that finds it without a single git write. import ( "context" @@ -242,49 +242,117 @@ func deadProgramFolder(t *testing.T, repo, keep string) *ProgramFolder { return folder } -// A RUN FOUND INTERRUPTED AT A REOPEN IS FINISHED THE WAY ONE THAT ENDED IS: -// what it left uncommitted committed on its branch, the branch left checked -// out, and its row and page saying where the work is. -func TestAProgramRunFoundInterruptedAtAReopenIsFinishedInItsFolder(t *testing.T) { +// A RUN FOUND INTERRUPTED AT A REOPEN IS SETTLED WITHOUT A COMMIT. Its process +// went away, so what is uncommitted in its folder may be its last edits or the +// person's own made on its branch since — here both — and codeaf commits +// neither: the checkout stays on the run's branch as it was left, and the row +// and page say where the work is and how much of it is not committed. +func TestAProgramRunFoundInterruptedAtAReopenIsSettledWithoutACommit(t *testing.T) { repo := newTestRepo(t) var dead *ProgramFolder agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, _ time.Time) { dead = deadProgramFolder(t, repo, taskDir) + writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own edit, made after codeaf closed\n") }) row := reopenedRow(t, agent, id) if head := currentBranch(repo); head != dead.Branch { t.Fatalf("the checkout is on %q after the reopen, want the dead run's branch %q", head, dead.Branch) } - if files := gitOut(t, repo, "ls-tree", "--name-only", dead.Branch); !strings.Contains(files, "half.txt") { - t.Fatalf("what the dead run left was not committed on its branch:\n%s", files) + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", dead.Branch)); tip != dead.Start { + t.Fatalf("the reopen committed on the dead run's branch: %s, want it still at %s", tip, dead.Start) } - if subject := strings.TrimSpace(gitOut(t, repo, "log", "-1", "--format=%s", dead.Branch)); subject != "The dead run" { - t.Fatalf("the commit is %q, want the run's title", subject) + if status := gitOut(t, repo, "status", "--porcelain"); !strings.Contains(status, " M shared.txt") || !strings.Contains(status, "?? half.txt") { + t.Fatalf("what was uncommitted was not left as it was:\n%s", status) } - if row.Branch != dead.Branch || !strings.Contains(row.Report, "its work is on the branch "+dead.Branch) || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { - t.Fatalf("the reopened row = %+v, want its ending and where its work is", row) + want := "its work so far is on its branch " + dead.Branch + " in " + repo + ", which is checked out there, as it left it, with 2 files not committed; commit or stash them there before you go back to your branch work" + if !strings.Contains(row.Report, want) || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { + t.Fatalf("the reopened row = %+v, want its ending and %q", row, want) } - if again, ok := readProgramFolder(canonicalPath(repo)); !ok || again.Ended == "" { - t.Fatalf("the folder's record still says it is owed: %+v", again) + if again, ok := readProgramFolder(canonicalPath(repo)); !ok || again.Ended != want { + t.Fatalf("the folder's record = %+v, want it ended with %q", again, want) + } + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the reopen still holds the folder: %q", holder) + } +} + +// A RUN THAT WENT AWAY IN A FOLDER IS SETTLED BEFORE THE NEXT ONE STARTS THERE, +// AND NOTHING OF IT IS COMMITTED: the next run meets what is uncommitted the +// way it meets anybody's changes — refused, naming them — and is told whose +// they may be. A person's edits made on the dead run's branch used to be swept +// into a commit under codeaf's name, and the next run cut on top of it. +func TestTheNextRunInAFolderIsRefusedWhatTheOneThatWentAwayLeft(t *testing.T) { + repo := newTestRepo(t) + dead := deadProgramFolder(t, repo, t.TempDir()) + writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own edit, made after codeaf closed\n") + _, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + want := repo + " has changes that are not committed (shared.txt, half.txt); commit or stash them, then ask again; they may be an earlier fake run's, which codeaf could not finish: its branch " + dead.Branch + " is checked out there" + if err == nil || err.Error() != want { + t.Fatalf("the next run = %v, want %q", err, want) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", dead.Branch)); tip != dead.Start { + t.Fatalf("the next run committed on the dead run's branch: %s, want it still at %s", tip, dead.Start) + } + if head := currentBranch(repo); head != dead.Branch { + t.Fatalf("the checkout is on %q, want the dead run's branch", head) + } + if again, ok := readProgramFolder(canonicalPath(repo)); !ok || !strings.HasPrefix(again.Ended, "its work so far is on its branch "+dead.Branch) { + t.Fatalf("the dead run's record = %+v, want it settled", again) + } + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the refused run still holds the folder: %q", holder) } } -// A RUN THAT WENT AWAY IN A FOLDER IS FINISHED BEFORE THE NEXT ONE STARTS -// THERE, so what it left is neither refused as the person's changes nor handed -// to the next run as its own. -func TestTheNextRunInAFolderFinishesTheOneThatWentAway(t *testing.T) { +// A MERGE THE PERSON IS RESOLVING ON A DEAD RUN'S BRANCH IS NOT CONCLUDED: the +// next run is refused over it, and MERGE_HEAD and the conflict are left alone. +func TestTheNextRunDoesNotConcludeAMergeOnTheBranchAnEarlierRunLeft(t *testing.T) { repo := newTestRepo(t) - keep := t.TempDir() - dead := deadProgramFolder(t, repo, keep) + dead := deadProgramFolder(t, repo, t.TempDir()) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + mustGit(t, repo, "checkout", "-q", "-b", "other", "work") + writeFile(t, filepath.Join(repo, "shared.txt"), "theirs\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "theirs") + mustGit(t, repo, "checkout", "-q", dead.Branch) + writeFile(t, filepath.Join(repo, "shared.txt"), "ours\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "ours") + if _, err := git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "merge", "other"); err == nil { + t.Fatal("the merge did not stop on its conflict") + } + head := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + _, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + if err == nil || !strings.HasPrefix(err.Error(), repo+" is in the middle of a merge; finish it or abort it, then ask again") { + t.Fatalf("the next run = %v, want the merge named", err) + } + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); after != head { + t.Fatalf("the person's merge was concluded: HEAD moved from %s to %s", head, after) + } + if _, err := os.Stat(filepath.Join(repo, ".git", "MERGE_HEAD")); err != nil { + t.Fatalf("the merge in progress is gone: %v", err) + } +} + +// A CLEAN FOLDER A RUN WENT AWAY IN IS WORKED IN AGAIN: its committed work +// stays on its branch, which the next run is cut from, as it would be from +// any branch the person had checked out. +func TestTheNextRunCarriesOnInAFolderTheOneThatWentAwayLeftClean(t *testing.T) { + repo := newTestRepo(t) + dead := deadProgramFolder(t, repo, t.TempDir()) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + commitIn(t, repo, "done.txt") next, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) if err != nil { - t.Fatalf("the next run was refused over the dead one's leftovers: %v", err) + t.Fatalf("the next run was refused a clean folder: %v", err) } defer next.Finish("") - if files := gitOut(t, repo, "ls-tree", "--name-only", dead.Branch); !strings.Contains(files, "half.txt") { - t.Fatalf("the dead run's leftovers were not committed on its branch:\n%s", files) - } if next.Home != dead.Branch { t.Fatalf("the next run was cut from %q, want the dead run's branch %q, which was left checked out", next.Home, dead.Branch) } + if files := gitOut(t, repo, "ls-tree", "--name-only", dead.Branch); !strings.Contains(files, "done.txt") { + t.Fatalf("the dead run's committed work is not on its branch:\n%s", files) + } } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 98a043d5c..60bd5859d 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -637,8 +637,8 @@ func programClosedSentence(name string) string { // closed under AFTER the program had exited: its worker was still settling // owed receipts, or the run was about to end. The program was not running, so // the sentence does not say it was; what was not done is the run's ending in -// its folder, which the next codeaf to find the run does -// ([settleOwedProgramFolder]) and says under this line. +// its folder, which the next codeaf to find the run settles without writing +// to git ([settleOwedProgramFolder]) and says under this line. func programEndedSentence(name string) string { return name + " had ended; codeaf closed before it could say where its work is" } @@ -651,8 +651,8 @@ func programEndedSentence(name string) string { const runLimitSentence = "a limit you set stopped it" // endOrphanedProgramRun ends a program's run whose store was left open by a -// process that went away, at the run's last evidence of life, and finishes the -// folder it worked in when that process went away before it could +// process that went away, at the run's last evidence of life, and settles the +// folder it worked in when that process went away before it could finish it // ([settleOwedProgramFolder]), answering how it left the folder. It ends // nothing in a store whose run has ended, or whose run no program worked (the // task's record folder holds no program record, [delegate.ProgramFile]). @@ -661,10 +661,12 @@ const runLimitSentence = "a limit you set stopped it" // finds the store can be hours later than the one that lost it, and the page // counts a run's time to its ending ([plandb.Store.FailRootAt] says why). // -// THE FOLDER IS FINISHED WHATEVER THE STORE SAYS. A run a person stopped, or +// THE FOLDER IS SETTLED WHATEVER THE STORE SAYS. A run a person stopped, or // one codeaf closed under, has its store's ending written before its folder is // finished, so a process that went away in between leaves an ended store over -// a folder still on the program's branch with its last changes uncommitted. +// a folder still on the program's branch with its last changes uncommitted — +// and those are left uncommitted, because nobody saw the run end +// ([ProgramFolder.settleGone]). func endOrphanedProgramRun(store *plandb.Store) (ProgramFolderEnd, bool) { rootID := store.RootID() root := store.Task(rootID) @@ -793,9 +795,9 @@ func (a *Agent) endInterruptedProgramRun() { // reads ([Agent.beltRunEndedAt]). The store's ending can come after the exit // by the whole wait for owed receipts, and that wait is not the run's time. // -// AND IT SAYS WHERE THE WORK IS when this reopen finished the run's folder +// AND IT SAYS WHERE THE WORK IS when this reopen settled the run's folder // (settled): the folder's sentence under the ending, and the program's branch -// when it holds the work, as the live ending would have said them. +// when it holds the work. func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, kept TaskNotice, end ProgramFolderEnd, settled bool) { root := store.Task(store.RootID()) if root == nil || (root.Status != plandb.StatusFailed && root.Status != plandb.StatusCancelled) { From 6e38731f0734fb780f50ce94083ea9cbb86f010b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:40:41 -0400 Subject: [PATCH 132/195] session, manual: a program's run is refused a folder inside or around one another run holds The hold on a program's folder was keyed by its exact path, so a run on a plain folder of projects and a run in one of those projects each held their own and started together; once the outer run submitted, its checkpoint restore put back what the inner run had changed and removed the files it had added, and counted them as its own. Now a folder is busy when a held folder is it, holds it, or is inside it. The hold file says which folder it holds as well as whose run, a run takes its own hold before it reads the tree around it (so two runs readying a parent and a child can both be refused and never both start), the folders above are asked by name and the holds below by what they say they hold, and the refusal names the held folder and how it stands to the one asked for. The hold lives in programhold.go, which the next change's guards read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 4 +- internal/manual/chat/senior-dev.md | 10 +- internal/manual/chat_test.go | 1 + internal/session/programfolder.go | 68 ++------- internal/session/programhold.go | 214 +++++++++++++++++++++++++++ internal/session/programhold_test.go | 95 ++++++++++++ internal/session/taskstands.go | 4 +- 7 files changed, 333 insertions(+), 63 deletions(-) create mode 100644 internal/session/programhold.go create mode 100644 internal/session/programhold_test.go diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 28dc0b13c..87f710f1d 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -169,7 +169,9 @@ before you are shown a card. **One folder takes one program run at a time**, from any conversation, any window or a shell: `<folder> is busy: <name>, task 4 (…), is working in it, and one folder takes one -program run at a time; ask again when that run has ended`. +program run at a time; ask again when that run has ended`. So do the folders inside it +and around it: `… is working in <held folder>, 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 <folder>; diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 13f89da1d..ea81eed75 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -305,12 +305,20 @@ In the chat the model is told this before you are shown a card, and can commit o 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, another window, a shell run +## 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: `<folder> 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: `<folder> is busy: senior-dev, +task 4 (…), is working in <held folder>, 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. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 201b339c5..343befa26 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -971,6 +971,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, {"the delegate was refused because of uncommitted changes", "delegates"}, diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index b0ad76e0f..f6b67ee27 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -44,9 +44,10 @@ package session // record folder unless they were there before the run. // 5. ONE RUN PER FOLDER. codeaf starts and stops the run and keeps its money, // its time and its screen, and nothing else. A second program run on a -// folder one is working in — from any conversation, any window, or a -// shell — is refused, naming the run that holds it; the hold is a file -// lock, which dies with the process that took it ([claimProgramFolder]). +// folder one is working in, or on a folder inside it or around it — from +// any conversation, any window, or a shell — is refused, naming the run +// that holds it; the hold is a file lock, which dies with the process that +// took it (programhold.go). // // WHY THERE IS SO LITTLE HERE. Until 2026-09-24 a program ran through the // general task machinery: a copy of the folder cut for every run, the brief's @@ -183,9 +184,9 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { Notes: order.Program.Notes, Keep: order.Keep, Sign: order.Sign, key: canonicalPath(dir), place: order.Place, } - lock, holder := claimProgramFolder(folder.key, order.Program.Name+", "+order.Holder) - if holder != "" { - return nil, errors.New(programFolderBusy(dir, holder)) + lock, hold, busy := claimProgramFolder(folder.key, order.Program.Name+", "+order.Holder) + if busy { + return nil, errors.New(programFolderBusy(dir, hold)) } folder.lock = lock // A RUN THAT WENT AWAY IN THIS FOLDER IS SETTLED BEFORE THE NEXT ONE STARTS, @@ -430,11 +431,6 @@ func porcelainZPaths(out string) []string { return paths } -// programFolderBusy is the refusal for a folder another program run holds. -func programFolderBusy(dir, holder string) string { - return dir + " is busy: " + holder + ", is working in it, and one folder takes one program run at a time; ask again when that run has ended" -} - // ProgramFolderEnd is how a program's run left its folder, as // [ProgramFolder.Finish] found it and made it. type ProgramFolderEnd struct { @@ -920,52 +916,6 @@ func changedBetween(dir, from, to string) []string { return paths } -// claimProgramFolder takes the hold on one folder for a program's run and -// writes holder into it, so a second run is told whose it is. It answers the -// held lock, or the holder of a lock somebody else has; a nil lock with no -// holder is a filesystem that takes no locks, and the run goes ahead unheld, -// which is what every run did before the hold existed. -// -// flock DIES WITH ITS PROCESS, however it dies, so a crashed codeaf leaves no -// hold behind to be broken by hand; and it is per open file, so two -// conversations in one engine exclude each other exactly as two windows do. -func claimProgramFolder(key, holder string) (*os.File, string) { - directory := home.Join("v3", programFolderDir) - if err := os.MkdirAll(directory, 0o700); err != nil { - return nil, "" - } - path := filepath.Join(directory, programFolderName(key)+".lock") - file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return nil, "" - } - if err := filelock.Lock(file, true, true); err != nil { - _ = file.Close() - if !isLockHeld(err) { - return nil, "" - } - held, _ := os.ReadFile(path) - if said := strings.TrimSpace(string(held)); said != "" { - return nil, said - } - return nil, "another program run" - } - _ = file.Truncate(0) - _, _ = file.WriteAt([]byte(holder), 0) - return file, "" -} - -// programFolderHolder is who holds a folder now, "" when nobody does: the -// hold asked for and let go at once, for a door that only wants to know. -func programFolderHolder(key string) string { - lock, holder := claimProgramFolder(key, "") - if lock != nil { - _ = filelock.Unlock(lock) - _ = lock.Close() - } - return holder -} - // release lets the folder go. func (f *ProgramFolder) release() { if f.lock == nil { @@ -1040,8 +990,8 @@ func settleOwedProgramFolder(keep string) (ProgramFolderEnd, bool) { if !ok || owed.Ended != "" || filepath.Clean(owed.Keep) != filepath.Clean(keep) { continue } - lock, holder := claimProgramFolder(owed.key, owed.Program+", settling a run codeaf closed under") - if holder != "" || lock == nil { + lock, _, busy := claimProgramFolder(owed.key, owed.Program+", settling a run codeaf closed under") + if busy || lock == nil { // A HOLD SOMEBODY ELSE HAS, or one nobody can take, is a folder this // reopen cannot know is idle: it is left for the next codeaf that can. return ProgramFolderEnd{}, false diff --git a/internal/session/programhold.go b/internal/session/programhold.go new file mode 100644 index 000000000..479dd6a46 --- /dev/null +++ b/internal/session/programhold.go @@ -0,0 +1,214 @@ +package session + +// THE HOLD A PROGRAM'S RUN HAS ON ITS FOLDER, and everything that asks it. +// +// A program works in the person's folder itself (programfolder.go), so while +// it runs that folder is its. The hold is how every other road in codeaf knows: +// a flock on a file under the state root named for the folder, which dies with +// the process that took it however that process dies, and which holds between +// two windows, two conversations in one engine and a shell alike, because a +// flock is per open file. +// +// A FOLDER IS BUSY WHEN A HELD FOLDER IS IT, HOLDS IT, OR IS INSIDE IT. A run +// on a plain folder of projects checkpoints everything under it and, once it +// has submitted, puts back whatever changed there and removes whatever was +// added; a second run in one of those projects had its edits reverted and its +// new files deleted under it, while both held their own exact path and each +// was sure it was alone. So the question is asked of the tree: the folders +// above a path by name, and every held folder below it by reading what each +// hold says it holds. + +import ( + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" +) + +// programHold is one live hold on a folder, the two things a refusal names: +// the folder, resolved, and whose run holds it (`senior-dev, task 4 (Fix the +// parser)`, or `senior-dev, a run started at a shell`). +type programHold struct { + dir string + holder string +} + +// programHoldTries and programHoldPause are how often, and how far apart, a +// run asks again for a hold it found taken. A door that only wants to know +// whether a folder is busy takes the hold's file for the instant of asking +// ([programHoldAt]), and a run that asks at that same instant would read +// that as a run holding it; a real run holds its folder for minutes, so a +// few short asks cost a refused run nothing it would notice. +const ( + programHoldTries = 5 + programHoldPause = 20 * time.Millisecond +) + +// claimProgramFolder takes the hold on one folder for a program's run and +// writes into it whose run it is and which folder, so a second run is told. +// It answers the held lock; or, when the folder is busy — held itself, or +// inside or around a folder held — the hold in the way; or a nil lock and no +// hold, which is a filesystem that takes no locks, and the run goes ahead +// unheld, which is what every run did before the hold existed. +// +// THE FOLDER'S OWN HOLD IS TAKEN BEFORE THE TREE AROUND IT IS READ, and kept +// while it is. Two runs readying a parent and a child at the same moment each +// hold their own and then look for the other, so at least one of them finds +// the other and refuses: they can both be refused, and never both start. +func claimProgramFolder(key, holder string) (*os.File, programHold, bool) { + directory := home.Join("v3", programFolderDir) + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, programHold{}, false + } + path := programHoldFile(key) + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, programHold{}, false + } + for try := 1; ; try++ { + err = filelock.Lock(file, true, true) + if err == nil || !isLockHeld(err) || try == programHoldTries { + break + } + time.Sleep(programHoldPause) + } + if err != nil { + _ = file.Close() + if !isLockHeld(err) { + return nil, programHold{}, false + } + return nil, readProgramHold(path, key), true + } + _ = file.Truncate(0) + _, _ = file.WriteAt([]byte(strings.TrimSpace(holder)+"\n"+key), 0) + if near, busy := programHoldNear(key, path); busy { + _ = filelock.Unlock(file) + _ = file.Close() + return nil, near, true + } + return file, programHold{}, false +} + +// programFolderHolder is who holds a folder now, or a folder around it or +// inside it, "" when nobody does, for a door that only wants to know. +func programFolderHolder(key string) string { + hold, busy := programHoldNear(key, "") + if !busy { + return "" + } + return hold.holder +} + +// programHoldNear answers the live hold on key, on a folder above it, or on a +// folder inside it, leaving the hold file skip out (the asker's own). It takes +// no hold of its own for longer than it takes to ask. +func programHoldNear(key, skip string) (programHold, bool) { + key = filepath.Clean(strings.TrimSpace(key)) + if key == "" || key == "." { + return programHold{}, false + } + if hold, busy := programHoldOver(key, skip); busy { + return hold, true + } + files, _ := filepath.Glob(filepath.Join(home.Join("v3", programFolderDir), "*.lock")) + for _, path := range files { + if path == skip { + continue + } + // THE FILE IS READ BEFORE IT IS ASKED, so a hold on a folder that has + // nothing to do with this one is never touched at all. + if held := readProgramHold(path, ""); held.dir == "" || !strictlyInside(held.dir, key) { + continue + } + if hold, busy := programHoldAt(path, ""); busy { + return hold, true + } + } + return programHold{}, false +} + +// programHoldOver answers the live hold on path or on a folder above it, +// leaving the hold file skip out. It asks by name — one file for each folder +// on the way up — so it is cheap enough to ask before every write a tool +// makes ([programHoldGuard]). +func programHoldOver(path, skip string) (programHold, bool) { + for dir := filepath.Clean(path); ; { + if file := programHoldFile(dir); file != skip { + if hold, busy := programHoldAt(file, dir); busy { + return hold, true + } + } + parent := filepath.Dir(dir) + if parent == dir { + return programHold{}, false + } + dir = parent + } +} + +// programHoldAt asks one hold file whether a run holds it, by taking it, +// shared, for the instant of asking: a run's hold is exclusive, so a shared +// one is refused exactly when a run has it. key is the folder the file is +// named for, when the asker knows it. +func programHoldAt(path, key string) (programHold, bool) { + file, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return programHold{}, false + } + defer file.Close() + if err := filelock.Lock(file, false, true); err != nil { + if !isLockHeld(err) { + return programHold{}, false + } + return readProgramHold(path, key), true + } + _ = filelock.Unlock(file) + return programHold{}, false +} + +// readProgramHold is what a hold file says: whose run, and which folder. A +// file written before it said the folder is read by the run's record beside +// it ([programFolderRecord]), and failing that by key. +func readProgramHold(path, key string) programHold { + body, _ := os.ReadFile(path) + holder, dir, _ := strings.Cut(string(body), "\n") + hold := programHold{dir: strings.TrimSpace(dir), holder: strings.TrimSpace(holder)} + if hold.dir == "" { + if record, ok := readProgramFolderAt(strings.TrimSuffix(path, ".lock") + ".json"); ok { + hold.dir = record.key + } else { + hold.dir = key + } + } + if hold.holder == "" { + hold.holder = "another program run" + } + return hold +} + +// programHoldFile is the file one folder's hold is taken on. +func programHoldFile(key string) string { + return filepath.Join(home.Join("v3", programFolderDir), programFolderName(key)+".lock") +} + +// programFolderBusy is the refusal for a folder a program's run cannot have +// because another run holds it, or holds a folder around it or inside it. +func programFolderBusy(dir string, hold programHold) string { + return dir + " is busy: " + hold.holder + ", is working in " + hold.where(dir) + + ", and one folder takes one program run at a time; ask again when that run has ended" +} + +// where is the held folder as a sentence about dir names it: "it" when it is +// dir, and the folder itself, with how the two stand, when it is not. +func (h programHold) where(dir string) string { + switch { + case h.dir == "" || h.dir == canonicalPath(dir): + return "it" + case strictlyInside(h.dir, canonicalPath(dir)): + return h.dir + ", which is inside it" + } + return h.dir + ", which holds it" +} diff --git a/internal/session/programhold_test.go b/internal/session/programhold_test.go new file mode 100644 index 000000000..87bfa82e1 --- /dev/null +++ b/internal/session/programhold_test.go @@ -0,0 +1,95 @@ +package session + +// THE HOLD A PROGRAM'S RUN HAS ON ITS FOLDER (programhold.go), in real folders +// and real git: a folder is busy when a held folder is it, holds it or is +// inside it, and a folder nobody holds is left exactly as it was by every +// door that asks. + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// holdFolder readies dir for a run of the fake program and answers the +// folder, held until the test ends. +func holdFolder(t *testing.T, dir, title string) *ProgramFolder { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: dir, Title: title, Holder: "task 4 (" + title + ")", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { folder.Finish("") }) + return folder +} + +// prepareErr is the refusal a run readied on dir meets, "" when it was let +// through (and then finished at once). +func prepareErr(t *testing.T, dir string) string { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: dir, Title: "The second run", Holder: "task 5 (The second run)", Keep: t.TempDir()}) + if err != nil { + return err.Error() + } + folder.Finish("") + return "" +} + +// ONE FOLDER TAKES ONE PROGRAM RUN, AND SO DO THE FOLDERS INSIDE IT. A run on a +// plain folder of projects puts back whatever changed under it once it has +// submitted, so a second run in one of those projects had its work reverted +// under it while each held only its own exact path. A run on a folder inside a +// held one, or around one, is refused naming the run and the folder it holds, +// at its start and at its card alike; two runs side by side are not. +func TestAProgramRunIsRefusedAFolderInsideOrAroundAHeldOne(t *testing.T) { + work := t.TempDir() + project := filepath.Join(work, "proj") + sibling := filepath.Join(work, "other") + for _, dir := range []string{project, sibling} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + t.Run("inside", func(t *testing.T) { + held := holdFolder(t, work, "Tidy the projects") + want := project + " is busy: fake, task 4 (Tidy the projects), is working in " + canonicalPath(work) + + ", which holds it, and one folder takes one program run at a time; ask again when that run has ended" + if got := prepareErr(t, project); got != want { + t.Fatalf("a run inside a held folder = %q, want %q", got, want) + } + folder := project + if got := programGroundRefusal(testPrograms("fake")[0], &folder); got != want { + t.Fatalf("its card = %q, want %q", got, want) + } + held.Finish("") + if got := prepareErr(t, project); got != "" { + t.Fatalf("the folder was still refused after the run around it ended: %q", got) + } + }) + t.Run("around", func(t *testing.T) { + held := holdFolder(t, project, "Fix the parser") + want := work + " is busy: fake, task 4 (Fix the parser), is working in " + canonicalPath(project) + + ", which is inside it, and one folder takes one program run at a time; ask again when that run has ended" + if got := prepareErr(t, work); got != want { + t.Fatalf("a run around a held folder = %q, want %q", got, want) + } + if got := prepareErr(t, sibling); got != "" { + t.Fatalf("a run beside a held folder was refused: %q", got) + } + held.Finish("") + }) + t.Run("a repository inside", func(t *testing.T) { + repo := newTestRepo(t) + parent := filepath.Dir(repo) + held := holdFolder(t, parent, "Tidy the projects") + got := prepareErr(t, repo) + if !strings.HasPrefix(got, repo+" is busy: fake, task 4 (Tidy the projects), is working in "+canonicalPath(parent)+", which holds it") { + t.Fatalf("a repository inside a held folder = %q", got) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("a refused repository was switched to %q", head) + } + held.Finish("") + }) +} diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index de39ba765..4f9f8975e 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -414,8 +414,8 @@ func programGroundRefusal(program delegate.Delegate, dir *string) string { return refusal } *dir = folder - if holder := programFolderHolder(canonicalPath(folder)); holder != "" { - return programFolderBusy(folder, holder) + if hold, busy := programHoldNear(canonicalPath(folder), ""); busy { + return programFolderBusy(folder, hold) } if repo { return programCheckoutInTheWay(folder, program.Notes) From 2dec537cd2d5c162d9975ecbb3445a0dec899bb3 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:49:06 -0400 Subject: [PATCH 133/195] session, manual: nothing else of codeaf's writes in a folder a program's run holds Without the copy a program works in the person's live folder, and the hold on it only kept out a second program. The chat's own write and edit tools, a quick task, a typed or proposed ordinary task, and the landing of one already running all still wrote there. Once senior-dev had submitted, its restore put back what they changed and deleted what they added, with no copy kept; an ordinary task merged into the program's branch was undone while its row said it landed; and a task cut from the held repository recorded the program's branch as the person's. Now, while a program holds a folder from any conversation, window or shell, every tool that saves a file at a path it names refuses a path inside it, naming the file, the folder and the run (reads stay open). An ordinary task whose folder is it, inside it or around it is refused before it starts, at the card, a typed `/task`, a quick task, a node's start and the run road. A task already running keeps its branch rather than merge, a mirror is not laid, and `/land` waits. The receipt tells the chat the folder is the program's until it ends. `bash` and the person's own editor are not fenced, and the manual says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/design/delegate/DESIGN.md | 8 +- internal/manual/chat/delegates.md | 3 +- internal/manual/chat/how-tasks-run.md | 3 +- internal/manual/chat/senior-dev.md | 24 ++- internal/manual/chat_test.go | 2 + internal/session/delegate_door.go | 11 ++ internal/session/delegate_landing_test.go | 4 +- internal/session/hooks.go | 6 + internal/session/hooks_test.go | 6 +- internal/session/program_ground_test.go | 4 +- internal/session/programfolder_git_test.go | 2 +- internal/session/programhold.go | 123 ++++++++++++++ internal/session/programhold_test.go | 180 +++++++++++++++++++++ internal/session/standingtree.go | 5 + internal/session/task_depends_kept.go | 7 + internal/session/task_person.go | 5 + internal/session/task_quick.go | 5 + internal/session/task_run.go | 16 ++ internal/session/task_run_belt.go | 7 + internal/session/taskstands.go | 16 ++ 20 files changed, 424 insertions(+), 13 deletions(-) diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md index 2b05bd98e..d647a4e91 100644 --- a/docs/design/delegate/DESIGN.md +++ b/docs/design/delegate/DESIGN.md @@ -44,9 +44,11 @@ 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 its own working copy, under your -dollar and time limits, shows on the rail, can be stopped, and lands on your -branch when it ends. The chat is not blocked while it runs. Inside codeaf, a +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. diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 87f710f1d..1f8fdef62 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -109,7 +109,8 @@ names as its `ground`, or this conversation's own folder when it names none (a t 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. +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 diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index d1b2aa223..0fb3d5adb 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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index ea81eed75..dd079d1a8 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -211,7 +211,7 @@ which holds your home folder, so codeaf cut no branch there and committed nothin 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. +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 @@ -224,6 +224,28 @@ A shell run used to stop at once there with `workspace is not a git repository: <folder>; run with --in-place to work in a plain folder`. It no longer does: `--in-place` is passed for you. +## 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: `<file> is in <folder>, 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: `<folder> 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 <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, diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 343befa26..918228804 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -974,6 +974,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 6a4f8d7eb..c00acf8c8 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -189,10 +189,21 @@ func (c Config) delegateGuides() string { // IT NAMES THE FOLDER. A receipt that said "a copy" and "the folder itself" // without saying which let a model that had named ~/Desktop/pong read that its // program was there while it had been handed the person's home folder. +// +// AND IT SAYS THE FOLDER IS THE PROGRAM'S UNTIL IT ENDS ([programHoldGuard]). +// The receipt's next sentence invites the model to carry on with other work, +// and a model that did it by writing into the program's folder was refused one +// file at a time; told once here, it works elsewhere or waits. func delegateReceipt(ground string, via delegate.Delegate, record *TaskCopyRecord) string { if !via.LandsTree() { return "It is " + via.Name + "'s: it works alone, and its answer arrives when it ends." } + return delegateFolderReceipt(ground, via, record) + " Until it ends, codeaf's own tools write nothing in " + ground + "." +} + +// delegateFolderReceipt is where a program that edits files works, as its +// receipt says it ([delegateReceipt]). +func delegateFolderReceipt(ground string, via delegate.Delegate, record *TaskCopyRecord) string { if record == nil || record.Branch == "" { return "It is " + via.Name + "'s: it works alone in " + ground + " itself, which has no git history, so its changes are there as it makes them." } diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index 5698017d0..03a08a164 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -175,14 +175,14 @@ func TestAProgramsReceiptSaysWhereTheWorkWillBeAndPromisesNoMerge(t *testing.T) tree := testPrograms("fake")[0] repo, plain := "/r/repo", "/r/plain" record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "main", HomeSha: "0123456789abcdef"} - if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in /r/repo itself, on a new branch task/pong-abc123; your branch main does not move, and when it ends task/pong-abc123 stays checked out there with its work."; got != want { + if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in /r/repo itself, on a new branch task/pong-abc123; your branch main does not move, and when it ends task/pong-abc123 stays checked out there with its work. Until it ends, codeaf's own tools write nothing in /r/repo."; got != want { t.Fatalf("the receipt for a repository = %q, want %q", got, want) } detached := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", HomeSha: "0123456789abcdef"} if got := delegateReceipt(repo, tree, detached); !strings.Contains(got, "; the commit 0123456789ab does not move") { t.Fatalf("the receipt for a detached checkout = %q", got) } - if got := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}); got != "It is fake's: it works alone in /r/plain itself, which has no git history, so its changes are there as it makes them." { + if got := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}); got != "It is fake's: it works alone in /r/plain itself, which has no git history, so its changes are there as it makes them. Until it ends, codeaf's own tools write nothing in /r/plain." { t.Fatalf("the receipt for a plain folder = %q", got) } reader := tree diff --git a/internal/session/hooks.go b/internal/session/hooks.go index a023128ae..5ea2bf35f 100644 --- a/internal/session/hooks.go +++ b/internal/session/hooks.go @@ -234,6 +234,12 @@ func (a *Agent) controlPlaneFor() *controlPlane { // left to say about who is holding the tree — and it is a no-op in every // session that has never groomed a task, which is most of them. plane.register(treeClaimGuard{agent: a}) + // AND WHETHER A PROGRAM'S RUN IS WORKING THERE, which is the same question + // asked of a different holder (programhold.go): not a node of this + // conversation's graph, but a run that holds the person's folder itself, + // from this conversation or any other window or shell. It asks no graph, so + // it binds a session that never groomed a task as much as one that did. + plane.register(programHoldGuard{agent: a}) // AND WHERE A TASK IS STANDING, which is the path half of the same question // (taskoutside.go): not whose work a command would take, but which directory // it is aimed at. It is registered BEFORE the git guard below and the order diff --git a/internal/session/hooks_test.go b/internal/session/hooks_test.go index 8688861a5..7c694e6d5 100644 --- a/internal/session/hooks_test.go +++ b/internal/session/hooks_test.go @@ -106,14 +106,16 @@ func TestTheControlPlaneRegistersTheFourMechanismsInOrder(t *testing.T) { // nothing until an agent is built with a scope (orchestrate.go), or some // node is running in a tree this agent is writing in (treehold.go). The // claim comes after the scope because the scope is about the writer and - // the claim is about everybody else. + // the claim is about everybody else. The program hold beside it is inert + // the same way until a program's run holds a folder this agent writes in + // (programhold.go). // // AND THE GROUND COMES BEFORE THE GIT GUARD, which is the one order in // this list that a person would notice being wrong: the git guard's // sentences are about a task's OWN copy, so a command aimed at another // directory has to meet the path law first or be refused with a // paragraph that is false about every path in it (taskoutside.go). - {"pre-action", planeNames(plane.preAction), []string{"approval", "changes", "write-scope", "tree-claim", "task-ground", "task-git"}}, + {"pre-action", planeNames(plane.preAction), []string{"approval", "changes", "write-scope", "tree-claim", "program-hold", "task-ground", "task-git"}}, {"post-feedback", planeNames(plane.postFeedback), []string{"changes", "writes", "loop"}}, } { if !sameNames(expected.got, expected.want) { diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go index 609674fe9..766e556fd 100644 --- a/internal/session/program_ground_test.go +++ b/internal/session/program_ground_test.go @@ -90,10 +90,10 @@ func TestAProgramsReceiptNamesItsFolder(t *testing.T) { repo := newTestRepo(t) plain := t.TempDir() record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "work"} - if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in "+repo+" itself, on a new branch task/pong-abc123; your branch work does not move, and when it ends task/pong-abc123 stays checked out there with its work."; got != want { + if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in "+repo+" itself, on a new branch task/pong-abc123; your branch work does not move, and when it ends task/pong-abc123 stays checked out there with its work. Until it ends, codeaf's own tools write nothing in "+repo+"."; got != want { t.Fatalf("the receipt for a repository = %q, want %q", got, want) } - if got, want := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}), "It is fake's: it works alone in "+plain+" itself, which has no git history, so its changes are there as it makes them."; got != want { + if got, want := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}), "It is fake's: it works alone in "+plain+" itself, which has no git history, so its changes are there as it makes them. Until it ends, codeaf's own tools write nothing in "+plain+"."; got != want { t.Fatalf("the receipt for a plain folder = %q, want %q", got, want) } got := delegateStartedReceipt(3, "Pong", "", delegateReceipt(plain, tree, nil), "") diff --git a/internal/session/programfolder_git_test.go b/internal/session/programfolder_git_test.go index 7968f2efb..192096b0d 100644 --- a/internal/session/programfolder_git_test.go +++ b/internal/session/programfolder_git_test.go @@ -179,7 +179,7 @@ func TestTheReceiptSaysWhenThePersonsBranchHasAlreadyMoved(t *testing.T) { moved := moveBranch(t, repo, "work") record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "work", HomeSha: start} want := "It is fake's: it works alone in " + repo + " itself, on a new branch task/pong-abc123; your branch work has already moved, from " + - shortSha(start) + " to " + shortSha(moved) + ", and codeaf does not move it, and when it ends task/pong-abc123 stays checked out there with its work." + shortSha(start) + " to " + shortSha(moved) + ", and codeaf does not move it, and when it ends task/pong-abc123 stays checked out there with its work. Until it ends, codeaf's own tools write nothing in " + repo + "." if got := delegateReceipt(repo, testPrograms("fake")[0], record); got != want { t.Fatalf("the receipt = %q, want %q", got, want) } diff --git a/internal/session/programhold.go b/internal/session/programhold.go index 479dd6a46..b5cd43edd 100644 --- a/internal/session/programhold.go +++ b/internal/session/programhold.go @@ -19,11 +19,15 @@ package session // hold says it holds. import ( + "context" + "encoding/json" "os" "path/filepath" "strings" "time" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/home" ) @@ -212,3 +216,122 @@ func (h programHold) where(dir string) string { } return h.dir + ", which holds it" } + +// ── nothing else of codeaf's writes in a held folder ──────────────────────── +// +// WITHOUT THE COPY, A PROGRAM WORKS IN THE PERSON'S LIVE FOLDER, and what +// anything else writes there meanwhile becomes the program's to act on. Once +// senior-dev has submitted it checks its frozen tree, and a tree that moved is +// put back — `checkout --force`, `reset`, `clean -fd` — which reverts a file +// the chat edited and deletes one it wrote, with no copy kept; an ordinary +// task merged into it mid-run is undone the same way while its row says it +// landed; and whatever survives is swept into the run's finishing commit as +// the program's work. So while a program holds a folder, from any conversation, +// window or shell: +// +// - the chat's own file tools refuse a path inside it ([programHoldGuard]), +// and reading stays open; +// - an ordinary task — a proposal's card, a typed `/task`, a quick task, a +// node starting on the session's own graph or on the run road — whose +// folder is inside it, or holds it, is refused before it starts +// ([programHoldRefusal]); +// - a task that was already running lands beside it rather than into it: its +// branch kept, its copy not laid, a `/land` refused. +// +// `bash` IS NOT FENCED, and neither is the person's own editor: a command's +// effects are whatever it did, and a guard that pattern-matched commands would +// promise what it cannot keep ([treeClaimGuard] says the same). The manual says +// so plainly (senior-dev.md), and that edits made there join the run's work. + +// programHoldGuard refuses a write by one of codeaf's own file tools into a +// folder a program's run holds. It is a pre-action citizen for the reason +// every guard here is one: [Agent.executeTool] is the single door every call +// passes through (hooks.go). +// +// IT BINDS EVERY HAND THAT PUTS A FILE ON THE PERSON'S DISK AT A PATH THE CALL +// NAMES ([savingPath]): write and edit, edit_video's writing actions, and the +// generated picture, music, video and speech a path was given for. A +// generation that names no path lands in this session's own folders, which no +// program holds. +type programHoldGuard struct{ agent *Agent } + +func (programHoldGuard) Name() string { return "program-hold" } + +func (g programHoldGuard) PreAction(_ context.Context, _ *episode, _ *eventHub, call ai.ToolCall) (ai.ToolCall, toolResult, bool) { + path, shown, ok := g.agent.savingPath(call) + if !ok { + return call, toolResult{}, true + } + hold, busy := programHoldOver(canonicalPath(path), "") + if !busy { + return call, toolResult{}, true + } + return call, toolResult{text: programHoldWriteRefusal(shown, hold), isError: true}, false +} + +// programHoldWriteRefusal is what the model reads instead of a write into a +// held folder: the file, the folder, whose run holds it, that nothing was +// written, and the two things that work. +func programHoldWriteRefusal(shown string, hold programHold) string { + return shown + " is in " + hold.dir + ", where " + hold.holder + + ", is working, so nothing was written; wait for that run to end, or stop it, then write there" +} + +// savingPath is the absolute path one call is about to put a file at, and the +// same path as a person reads it, for every hand in [savingTools] whose call +// both writes ([producedAFile]) and names where. It resolves a relative path +// against the workspace the way [Agent.mutatingPath] does, which answers for +// the hands it knows. +func (a *Agent) savingPath(call ai.ToolCall) (string, string, bool) { + if path, shown, ok := a.mutatingPath(call); ok { + return path, shown, true + } + name := call.Function.Name + if _, known := mutatingTools[name]; known || !producedAFile(name, call.Function.Arguments) { + return "", "", false + } + var args struct { + Path string `json:"path"` + } + if err := decodeToolArguments(json.RawMessage(call.Function.Arguments), &args); err != nil { + return "", "", false + } + path, workspace := strings.TrimSpace(args.Path), strings.TrimSpace(a.config.Workspace) + if path == "" || (workspace == "" && !filepath.IsAbs(path)) { + return "", "", false + } + if !filepath.IsAbs(path) { + return filepath.Clean(filepath.Join(workspace, path)), filepath.ToSlash(filepath.Clean(path)), true + } + return filepath.Clean(path), filepath.Clean(path), true +} + +// programHoldRefusal is why an ordinary task may not work in dir now — a +// program's run holds it, a folder around it, or a folder inside it — in one +// sentence naming the run and saying what to do; "" when nothing holds it. +func programHoldRefusal(dir string) string { + dir = strings.TrimSpace(dir) + if dir == "" { + return "" + } + hold, busy := programHoldNear(canonicalPath(dir), "") + if !busy { + return "" + } + return dir + " is busy: " + hold.holder + ", is working in " + hold.where(dir) + + ", and nothing else of codeaf's works there until that run has ended; wait for it, or stop it, then ask again" +} + +// standHeldRefusal is [programHoldRefusal] for where a task would stand, and "" +// for a stand that only reads its ground: a REFERENCE works in a folder of its +// own and writes nothing where it looks. +func standHeldRefusal(stand taskStand, workspace string) string { + if stand.mode == TaskModeReference { + return "" + } + dir := stand.dir + if strings.TrimSpace(dir) == "" { + dir = workspace + } + return programHoldRefusal(dir) +} diff --git a/internal/session/programhold_test.go b/internal/session/programhold_test.go index 87bfa82e1..9609c98c5 100644 --- a/internal/session/programhold_test.go +++ b/internal/session/programhold_test.go @@ -6,10 +6,15 @@ package session // door that asks. import ( + "context" "os" "path/filepath" "strings" "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/codeaf/internal/approval" ) // holdFolder readies dir for a run of the fake program and answers the @@ -93,3 +98,178 @@ func TestAProgramRunIsRefusedAFolderInsideOrAroundAHeldOne(t *testing.T) { held.Finish("") }) } + +// holdOf is the hold a refusal names for a run holdFolder readied on dir. +func holdOf(dir, title string) programHold { + return programHold{dir: canonicalPath(dir), holder: "fake, task 4 (" + title + ")"} +} + +// THE CHAT'S OWN FILE TOOLS DO NOT WRITE IN A FOLDER A PROGRAM'S RUN HOLDS. +// senior-dev works in the person's folder itself, and once it has submitted it +// puts back whatever changed there and removes whatever was added; a file the +// chat wrote meanwhile was deleted with no copy kept, while the chat had told +// the person it was written. Every hand that puts a file at a path it names is +// refused, through the real tool door, naming the file, the folder and the +// run; reading stays open, a path outside the folder is written, and the same +// write goes through once the run has ended. +func TestTheChatsFileToolsAreRefusedAFolderAProgramHolds(t *testing.T) { + repo := newTestRepo(t) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = repo + config.ApprovalPolicy = &approval.Policy{Default: approval.ActionAllow} + }) + held := holdFolder(t, repo, "Fix the parser") + result := agent.executeTool(context.Background(), agent.newEpisode(), nil, + withdrawnCall("c1", "write", `{"path":"NOTES.md","content":"the chat's note\n"}`), "") + if want := programHoldWriteRefusal("NOTES.md", holdOf(repo, "Fix the parser")); !result.isError || result.text != want { + t.Fatalf("the chat's write = %q (error %v), want %q", result.text, result.isError, want) + } + if _, err := os.Stat(filepath.Join(repo, "NOTES.md")); !os.IsNotExist(err) { + t.Fatalf("the refused write was written: %v", err) + } + if read := agent.executeTool(context.Background(), agent.newEpisode(), nil, + withdrawnCall("c2", "read", `{"path":"shared.txt"}`), ""); read.isError { + t.Fatalf("reading in a held folder was refused: %q", read.text) + } + guard := programHoldGuard{agent: agent} + for _, call := range []ai.ToolCall{ + scopedCall("edit", filepath.Join(repo, "shared.txt")), + withdrawnCall("c3", "edit_video", `{"action":"join","path":"cut.mp4"}`), + withdrawnCall("c4", "generate_image", `{"prompt":"a harbour","path":"art/harbour"}`), + withdrawnCall("c5", "speak", `{"text":"hello","path":"clips/hello"}`), + withdrawnCall("c6", "generate_music", `{"description":"a tune","path":"tune"}`), + withdrawnCall("c7", "generate_video", `{"prompt":"a boat","path":"boat"}`), + } { + if _, refusal, ok := guard.PreAction(context.Background(), nil, nil, call); ok || !refusal.isError || !strings.Contains(refusal.text, "where fake, task 4 (Fix the parser), is working, so nothing was written") { + t.Fatalf("%s into the held folder = %+v (let through %v)", call.Function.Name, refusal, ok) + } + } + elsewhere := t.TempDir() + for _, call := range []ai.ToolCall{ + scopedCall("write", filepath.Join(elsewhere, "notes.md")), + withdrawnCall("c8", "generate_image", `{"prompt":"a harbour"}`), + withdrawnCall("c9", "edit_video", `{"action":"measure","path":"cut.mp4"}`), + withdrawnCall("c10", "bash", `{"command":"echo hi > note.txt"}`), + } { + if _, refusal, ok := guard.PreAction(context.Background(), nil, nil, call); !ok { + t.Fatalf("%s was refused though it writes nothing in the held folder: %q", call.Function.Name, refusal.text) + } + } + held.Finish("") + if again := agent.executeTool(context.Background(), agent.newEpisode(), nil, + withdrawnCall("c11", "write", `{"path":"NOTES.md","content":"the chat's note\n"}`), ""); again.isError { + t.Fatalf("the write was still refused after the run ended: %q", again.text) + } +} + +// AN ORDINARY TASK IS REFUSED A FOLDER A PROGRAM'S RUN HOLDS, OR ONE AROUND +// IT, BEFORE IT STARTS. A task cut from the held repository recorded the +// program's branch as the person's, sealed its unfinished edits in as theirs, +// and merged back into the live checkout under it. Every door says so in one +// sentence: a proposal before its card, a typed `/task`, a quick task, a node +// starting on the session's own graph, and a run on the run road. A reference, +// which only reads its ground, is not refused, and a folder nobody holds is +// untouched by any of it. +func TestAnOrdinaryTaskIsRefusedAFolderAProgramHolds(t *testing.T) { + repo := newTestRepo(t) + registerBeltRunEngine(t, newBeltRunDouble("done")) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + }) + spec := taskSpec{ground: repo, brief: "fix the parser", deliverable: "the parser fixed in parser.go", acceptance: "its tests pass"} + if stand := agent.resolveTaskGround(spec); stand.refusal != "" || standHeldRefusal(stand, repo) != "" { + t.Fatalf("a folder nobody holds was refused: %+v", stand) + } + holdFolder(t, repo, "Fix the parser") + want := repo + " is busy: fake, 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" + if stand := agent.resolveTaskGround(spec); stand.refusal != canonicalPath(repo)+strings.TrimPrefix(want, repo) { + t.Fatalf("the proposal's refusal = %q, want %q", stand.refusal, want) + } + if _, _, _, err := agent.StartTask(context.Background(), "fix the parser", false); err == nil || !strings.Contains(err.Error(), " is busy: fake, task 4 (Fix the parser), is working in it, and nothing else of codeaf's works there") { + t.Fatalf("a typed /task = %v, want it refused", err) + } + if _, _, refusal := agent.admitQuick(quickAsk{line: "tidy the readme"}); refusal.said != want { + t.Fatalf("a quick task = %q, want %q", refusal.said, want) + } + graph := &TaskGraph{nodes: map[uint64]*TaskNode{}} + node := &TaskNode{graph: graph, id: 9, Ground: repo, Mode: TaskModeWorktree} + if _, err := prepareTaskTreeForNode(context.Background(), agent.config.Place, repo, "a1a1a1a1a1a1a1a1", node); err == nil || err.Error() != want { + t.Fatalf("a node starting there = %v, want %q", err, want) + } + if err := agent.startKnownTaskRun(context.Background(), agent.graph().reserve(), "Fix", "fix the parser", nil, taskStand{dir: repo, mode: TaskModeWorktree}, ""); err == nil || err.Error() != want { + t.Fatalf("a run on the run road = %v, want %q", err, want) + } + if branches := strings.TrimSpace(gitOut(t, repo, "worktree", "list", "--porcelain")); strings.Count(branches, "worktree ") != 1 { + t.Fatalf("a refused task cut a working copy from the held repository:\n%s", branches) + } + if refusal := standHeldRefusal(taskStand{dir: repo, mode: TaskModeReference}, repo); refusal != "" { + t.Fatalf("a reference, which only reads, was refused: %q", refusal) + } + around := filepath.Dir(repo) + if refusal := programHoldRefusal(around); !strings.Contains(refusal, "is working in "+canonicalPath(repo)+", which is inside it") { + t.Fatalf("a task on a folder around the held one = %q", refusal) + } +} + +// A TASK THAT WAS ALREADY RUNNING LANDS BESIDE A HELD FOLDER, NOT INTO IT. Its +// branch is kept rather than merged into the program's live checkout — where +// the program's restore would have undone it while its row said it landed — +// a mirror is not laid, and a `/land` of the chat's own copy is refused with +// the copy left whole; each goes through once the run has ended. +func TestATaskThatWasRunningLandsBesideAHeldFolder(t *testing.T) { + t.Run("a branch", func(t *testing.T) { + repo := newTestRepo(t) + tree, err := prepareTaskTree(Place{Dir: t.TempDir()}, repo, "b1b1b1b1b1b1b1b1", 1, "update the changelog") + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(tree.dir, "CHANGELOG.md"), "a line\n") + held := holdFolder(t, repo, "Fix the parser") + programTip := strings.TrimSpace(gitOut(t, repo, "rev-parse", held.Branch)) + merge, said, _, _ := tree.comeHome("update the changelog", []string{"CHANGELOG.md"}, false) + if merge != mergeKept || !strings.Contains(said, "its branch "+tree.branch+" was kept: fake, task 4 (Fix the parser), is working in it — bring it in when that run has ended") { + t.Fatalf("the landing = %q %q, want its branch kept beside the held folder", merge, said) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", held.Branch)); tip != programTip { + t.Fatalf("the task merged into the program's branch: %s, want %s", tip, programTip) + } + if files := gitOut(t, repo, "ls-tree", "--name-only", tree.branch); !strings.Contains(files, "CHANGELOG.md") { + t.Fatalf("the kept branch does not hold the work:\n%s", files) + } + }) + t.Run("a mirror", func(t *testing.T) { + plain, copy := t.TempDir(), t.TempDir() + writeFile(t, filepath.Join(copy, "notes.md"), "the family's notes\n") + held := holdFolder(t, plain, "Fix the parser") + mirror := taskTree{dir: copy, ground: plain, mode: TaskModeMirror} + merge, said, _, refusal := mirror.comeHome("notes", []string{"notes.md"}, false) + if merge != mergeAborted || refusal != refusedByTheWork || !strings.HasPrefix(said, "its work was not laid into "+plain+" and is kept in "+copy+": ") { + t.Fatalf("the mirror's landing = %q %q %v, want it kept in its copy", merge, said, refusal) + } + if _, err := os.Stat(filepath.Join(plain, "notes.md")); !os.IsNotExist(err) { + t.Fatalf("the mirror was laid into the held folder: %v", err) + } + held.Finish("") + if merge, said, _, _ := mirror.comeHome("notes", []string{"notes.md"}, false); merge != mergeInPlace { + t.Fatalf("the mirror's landing once the run ended = %q %q", merge, said) + } + }) + t.Run("a /land", func(t *testing.T) { + repo := newTestRepo(t) + agent, _, _ := standingLab(t, repo) + writeThrough(t, agent, filepath.Join(repo, "shared.txt"), "the changed line\n") + held := holdFolder(t, repo, "Fix the parser") + if _, err := agent.Land(repo); err == nil || !strings.Contains(err.Error(), " is busy: fake, task 4 (Fix the parser), is working in it") { + t.Fatalf("a /land into the held folder = %v", err) + } + if waiting := agent.UnlandedChanges(); len(waiting) != 1 { + t.Fatalf("the refused landing dropped the copy: %+v", waiting) + } + held.Finish("") + if landing, err := agent.Land(repo); err != nil || landing.Merged != mergeMerged { + t.Fatalf("the /land once the run ended = %+v, %v", landing, err) + } + }) +} diff --git a/internal/session/standingtree.go b/internal/session/standingtree.go index 854faac07..9437f4543 100644 --- a/internal/session/standingtree.go +++ b/internal/session/standingtree.go @@ -698,6 +698,11 @@ func (a *Agent) Land(folder string) (FolderLanding, error) { if len(tree.Wrote) == 0 { return FolderLanding{}, fmt.Errorf("nothing has been changed in %s", filepath.Base(tree.Folder)) } + // A FOLDER A PROGRAM'S RUN HOLDS TAKES NO LANDING (programhold.go): the copy + // and its record stay exactly as they are, for a `/land` once that run ends. + if refusal := programHoldRefusal(tree.Folder); refusal != "" { + return FolderLanding{}, errors.New(refusal) + } landing := FolderLanding{Folder: tree.Folder, Name: filepath.Base(tree.Folder), Files: append([]string{}, tree.Wrote...)} // THE TASK TREE IS BUILT HERE AND HELD NOWHERE, because it is the argument // the landing takes rather than a second record of the copy: this file's diff --git a/internal/session/task_depends_kept.go b/internal/session/task_depends_kept.go index 202f0b350..9d378c6ee 100644 --- a/internal/session/task_depends_kept.go +++ b/internal/session/task_depends_kept.go @@ -34,8 +34,15 @@ func (n *TaskNode) keptDependencyBranches() []string { // prepareTaskTreeForNode adds the one inheritance a graph, rather than a stand, // knows about: completed dependency branches that were kept out of the person's // protected checkout. +// +// IT IS WHERE EVERY NODE OF THE SESSION'S OWN GRAPH STARTS, whichever door +// admitted it, so it is also where a node is refused a folder a program's run +// holds, before anything is cut from it or written in it (programhold.go). func prepareTaskTreeForNode(ctx context.Context, place Place, workspace, session string, node *TaskNode) (taskTree, error) { stand := node.stand() + if refusal := standHeldRefusal(stand, workspace); refusal != "" { + return taskTree{}, errors.New(refusal) + } branches := node.keptDependencyBranches() if len(branches) == 0 { return prepareTaskTreeOn(ctx, place, workspace, session, node.id, node.title(), stand) diff --git a/internal/session/task_person.go b/internal/session/task_person.go index becb234e5..4ac9b8823 100644 --- a/internal/session/task_person.go +++ b/internal/session/task_person.go @@ -145,6 +145,11 @@ func (a *Agent) startTaskLegacy(ctx context.Context, brief string, solo bool) (u // takes the rung below rather than stopping. The ladder reads the paths in // the person's own sentence, which is the brief it is handed here. stand := a.taskGroundOrStandingIn(spec) + // A FOLDER A PROGRAM'S RUN HOLDS IS REFUSED AT THE DOOR (programhold.go): + // this door has nobody to ask, so the rung below may still land on it. + if refusal := standHeldRefusal(stand, a.config.Workspace); refusal != "" { + return 0, "", "", errors.New(refusal) + } spec.ground, spec.mode = stand.dir, stand.mode graph.admit(id, spec) return id, spec.title, stand.redirect, nil diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 426fc2da1..5195812b2 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -575,6 +575,11 @@ func (a *Agent) newQuickSpec(ask quickAsk) (taskSpec, string) { if refusal != "" { return taskSpec{}, refusal } + // A QUICK TASK WORKS WHERE ITS CALLER WORKS, so a folder a program's run + // holds is refused it before it starts (programhold.go). + if refusal := programHoldRefusal(a.config.Workspace); refusal != "" { + return taskSpec{}, refusal + } dependsOn := append([]uint64(nil), ask.dependsOn...) // A DEPENDENCY THAT CAN NEVER RESOLVE IS REFUSED AT THE DOOR, on // [Agent.refuseProposedTask]'s terms and through the same reader: an id no diff --git a/internal/session/task_run.go b/internal/session/task_run.go index faf93deba..75980e1c2 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -8775,6 +8775,14 @@ func (t taskTree) comeHome(title string, wrote []string, sign bool) (string, str // gives the person a durable result and gives the working copy back without // changing a byte of the checkout they are using. func (t taskTree) keptInsteadOfMerged() string { + // NOR ONE A PROGRAM'S RUN IS WORKING IN (programhold.go). A merge there + // lands under the program — stashing its unfinished edits, or put back by + // its own restore once it has submitted — while this row says it landed; + // kept, the work waits on its own branch for the run to end. + if hold, busy := programHoldNear(canonicalPath(t.root), ""); busy { + return "its branch " + t.branch + " was kept: " + hold.holder + ", is working in " + hold.where(t.root) + + " — bring it in when that run has ended" + } // A TASK NEVER WRITES A PROTECTED, MOVED OR DETACHED CHECKOUT. if t.landsInThePersonsRepository() { return t.keptLandingSentence() @@ -8801,6 +8809,14 @@ func (t taskTree) landMirror(wrote []string) (string, string, []string, landingR if strings.TrimSpace(t.ground) == "" || strings.TrimSpace(t.dir) == "" { return mergeInPlace, "", nil, refusedNothing } + // A FOLDER A PROGRAM'S RUN HOLDS IS NOT LAID INTO (programhold.go): the + // program would count the files as its own, or put them back once it has + // submitted. Nothing is laid and the copy stays whole, a refusal a second + // answer gets past once that run has ended. + if refusal := programHoldRefusal(t.ground); refusal != "" { + return mergeAborted, "its work was not laid into " + t.ground + " and is kept in " + t.dir + ": " + refusal, + nil, refusedByTheWork + } // AND IT DOES NOT WRITE OVER A FILE THAT CHANGED UNDER IT // (task_mirror_manners.go). The mark is [mergeConflicted] because that is what // this is — the same file changed on both sides — and because every one of the diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 60bd5859d..0cef02c26 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -386,6 +386,13 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie // changes that are not committed, another program's run in it — refuses // before a store is seeded or a row is published. var folder *ProgramFolder + if via == nil { + // AN ORDINARY RUN IS REFUSED A FOLDER A PROGRAM'S RUN HOLDS + // (programhold.go), before a store is seeded or a copy cut from it. + if refusal := standHeldRefusal(stand, a.config.Workspace); refusal != "" { + return errors.New(refusal) + } + } if via != nil && via.LandsTree() { prepared, err := PrepareProgramFolder(ProgramFolderOrder{ Program: *via, Dir: stand.dir, Title: title, Holder: taskStopName(id, title), diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 4f9f8975e..fd45bd0fc 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -156,6 +156,22 @@ func (a *Agent) resolveTaskGround(spec taskSpec) taskStand { if program, err := a.delegateFor(spec.via); err == nil { return programGround(spec, workspace, program) } + stand := a.ordinaryTaskGround(spec, workspace) + // AND NOTHING ELSE OF CODEAF'S WORKS IN A FOLDER A PROGRAM'S RUN HOLDS + // (programhold.go), so the card is never shown for work that could only + // be cut from the program's unfinished branch or land under it. + if stand.ask == "" && stand.refusal == "" { + if refusal := standHeldRefusal(stand, workspace); refusal != "" { + return taskStand{refusal: refusal} + } + } + return stand +} + +// ordinaryTaskGround is [Agent.resolveTaskGround] for work no program is handed: +// the placement a model asked for, the ladder, the brief's last word, and the +// mode. +func (a *Agent) ordinaryTaskGround(spec taskSpec, workspace string) taskStand { redirect := "" // A MODEL'S PLACEMENT IS EVIDENCE, NOT AUTHORITY, INSIDE A REPOSITORY. A // branch is the repository's isolation boundary even when `where` asked for From 0661692840b06c0683311a29e68717209deadd9d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:52:03 -0400 Subject: [PATCH 134/195] delegate, codeaf, manual: a shell run finishes its folder when its program ends, and a hangup stops it A shell run finished its folder only after the model API had waited up to seventy seconds for the price of a call the stop cut short, and the second ctrl-c the manual offers during that wait killed the host first: the folder stayed on the program's branch with its work uncommitted and nothing said. A closed terminal or a dropped ssh connection sent SIGHUP, which nothing caught, so the host died on the spot; and a host that died without a word never stopped its child, which runs in a process group of its own and so worked on in the folder after the hold had gone. Now the folder is finished, and the program's end written to its record, as soon as the program has exited, before the wait for prices. SIGHUP stops the run the way the first ctrl-c does, and a second hangup is ignored rather than allowed to kill the finishing. A child looks for its parent once a second and stops, as a SIGTERM would stop it, when the process that started it is gone. A folder a killed host still leaves unfinished is settled by the next run there without a commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/carried.go | 43 ++++++++++++++++++----- cmd/codeaf/carried_folder_test.go | 27 +++++++++++++++ cmd/codeaf/carried_signal_test.go | 35 +++++++++++++++++++ docs/design/delegate/PROTOCOL.md | 7 ++++ internal/delegate/cli.go | 42 +++++++++++++++++++++++ internal/delegate/cli_test.go | 55 ++++++++++++++++++++++++++++++ internal/manual/chat/senior-dev.md | 12 +++++-- internal/manual/chat_test.go | 1 + 8 files changed, 210 insertions(+), 12 deletions(-) diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index 293062b2b..70e4cba82 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -87,19 +87,32 @@ func runCarried(program delegate.Delegate, args []string) error { return runCarriedHost(ctx, inv) } -// carriedSignals is a shell run's context: it ends on the first ctrl-c or -// SIGTERM, and that first signal hands the rest back to the terminal. +// 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: a price still being -// waited for is then not in the run's line. +// 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) - context.AfterFunc(ctx, stop) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + context.AfterFunc(ctx, func() { + signal.Ignore(syscall.SIGHUP) + stop() + }) return ctx, stop } @@ -334,16 +347,28 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { // 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. - _ = api.Close() - view.closed(ended) + _ = carriedAPIClose(api) session.CloseUsage() - finish(view.endingWords()) 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. diff --git a/cmd/codeaf/carried_folder_test.go b/cmd/codeaf/carried_folder_test.go index 06650379d..caea76f56 100644 --- a/cmd/codeaf/carried_folder_test.go +++ b/cmd/codeaf/carried_folder_test.go @@ -22,6 +22,7 @@ import ( "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" ) @@ -248,3 +249,29 @@ func newestRecordOf(t *testing.T, name string) string { } 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_signal_test.go b/cmd/codeaf/carried_signal_test.go index 35a1ca207..750cfeaef 100644 --- a/cmd/codeaf/carried_signal_test.go +++ b/cmd/codeaf/carried_signal_test.go @@ -53,3 +53,38 @@ func TestASecondInterruptLeavesAShellRunAtOnce(t *testing.T) { 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/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index 2a5ddc3e8..e315a51a4 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -176,6 +176,13 @@ 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 +(`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` diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index edc70257a..c7c0227ee 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -12,9 +12,11 @@ import ( "flag" "fmt" "io" + "os" "path/filepath" "strconv" "strings" + "time" ) // ErrHelp is Parse's answer when the line asked for help and got it. @@ -184,7 +186,12 @@ func commandHelp(program Delegate, command Command, fs *flag.FlagSet, out io.Wri // 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() + go watchHost(ctx, cancel) api, _ := ModelAPIFromEnv() emitter := NewEmitter(stdout) host := &childHost{inv: inv, emitter: emitter, api: api, ending: StatusFail} @@ -207,6 +214,41 @@ func RunChild(ctx context.Context, inv *Invocation, stdout io.Writer) string { 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 + +// watchHost ends a child's context when the process that started it is gone, +// and returns when the context ends 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. +func watchHost(ctx context.Context, cancel context.CancelFunc) { + host := hostPID() + tick := time.NewTicker(hostWatch) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + if hostPID() != host { + cancel() + return + } + } + } +} + // childHost is the Host of a program running as a child: records to stdout, // models from the environment. type childHost struct { diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index 0d1f2ce4d..144999cee 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -6,7 +6,9 @@ import ( "errors" "flag" "strings" + "sync" "testing" + "time" ) // testProgram is a program with two commands, the default one taking a flag @@ -257,3 +259,56 @@ func TestValidateHoldsTheGuideToOneAffordableParagraph(t *testing.T) { 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 := hostPID, hostWatch + t.Cleanup(func() { hostPID, hostWatch = previousPID, previousWatch }) + 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") + } +} diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index dd079d1a8..2d47a6bbc 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -433,7 +433,7 @@ glm-5.1 and minimax-m2.7. A run with no crew set uses it, and so does a shell ru **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 +## 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 <folder>, on its own branch <branch>` in a repository), then prints each stage, step and @@ -452,8 +452,14 @@ When ctrl-c or `--max-cost` stops the run in the middle of a model call, that ca 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; a price still owed is then missing from the run's line and -from this machine's spending ledger. +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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 918228804..c9750666c 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -935,6 +935,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how long did the senior-dev run take", "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"}, // Its page is the actions it took, each under the step of its process, // asked the ways somebody watching it would ask. From a9cf23b1c42a302cf7e5310a5b1d32a7f871285b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:55:19 -0400 Subject: [PATCH 135/195] session, manual: a reopened program's row says where its work went, and the receipt names a repository at home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reopen that settled a program's folder left the row `interrupted` for good when the store called the program's task done, on the reading that the work was never landed and that was a person's call; a program's work is never landed by anybody, and the folder had just been settled. A folder another process had ended — the run itself before codeaf closed, or the next run in that folder, which then wrote its own record over the one beside the hold — left the reopen finding nothing owed, so the row said only that codeaf closed. And the receipt of a folder inside a repository rooted at the home folder told the chat it "has no git history". Now how a run's folder was left is also written into the run's own record folder, and a reopen that finds nothing owed reads it back and puts it on the page once; a done task's row settles done, with the program's result, where the work is and the branch that holds it. The receipt for a folder under a repository at home names that repository and says no branch is cut there and nothing committed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 12 ++-- internal/session/delegate_door.go | 11 +++ internal/session/programfolder.go | 62 +++++++++++++++++ internal/session/programfolder_test.go | 93 ++++++++++++++++++++++++++ internal/session/task_run_belt.go | 40 +++++++++-- 5 files changed, 207 insertions(+), 11 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 2d47a6bbc..250713340 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -562,12 +562,12 @@ 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 (closing it, `ctrl+c`, a closed terminal) only detaches: senior-dev keeps working. -When that engine is stopped (`codeaf engine --stop`, a signal) or crashes, the conversation -itself 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` beside it, no -stage, nothing waiting on you, and no fault. If senior-dev had already exited, it reads -`senior-dev had ended; codeaf closed before it could say where its work is`. +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 diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index c00acf8c8..239e8fb1b 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -203,8 +203,19 @@ func delegateReceipt(ground string, via delegate.Delegate, record *TaskCopyRecor // delegateFolderReceipt is where a program that edits files works, as its // receipt says it ([delegateReceipt]). +// +// A FOLDER WITH NO BRANCH IS NOT ALWAYS A FOLDER WITH NO HISTORY. One inside a +// repository whose root holds the home folder — a dotfiles repository — is +// worked in without git because codeaf will not cut a branch there, and a +// receipt that said it "has no git history" had the chat telling the person so, +// or advising `git init` inside their dotfiles; it names the repository, the +// way the run's ending does ([ProgramFolderEnd.Sentence]). func delegateFolderReceipt(ground string, via delegate.Delegate, record *TaskCopyRecord) string { if record == nil || record.Branch == "" { + if _, _, outer, _ := programFolderOf(ground); outer != "" { + return "It is " + via.Name + "'s: it works alone in " + ground + " itself, inside the git repository at " + outer + + ", which holds your home folder, so codeaf cuts no branch there and commits nothing; its changes are there as it makes them." + } return "It is " + via.Name + "'s: it works alone in " + ground + " itself, which has no git history, so its changes are there as it makes them." } folder := ProgramFolder{Home: record.Home, Start: record.HomeSha} diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index f6b67ee27..ff2c9c45e 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -203,6 +203,7 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { end := owed.settleGone() owed.Ended = end.Sentence() owed.write() + end.keepEnding() earlier = &end } if _, err := os.Stat(dir); os.IsNotExist(err) { @@ -466,6 +467,62 @@ type ProgramFolderEnd struct { Refused string // Notes is where the program's notes went, as a sentence. Notes string + + // said is the sentence a run's folder was ended with, read back from its + // record folder by a process that did not end it ([keptProgramFolderEnd]); + // [ProgramFolderEnd.Sentence] answers it as it was said. + said string +} + +// programFolderEndFile is the file in a run's own record folder that says how +// its folder was left: the branch, whether it holds the work, the files, and +// the sentence. +// +// IT IS WRITTEN WHERE THE RUN'S RECORD LIVES, NOT ONLY BESIDE THE HOLD. The +// record beside the hold is one folder's, and the next run in that folder +// writes over it; and a run whose folder was ended by one process can have its +// row settled by another — a codeaf that closed after the ending and before +// the row. Either way the conversation that reopens the run finds its ending +// here, and its row names the branch and where the work went instead of +// staying `interrupted` ([Agent.settleInterruptedProgramRow]). +const programFolderEndFile = "program-folder.json" + +// programFolderEnding is what [programFolderEndFile] holds. +type programFolderEnding struct { + Branch string `json:"branch,omitempty"` + Kept bool `json:"kept,omitempty"` + Changed []string `json:"changed,omitempty"` + Said string `json:"said"` +} + +// keepEnding writes how the run's folder was left into the run's record +// folder ([programFolderEndFile]). It is a record, so a disk that refuses it +// costs a later reopen its sentence and never the run. +func (e ProgramFolderEnd) keepEnding() { + keep := strings.TrimSpace(e.Folder.Keep) + if keep == "" { + return + } + body, err := json.MarshalIndent(programFolderEnding{Branch: e.Folder.Branch, Kept: e.Kept, Changed: e.Changed, Said: e.Sentence()}, "", " ") + if err != nil || os.MkdirAll(keep, 0o700) != nil { + return + } + _ = os.WriteFile(filepath.Join(keep, programFolderEndFile), body, 0o600) +} + +// keptProgramFolderEnd is how the run whose record folder is keep left its +// folder, as it wrote it there ([ProgramFolderEnd.keepEnding]); false when it +// wrote nothing. +func keptProgramFolderEnd(keep string) (ProgramFolderEnd, bool) { + body, err := os.ReadFile(filepath.Join(keep, programFolderEndFile)) + if err != nil { + return ProgramFolderEnd{}, false + } + var ending programFolderEnding + if json.Unmarshal(body, &ending) != nil || strings.TrimSpace(ending.Said) == "" { + return ProgramFolderEnd{}, false + } + return ProgramFolderEnd{Folder: ProgramFolder{Branch: ending.Branch, Keep: keep}, Kept: ending.Kept, Changed: ending.Changed, said: ending.Said}, true } // Finish ends a program's run in its folder, per the fourth point of the @@ -476,6 +533,7 @@ func (f *ProgramFolder) Finish(result string) ProgramFolderEnd { end := f.settle(result) f.Ended = end.Sentence() f.write() + end.keepEnding() f.release() return end } @@ -756,6 +814,9 @@ func (f *ProgramFolder) StopPromise() string { // how much of it, that its branch is checked out, and how to go back to the // person's own branch and bring the work in. func (e ProgramFolderEnd) Sentence() string { + if e.said != "" { + return e.said + } f := e.Folder var said string switch { @@ -1003,6 +1064,7 @@ func settleOwedProgramFolder(keep string) (ProgramFolderEnd, bool) { end := owed.settleGone() owed.Ended = end.Sentence() owed.write() + end.keepEnding() owed.release() return end, true } diff --git a/internal/session/programfolder_test.go b/internal/session/programfolder_test.go index 3a0217336..fabcc13c8 100644 --- a/internal/session/programfolder_test.go +++ b/internal/session/programfolder_test.go @@ -356,3 +356,96 @@ func TestTheNextRunCarriesOnInAFolderTheOneThatWentAwayLeftClean(t *testing.T) { t.Fatalf("the dead run's committed work is not on its branch:\n%s", files) } } + +// A REOPEN THAT SETTLES A FINISHED PROGRAM'S FOLDER SETTLES ITS ROW DONE. The +// program finished and codeaf closed before the row was published; the row was +// left `interrupted` for ever, with no branch, while the page said where the +// work was. It now reads done, with the program's result and the folder's +// sentence, and names the branch that holds the work. +func TestAReopenSettlesTheRowOfAProgramThatFinished(t *testing.T) { + repo := newTestRepo(t) + var dead *ProgramFolder + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, _ time.Time) { + dead = deadProgramFolder(t, repo, taskDir) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + commitIn(t, repo, "done.txt") + if err := store.CompleteRoot("finished: its tests pass"); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.State != TaskDone || !strings.HasPrefix(row.Report, "finished: its tests pass") || + !strings.Contains(row.Report, "its work so far is on its branch "+dead.Branch) { + t.Fatalf("the reopened row = %+v, want it done, with its result and where its work is", row) + } + if row.Branch != dead.Branch || row.Merge != mergeKept || row.EndedAt.IsZero() { + t.Fatalf("the reopened row = %+v, want it to name the branch that holds the work", row) + } +} + +// A FOLDER ANOTHER PROCESS ENDED IS READ BACK FROM THE RUN'S RECORD FOLDER. +// Its folder was finished — by the run itself before codeaf closed, or by the +// next run in that folder, which then wrote its own record over the one beside +// the hold — and the reopen found nothing owed, so the row said only that +// codeaf closed. It now says where the work went and names the branch. +func TestAReopenReadsTheEndingOfAFolderAnotherProcessEnded(t *testing.T) { + t.Run("ended by the run", func(t *testing.T) { + repo := newTestRepo(t) + var said, branch string + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, _ time.Time) { + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The ended run", Holder: "task 9 (The ended run)", Keep: taskDir}) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + said, branch = folder.Finish("done").Sentence(), folder.Branch + }) + row := reopenedRow(t, agent, id) + if !strings.Contains(row.Report, said) || row.Branch != branch || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { + t.Fatalf("the reopened row = %+v, want %q and the branch %s", row, said, branch) + } + }) + t.Run("settled by the next run", func(t *testing.T) { + repo := newTestRepo(t) + var dead *ProgramFolder + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, _ time.Time) { + dead = deadProgramFolder(t, repo, taskDir) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + commitIn(t, repo, "done.txt") + next, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + next.Finish("") + }) + row := reopenedRow(t, agent, id) + if !strings.Contains(row.Report, "its work so far is on its branch "+dead.Branch) || row.Branch != dead.Branch { + t.Fatalf("the reopened row = %+v, want where the dead run's work is", row) + } + }) +} + +// THE RECEIPT OF A FOLDER INSIDE A REPOSITORY AT THE HOME FOLDER NAMES THAT +// REPOSITORY. It said the folder "has no git history", which the chat repeated +// to the person, or answered with a `git init` inside their dotfiles. +func TestTheReceiptOfAFolderUnderARepositoryAtHomeNamesIt(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + mustGit(t, home, "init", "-q") + writeFile(t, filepath.Join(home, ".zshrc"), "export A=1\n") + mustGit(t, home, "add", "-A") + mustGit(t, home, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "dotfiles") + project := filepath.Join(home, "Desktop", "pong") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + want := "It is fake's: it works alone in " + project + " itself, inside the git repository at " + canonicalPath(home) + + ", which holds your home folder, so codeaf cuts no branch there and commits nothing; its changes are there as it makes them." + if got := delegateReceipt(project, testPrograms("fake")[0], &TaskCopyRecord{Dir: project}); !strings.HasPrefix(got, want) || strings.Contains(got, "no git history") { + t.Fatalf("the receipt = %q, want %q", got, want) + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 0cef02c26..b987d0bf1 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -783,9 +783,28 @@ func (a *Agent) endInterruptedProgramRun() { return } end, settled := endOrphanedProgramRun(store) + if !settled { + // A FOLDER ENDED BY A PROCESS THAT DID NOT LIVE TO SETTLE THE ROW is read + // back from the run's record folder ([keptProgramFolderEnd]), and its + // page is told once where the work is. + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()) + if end, settled = keptProgramFolderEnd(taskDir); settled && !storeSays(store, end.Sentence()) { + _, _ = store.AddNote(store.RootID(), store.RootID(), end.Sentence()) + } + } a.settleInterruptedProgramRow(g, store, kept, end, settled) } +// storeSays is whether a note on the store's root already says sentence. +func storeSays(store *plandb.Store, sentence string) bool { + for _, note := range store.Notes(store.RootID(), 0) { + if strings.Contains(note.Body, sentence) { + return true + } + } + return false +} + // settleInterruptedProgramRow settles the row a reopen restored as interrupted // once its program's run has ended in its store, whether this reopen ended it // or the closing did first ([Agent.cutBeltRun]). @@ -794,8 +813,13 @@ func (a *Agent) endInterruptedProgramRun() { // the side list draws as waiting on a person — says something the page does // not: the page reads it ended, in codeaf's sentence, with its time stopped. // The row now says the same, not as a fault, ending where the store ended it. -// A run whose task the store calls done is left as it came back: its program -// finished, but the work was never landed, and that is a person's call. +// +// A RUN WHOSE TASK THE STORE CALLS DONE SETTLES DONE. Its program finished and +// codeaf closed before the row was published; the row used to be left +// interrupted, on the reading that the work was never landed and that was a +// person's call — but a program's work is never landed by anybody, it is left +// on its branch, and this reopen settles the folder too. So the row reads done, +// with the program's result and where the work is. // // THE ROW ENDS AT THE PROGRAM'S RECORDED EXIT when the record carries one, and // at the store's ending otherwise ([runClockEnd]) — the pair every live settle @@ -807,7 +831,7 @@ func (a *Agent) endInterruptedProgramRun() { // when it holds the work. func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, kept TaskNotice, end ProgramFolderEnd, settled bool) { root := store.Task(store.RootID()) - if root == nil || (root.Status != plandb.StatusFailed && root.Status != plandb.StatusCancelled) { + if root == nil || (root.Status != plandb.StatusFailed && root.Status != plandb.StatusCancelled && root.Status != plandb.StatusDone) { return } record, ok := delegate.ReadProgram(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())) @@ -815,8 +839,14 @@ func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, k return } row := kept - row.State = TaskFailed - row.Report, row.Ending, row.Stopped = interruptedProgramEnding(store, root, record) + if root.Status == plandb.StatusDone { + row.State, row.Ending, row.Stopped = TaskDone, "", false + row.Result = strings.TrimSpace(root.Result) + row.Report = row.Result + } else { + row.State = TaskFailed + row.Report, row.Ending, row.Stopped = interruptedProgramEnding(store, root, record) + } row.EndedAt = runClockEnd(kept.StartedAt, record, root.CompletedAt) row.Elapsed = 0 if settled { From c8d3a1f5de5628a1a21a121824a02f76a15b0f34 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:59:04 -0400 Subject: [PATCH 136/195] session: a run's folder is readied in a function of its own, and the hold waits by a deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run door's new refusal for an ordinary run on a held folder took startKnownTaskRunVia past the task engine's ceiling of fifteen decisions, and the hold asked again for a taken lock a counted number of times, which the session's one-deadline law forbids. Now readyRunFolder answers a new run's folder — a program's readied and held, an ordinary run refused a held one, a program that only answers given none — and the run door calls it first; and a run asks again for a taken hold for a tenth of a second, by the clock, rather than five times. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/programhold.go | 16 ++++----- internal/session/task_run_belt.go | 55 ++++++++++++++++++------------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/internal/session/programhold.go b/internal/session/programhold.go index b5cd43edd..a11b2c75b 100644 --- a/internal/session/programhold.go +++ b/internal/session/programhold.go @@ -40,14 +40,14 @@ type programHold struct { holder string } -// programHoldTries and programHoldPause are how often, and how far apart, a -// run asks again for a hold it found taken. A door that only wants to know +// programHoldWait is how long a run goes on asking for a hold it found taken, +// and programHoldPause how far apart it asks. A door that only wants to know // whether a folder is busy takes the hold's file for the instant of asking -// ([programHoldAt]), and a run that asks at that same instant would read -// that as a run holding it; a real run holds its folder for minutes, so a -// few short asks cost a refused run nothing it would notice. +// ([programHoldAt]), and a run that asks at that same instant would read that +// as a run holding it; a real run holds its folder for minutes, so a tenth of +// a second costs a refused run nothing it would notice. const ( - programHoldTries = 5 + programHoldWait = 100 * time.Millisecond programHoldPause = 20 * time.Millisecond ) @@ -72,9 +72,9 @@ func claimProgramFolder(key, holder string) (*os.File, programHold, bool) { if err != nil { return nil, programHold{}, false } - for try := 1; ; try++ { + for asked := time.Now(); ; { err = filelock.Lock(file, true, true) - if err == nil || !isLockHeld(err) || try == programHoldTries { + if err == nil || !isLockHeld(err) || time.Since(asked) >= programHoldWait { break } time.Sleep(programHoldPause) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index b987d0bf1..ead96b568 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -381,28 +381,9 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie return a.joinBeltRun(g, live, id, title, brief, dependencies, stand, via) } - // A PROGRAM THAT EDITS FILES WORKS IN THE FOLDER ITSELF (programfolder.go), - // and the folder is readied before anything else: a folder that refuses — - // changes that are not committed, another program's run in it — refuses - // before a store is seeded or a row is published. - var folder *ProgramFolder - if via == nil { - // AN ORDINARY RUN IS REFUSED A FOLDER A PROGRAM'S RUN HOLDS - // (programhold.go), before a store is seeded or a copy cut from it. - if refusal := standHeldRefusal(stand, a.config.Workspace); refusal != "" { - return errors.New(refusal) - } - } - if via != nil && via.LandsTree() { - prepared, err := PrepareProgramFolder(ProgramFolderOrder{ - Program: *via, Dir: stand.dir, Title: title, Holder: taskStopName(id, title), - Keep: plandb.TaskDir(filepath.Dir(path), storeID), Instead: "say which folder the work is in, as ground", - Place: a.config.Place, Sign: a.signsGitWork(), - }) - if err != nil { - return err - } - folder = prepared + folder, err := a.readyRunFolder(id, title, filepath.Dir(path), stand, via) + if err != nil { + return err } plan, store, err := a.seedBeltRunStore(g, path, storeID, title, brief) if err != nil { @@ -461,6 +442,36 @@ func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brie return nil } +// readyRunFolder answers the folder a new run that edits files by a program's +// hand works in, readied, and what refuses a run its folder. It is the first +// thing a run does, so a folder that refuses refuses before a store is seeded +// or a row is published. sessionDir is the folder the run's store is in. +// +// - A PROGRAM THAT EDITS FILES WORKS IN THE FOLDER ITSELF (programfolder.go), +// readied here: refused over changes that are not committed or another +// program's run in or around it, and otherwise held for the run. +// - AN ORDINARY RUN IS REFUSED A FOLDER A PROGRAM'S RUN HOLDS +// (programhold.go), before a copy is cut from it. +// +// A program that only answers reads the folder where it is and changes +// nothing, so it is neither readied nor refused, and nil is its folder. +func (a *Agent) readyRunFolder(id uint64, title, sessionDir string, stand taskStand, via *delegate.Delegate) (*ProgramFolder, error) { + if via == nil { + if refusal := standHeldRefusal(stand, a.config.Workspace); refusal != "" { + return nil, errors.New(refusal) + } + return nil, nil + } + if !via.LandsTree() { + return nil, nil + } + return PrepareProgramFolder(ProgramFolderOrder{ + Program: *via, Dir: stand.dir, Title: title, Holder: taskStopName(id, title), + Keep: plandb.TaskDir(sessionDir, strconv.FormatUint(id, 10)), Instead: "say which folder the work is in, as ground", + Place: a.config.Place, Sign: a.signsGitWork(), + }) +} + // joinBeltRun is the second task of a live run. The store holds one root, so the // new work is a child of it — normalizeSpec's own law for a task that names no // parent — and the supervisor already turning finds it ready on its next pass. From 76d034d556acbfd7ddab05faf13fa708375a8222 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:00:21 -0400 Subject: [PATCH 137/195] delegate: a child whose host has gone is ended when its grace has passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A child that saw its host go stopped as a SIGTERM would stop it, but nobody was left to send the SIGKILL a host's own stop sends after the grace, so a program still at work on its way out — a restore, a last test run — could go on in a folder the next run had already taken. Now the ladder a host's stop keeps is kept without the host: the stop, the grace, then the child is ended outright, unless it has finished by then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/design/delegate/PROTOCOL.md | 3 +- internal/delegate/cli.go | 36 +++++++++++++++++---- internal/delegate/cli_test.go | 54 ++++++++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index e315a51a4..8bb805ef1 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -179,7 +179,8 @@ 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 +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. diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index c7c0227ee..d93b55f91 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -191,7 +191,9 @@ func commandHelp(program Delegate, command Command, fs *flag.FlagSet, out io.Wri func RunChild(ctx context.Context, inv *Invocation, stdout io.Writer) string { ctx, cancel := context.WithCancel(ctx) defer cancel() - go watchHost(ctx, 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} @@ -221,8 +223,16 @@ 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 either way. +// 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 @@ -232,7 +242,13 @@ var hostWatch = time.Second // 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. -func watchHost(ctx context.Context, cancel context.CancelFunc) { +// +// 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() @@ -241,10 +257,18 @@ func watchHost(ctx context.Context, cancel context.CancelFunc) { case <-ctx.Done(): return case <-tick.C: - if hostPID() != host { - cancel() - return + if hostPID() == host { + continue } + cancel() + grace := time.NewTimer(hostGrace) + defer grace.Stop() + select { + case <-finished: + case <-grace.C: + hostGoneExit() + } + return } } } diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index 144999cee..1b66c30f7 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -268,8 +268,12 @@ func TestValidateHoldsTheGuideToOneAffordableParagraph(t *testing.T) { func TestAChildStopsWhenItsHostIsGone(t *testing.T) { t.Setenv(EnvModelAPI, "http://127.0.0.1:9/v1") t.Setenv(EnvModelToken, "token") - previousPID, previousWatch := hostPID, hostWatch - t.Cleanup(func() { hostPID, hostWatch = previousPID, previousWatch }) + 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 { @@ -312,3 +316,49 @@ func TestAChildStopsWhenItsHostIsGone(t *testing.T) { 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 +} From 7db748347e93f7981d14288bfe1ec3e7ba425cc8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:00:53 -0400 Subject: [PATCH 138/195] session: the folder's comments say a run that went away is settled, and where its ending is kept PrepareProgramFolder's comment still said a run that went away in the folder is finished first, and that nothing is changed by a refusal; it is settled without a commit, and only codeaf's own notes move. The record folder's comment now names the ending written there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/programfolder.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index ff2c9c45e..ec0e1ff7f 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -104,8 +104,9 @@ type ProgramFolderOrder struct { // `task 4 (Fix the parser)`, or `a run started at a shell`. Holder string // Keep is the run's record folder. The program's notes are moved into it - // when the run ends, and it is the name a reopen finds the run's folder by - // ([settleOwedProgramFolder]). + // when the run ends, how the folder was left is written there + // ([programFolderEndFile]), and it is the name a reopen finds the run's + // folder by ([settleOwedProgramFolder]). Keep string // Instead is what a folder that is the home folder is answered with, // after the refusal itself ([programHomeRefusal]). @@ -163,9 +164,9 @@ func (f *ProgramFolder) Plain() bool { return f == nil || f.Branch == "" } // PrepareProgramFolder readies the folder a program was asked to work in, per // the contract at the top of this file, and holds it for the run: the folder // resolved and made when it must be, the hold taken, a run that went away in -// it finished first, and in a repository the checkout read and the program's -// branch cut. The refusal is a sentence a person can act on, and nothing has -// been changed when there is one. +// it settled first, and in a repository the checkout read and the program's +// branch cut. The refusal is a sentence a person can act on, and nothing of +// the person's has been changed when there is one. func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { if strings.TrimSpace(order.Dir) == "" { return nil, errors.New(order.Program.Name + " was handed no folder to work in") From 2b538eb46105961c199ac9e19d22e4237fa006e1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:14:53 -0400 Subject: [PATCH 139/195] tui3, manual: senior-dev's pages read the home tab as dev now spells it, and an earlier-messages question still reaches its page dev's tab bar now names the home tab with the places bar's word, `home`; the program room's narrow test and the manual lines that quoted `Home` read it that way. The senior-dev pages' new words outranked compacting-over-and-over for "what happened to the earlier messages", so that page's heading now carries the asker's words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/compacting-over-and-over.md | 2 +- internal/manual/chat/home.md | 4 ++-- internal/manual/chat/senior-dev.md | 4 ++-- internal/manual/chat/worker-harness.md | 4 ++-- internal/tui3/programroom_test.go | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/manual/chat/compacting-over-and-over.md b/internal/manual/chat/compacting-over-and-over.md index 0a315897b..5545ec994 100644 --- a/internal/manual/chat/compacting-over-and-over.md +++ b/internal/manual/chat/compacting-over-and-over.md @@ -134,7 +134,7 @@ foldable material above the target. The pass still succeeds with what it took, a honestly, because there was nothing more to take. That is the one case where two passes in quick succession are not a defect. -## Where did the folded messages go — how do I get the compacted text back, why it loses the earlier part of our chat +## Where did the folded messages go — what happened to the earlier messages, how do I get the compacted text back, why it loses the earlier part of our chat They are still on disk. A fold replaces the oldest assistant work in the model's window with one line such as diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index d7093ae48..96e30dbab 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2376,9 +2376,9 @@ does not repeat that indicator unless it has a separate question. 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 `Home`, or `esc` leaves it. +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 `Home` leave 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/senior-dev.md b/internal/manual/chat/senior-dev.md index 250713340..6db0b3c87 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -53,7 +53,7 @@ A senior-dev run is a task of the conversation that started it. Its row is on th 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 `Home` beside +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 @@ -70,7 +70,7 @@ stopped when you clicked; the true time is back on the row the moment you leave. 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 `Home` leaves it, and the run goes +`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. diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 988c764d6..5b5cf9f19 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -205,7 +205,7 @@ step, with its number and the head of what came back. A task handed to a program codeaf carries (`/<name> <brief>`, 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 `Home` beside +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. ``` @@ -220,7 +220,7 @@ it, and the program gets no tab of its own. ◐ thinking · 12s ``` -`esc`, a press on the conversation's tab and a press on `Home` leave it; none of them +`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. diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go index 19aac9b3f..6fa45282e 100644 --- a/internal/tui3/programroom_test.go +++ b/internal/tui3/programroom_test.go @@ -280,7 +280,7 @@ func TestAProgramsRoomAtFortyFourColumns(t *testing.T) { openProgramRoomNow(t, a) frame, _, _ := a.frame() text := plain(frame) - for _, want := range []string{"Home", "the run", "implement · $1.24", "IMPLEMENT", "edited internal/auth/"} { + for _, want := range []string{pageHome.word(), "the run", "implement · $1.24", "IMPLEMENT", "edited internal/auth/"} { if !strings.Contains(text, want) { t.Fatalf("the room at 44 columns lost %q:\n%s", want, text) } From 148ebf51786776e077da2a759c2599d3ef6568d5 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:45:44 -0400 Subject: [PATCH 140/195] changes: senior-dev, a coding agent built into codeaf, takes a whole task in the folder itself Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/changes/unreleased/1488-senior-dev.md diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md new file mode 100644 index 000000000..7000a9fbc --- /dev/null +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -0,0 +1,33 @@ +--- +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 <brief>` 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 <model>` 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." + - "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: <folder>, 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 <folder> 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. codeaf now starts it with `--in-place` there (and in a folder under a repository rooted at the home folder or above): 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/<title>-<id>` (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 <branch> in <folder>, N files, and that branch is checked out there; your branch main is as it was: `git -C '<folder>' switch main` goes back to it, and `git -C '<folder>' merge <branch>` from there brings the work in`). A run that changed nothing switches back and deletes its branch (`it changed nothing, so <folder> is back on your branch <b> and its branch <task> was deleted`). A checkout with changes not committed, or in the middle of a merge or rebase, is refused before its card: `<folder> 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 (`<folder> is busy: senior-dev, task 1 (<title>), 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: <title>`), 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." + - "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] <brief>` 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 55,442 bytes for the full prefix and 47,055 for the lean one, and neither weighed the programs paragraph. Both are fixed, and the caps are 56,409 and 47,821: raised by exactly what the paragraph costs on the owner's call of 2026-09-23, raised again by exactly what the preference for a program costs on the owner's call of 2026-09-24 (\"raise the cap only as much as necessary\"), and lowered by the 38 bytes the folder rule shrank." + - "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. From af8517d7883bdb7b5937069f0c30db78fd79d24e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:50:24 -0400 Subject: [PATCH 141/195] session: opening a conversation reads its task graph and never builds one for a program's ending endInterruptedProgramRun called a.graph(), which builds a graph, so every conversation opened got one. An interrupted row only exists where recoverTasks read a checkpoint back, which already built the graph; read it with tasker(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/task_run_belt.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index ead96b568..ffe6b22d9 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -769,7 +769,10 @@ func (a *Agent) endInterruptedProgramRun() { if a.config.InTask { return } - g := a.graph() + // READ, NEVER BUILT: an interrupted row exists only where [Agent.recoverTasks] + // read a checkpoint back, and that already built the graph. A conversation + // with none is not given one by being opened. + g := a.tasker() if g == nil || !g.holdsInterruptedRun() { return } From 2f2dc745d7e11eb4b9ef834da19e54db49a2df93 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:00:30 -0400 Subject: [PATCH 142/195] cmd/codeaf: the ssh policy test gives the control socket a home short enough to fit Under macOS's $TMPDIR the control path came to 104 bytes, one over the 103-byte socket limit, so the multiplexing options were rightly dropped and the test failed on a Mac while passing on Linux CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/chatv3_host_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/chatv3_host_test.go b/cmd/codeaf/chatv3_host_test.go index f89d92406..152a66626 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{ From b6e3cb0e8a9541765b018e11105658b914e766c4 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:37:15 -0400 Subject: [PATCH 143/195] session: a program's run is never carried on, and its row and ContinueRun say so in one sentence ContinueRun would have rebuilt a program's run on codeaf's own workers, and an interrupted program row blamed a working copy it never had. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/task_run_continue.go | 7 +++++++ internal/session/task_run_continue_test.go | 24 +++++++++++++++++++++- internal/session/task_run_copy.go | 15 +++++++++++++- internal/session/task_status.go | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/internal/session/task_run_continue.go b/internal/session/task_run_continue.go index dd55610b5..7cec866c8 100644 --- a/internal/session/task_run_continue.go +++ b/internal/session/task_run_continue.go @@ -65,6 +65,13 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { if !found { return "", fmt.Errorf("there is no run %d in this conversation", row) } + if why := runCannotContinue(kept.Copy, kept.Program); kept.Program != "" { + // A PROGRAM'S ROW IS REFUSED BEFORE ITS STATE IS READ. It carries no + // copy (the program works in the folder itself), so the copy road below + // would refuse it in a sentence about a copy, and nothing here seats the + // program itself: the run rebuilt would be codeaf's own workers. + return "", errors.New(why) + } if kept.State != TaskInterrupted { // A run that finished, failed or was stopped has said its last word. // Only work nothing is driving is waiting to be picked up. diff --git a/internal/session/task_run_continue_test.go b/internal/session/task_run_continue_test.go index 7bf33381a..7d2775aa0 100644 --- a/internal/session/task_run_continue_test.go +++ b/internal/session/task_run_continue_test.go @@ -194,7 +194,7 @@ func TestARunWithNoCopyIsRefusedInTheSameSentenceTheRowShows(t *testing.T) { // THE DOOR AND THE ROW SAY ONE SENTENCE, NOT TWO. A person reads the row's // words before they answer and the door's words after; two spellings of the // same fact is how a reading drifts from what actually happens. - why := runCannotContinue(nil) + why := runCannotContinue(nil, "") if why == "" { t.Fatal("the row shows no reason at all, so the offer would read as available") } @@ -307,3 +307,25 @@ func TestARunThatIsAlreadyGoingIsLeftAloneAndSaysSo(t *testing.T) { t.Fatal("the live run was replaced by the one that was refused") } } + +// A PROGRAM'S RUN IS NEVER CARRIED ON, and the door and its row say so in one +// sentence rather than blaming a copy it never had. +func TestAProgramsRunIsNeverCarriedOn(t *testing.T) { + agent, g, _, double := continueAgent(t) + agent.publishRunRow(g, TaskNotice{ + ID: 7, Title: "port the parser", State: TaskInterrupted, Program: "senior-dev", + StartedAt: agent.taskClockNow(), + }) + _, err := agent.ContinueRun(context.Background(), 7) + if err == nil { + t.Fatal("a program's run was carried on by codeaf's own workers") + } + if want := programNotCarriedOn("senior-dev"); err.Error() != want { + t.Fatalf("the door refuses with %q, want %q", err.Error(), want) + } + row, _ := runRowOf(g, 7) + if got := row.StatusFacts().CannotContinue; got != err.Error() { + t.Fatalf("the row says %q and the door %q", got, err.Error()) + } + nothingStarted(t, agent, double) +} diff --git a/internal/session/task_run_copy.go b/internal/session/task_run_copy.go index cc1dd35e7..99f7cfcd8 100644 --- a/internal/session/task_run_copy.go +++ b/internal/session/task_run_copy.go @@ -88,6 +88,12 @@ func runCopyOf(tree taskTree) *TaskCopyRecord { // destructive road wearing a helpful face. var errNoRunCopy = fmt.Errorf("this run's working copy was not written down when it started, so there is nothing to carry on from") +// programNotCarriedOn is the sentence a program's run is refused carrying on +// in, by the door and on its row alike. +func programNotCarriedOn(program string) string { + return program + "'s run is never carried on: its work is left where it ended, and a new hand-off starts a new run" +} + // runCannotContinue answers WHY a run cannot be carried on, in the words a // person reads, or the empty string when it can. // @@ -103,7 +109,14 @@ var errNoRunCopy = fmt.Errorf("this run's working copy was not written down when // a copy that WAS written down and is no longer there — is a fact about this // moment, so the door finds it out at the moment it matters ([runCopyTree]) and // says so then. -func runCannotContinue(record *TaskCopyRecord) string { +// +// A PROGRAM'S RUN IS NEVER CARRIED ON, whatever its record says: the program +// did its one run alone and left its work where it ended, and the next hand-off +// is a new run ([programNotCarriedOn]). +func runCannotContinue(record *TaskCopyRecord, program string) string { + if program = strings.TrimSpace(program); program != "" { + return programNotCarriedOn(program) + } if record == nil || strings.TrimSpace(record.Dir) == "" { return errNoRunCopy.Error() } diff --git a/internal/session/task_status.go b/internal/session/task_status.go index c3d7544bc..e9b04fef3 100644 --- a/internal/session/task_status.go +++ b/internal/session/task_status.go @@ -676,7 +676,7 @@ func (n TaskNotice) StatusFacts() TaskFacts { // make some surface write those words a second time // ([runCannotContinue]). It reads no disk, which is what keeps this // method the pure function every drawing road relies on. - CannotContinue: runCannotContinue(n.Copy), + CannotContinue: runCannotContinue(n.Copy, n.Program), } } From 7c568b78539dc8c123036b3c38ba90db57cca550 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:38:32 -0400 Subject: [PATCH 144/195] tui3: a program's task offers no steer in its @ block and no retry on its card The @ block told the model to steer running senior-dev work, which reads no messages; it now names the stop. The task-record card offered enter retry on a failed program task, which the engine refuses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/tui3/programbadge_test.go | 19 +++++++++++++++++++ internal/tui3/taskmention.go | 7 ++++++- internal/tui3/taskretry.go | 5 +++++ internal/tui3/taskretry_test.go | 19 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go index 6a9ba5bdc..aa83e418d 100644 --- a/internal/tui3/programbadge_test.go +++ b/internal/tui3/programbadge_test.go @@ -613,3 +613,22 @@ func TestAProgramsPlanRowOnTheRailWearsTheBadge(t *testing.T) { t.Fatalf("an ordinary plan row reads %q", drawn) } } + +// A PROGRAM'S RUNNING WORK OFFERS NO STEER in the `@` block the model reads, +// because the program reads no messages; it names the stop instead. An ordinary +// running task keeps its steer. +func TestAProgramsMentionBlockOffersTheStopAndNoSteer(t *testing.T) { + entry := session.TaskIndexEntry{ID: "7", Label: programTitle, Title: programTitle, Status: "running", Program: "senior-dev"} + block := taskPointerBlock(entry) + if strings.Contains(block, "Steer:") || strings.Contains(block, " say ") { + t.Fatalf("a program's block offers a steer it would refuse:\n%s", block) + } + if !strings.Contains(block, "senior-dev reads no messages; stop it with tasks id 7 stop") { + t.Fatalf("a program's block does not name its one door:\n%s", block) + } + ordinary := entry + ordinary.Program = "" + if block := taskPointerBlock(ordinary); !strings.Contains(block, `Steer: tasks id 7 say "…"`) { + t.Fatalf("an ordinary running task lost its steer:\n%s", block) + } +} diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index 5e9167f39..2d6bea49f 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -635,7 +635,12 @@ func taskPointerBlock(entry session.TaskIndexEntry) string { if entry.TranscriptURI != "" { where = append(where, "Transcript: "+entry.TranscriptURI) } - if entry.Live() { + if program := strings.TrimSpace(entry.Program); program != "" && entry.Live() { + // A PROGRAM'S RUNNING WORK OFFERS NO STEER, because the program reads no + // messages ([programRoomNoMessages]) and a `say` would be refused. The + // one door it has is the stop. + where = append(where, program+programRoomNoMessages+`; stop it with tasks id `+entry.ID+` stop`) + } else if entry.Live() { // TWO DOORS ON ONE RUNNING NODE, and the block is read by the model, so // it names the model's first: `tasks id N say "…"` reaches the node's // loop exactly as the person's own line does (session.SteerTask). The diff --git a/internal/tui3/taskretry.go b/internal/tui3/taskretry.go index c337bbfbb..5adb9d489 100644 --- a/internal/tui3/taskretry.go +++ b/internal/tui3/taskretry.go @@ -31,6 +31,11 @@ func (a *app) taskCanRetry(entry session.TaskIndexEntry) bool { if node == nil || node.state != session.TaskFailed || node.run != "" || a.taskSheet.awayOwner.on { return false } + // A PROGRAM'S TASK IS NEVER RETRIED: the engine's retry reopens one of its + // own nodes, and a program's run is not one (session's programNotCarriedOn). + if strings.TrimSpace(entry.Program) != "" || node.program != "" { + return false + } switch node.kind { case session.TaskKindJob, session.TaskKindAdaptive: return false diff --git a/internal/tui3/taskretry_test.go b/internal/tui3/taskretry_test.go index b9fa9aa83..b60f5d9f5 100644 --- a/internal/tui3/taskretry_test.go +++ b/internal/tui3/taskretry_test.go @@ -156,3 +156,22 @@ func TestTaskRetryOffersEveryStoredRunnerAndUnsuccessfulEnding(t *testing.T) { } } } + +// A PROGRAM'S FAILED TASK OFFERS NO RETRY, because the engine's retry reopens +// one of its own nodes and a program's run is not one. +func TestAProgramsFailedTaskOffersNoRetry(t *testing.T) { + a, _, entry := retryFixture(t) + if !a.taskCanRetry(entry) { + t.Fatal("the fixture's ordinary failed task is not offered a retry") + } + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Title: "Repair export", State: session.TaskFailed, Report: "old failure", Program: "senior-dev", EndedAt: time.Now()}}) + entry.Program = "senior-dev" + a.taskSheet.detail = entry + if a.taskCanRetry(entry) { + t.Fatal("a program's failed task is offered a retry the engine refuses") + } + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := ansi.Strip(strings.Join(rows, "\n")); strings.Contains(text, taskRetryWord) { + t.Fatalf("the card offers %q on a program's task:\n%s", taskRetryWord, text) + } +} From eec8237d6ef52156c27ed743230bd8a13a35559a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:44:48 -0400 Subject: [PATCH 145/195] session, tui3, manual: a program's run says what it cost on its row, its landed card and the tasks tool A settled run's own row now carries the run's total, as its index row already did, so the landed card shows the price; a reopen settling a run reads it off the store's spend rows. The tasks tool's reader of the run's store prints the price beside the time. No book is summed from rows, so nothing is charged twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 4 ++- internal/session/task_run_belt.go | 28 ++++++++++++--- internal/session/task_run_clock_test.go | 47 +++++++++++++++++++++++++ internal/session/task_run_index.go | 5 ++- internal/session/tools_tasks.go | 26 +++++++++++--- internal/tui3/programbadge_test.go | 20 +++++++++++ 6 files changed, 118 insertions(+), 12 deletions(-) diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 6db0b3c87..3d6238cc8 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -349,7 +349,9 @@ folder refused. 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, and is held to the run's dollar ceiling: **once the run's spend has +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 diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index ffe6b22d9..83cfb5a2c 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -853,6 +853,11 @@ func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, k return } row := kept + // AND WHAT IT CAME TO, read off the store's spend rows: the process that + // knew the run's total is gone ([Agent.publishRunRow] carries it live). + if row.CostUSD == 0 { + row.CostUSD = storeSpent(store) + } if root.Status == plandb.StatusDone { row.State, row.Ending, row.Stopped = TaskDone, "", false row.Result = strings.TrimSpace(root.Result) @@ -899,16 +904,21 @@ func interruptedProgramEnding(store *plandb.Store, root *plandb.Task, record del return report, TaskEndingProgram, false } +// storeSpent is every dollar a run's store holds spend rows for. +func storeSpent(store *plandb.Store) float64 { + spent := 0.0 + for _, total := range store.SpendSummary().ByRole { + spent += total.USD + } + return spent +} + // interruptedLimitEnding is which limit ended a program's run, off the run's // own facts: its spend against the dollar ceiling the run handed its program // (the run's own ceiling, [delegate.ProgramRecord.CeilingUSD]) says the // dollars ran out, and any other limit ending is the run's time. func interruptedLimitEnding(store *plandb.Store, record delegate.ProgramRecord) TaskEnding { - spent := 0.0 - for _, total := range store.SpendSummary().ByRole { - spent += total.USD - } - if record.CeilingUSD > 0 && spent >= record.CeilingUSD { + if spent := storeSpent(store); record.CeilingUSD > 0 && spent >= record.CeilingUSD { return TaskEndingCostLimit } return TaskEndingTimeLimit @@ -1023,6 +1033,14 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { if notice.Elapsed == 0 { notice.Elapsed = runSpan(notice.StartedAt, notice.EndedAt) } + // A SETTLED RUN'S OWN ROW SAYS WHAT IT CAME TO, the figure its index row + // carries ([Agent.beltRunSpent]), so the landed card and every page drawn + // from the row show the price. No book is summed from rows: the conversation's + // total comes from the calls themselves (task_run_money.go), so this is a + // label and never a second charge. + if notice.State.settled() && notice.CostUSD == 0 { + notice.CostUSD = a.beltRunSpent(notice.ID) + } a.emitTaskUpdate(notice) g.keepRunRows(notice.ID, []TaskNotice{notice}) a.indexRunRow(notice) diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go index 678267601..d78fd17d7 100644 --- a/internal/session/task_run_clock_test.go +++ b/internal/session/task_run_clock_test.go @@ -466,3 +466,50 @@ func TestACheckpointHoldingAnInterruptedRunRowIsNotRefused(t *testing.T) { t.Fatalf("the checkpoint came back with %d rows, want both", len(back.Runs)) } } + +// A PROGRAM'S SETTLED ROW AND ITS OWN CONVERSATION'S tasks TOOL SAY WHAT IT +// COST. The row's notice carried no price, so the landed card drew none, and the +// tool's reader of the run's store printed no dollars. The figure is a label: the +// conversation's books take the run's calls once, through the fold, and a row +// that carries the total must not add it again. +func TestAProgramsRowAndTheTasksToolSayWhatItCost(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "") + double := newBeltRunDouble("submitted and verified") + double.summary.USD = 2.30 + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, title, _, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if err := spec.Store.AddSpend(spec.Store.RootID(), "delegate/fake", "worker", 2.30, 100, 50); err != nil { + t.Fatal(err) + } + endBeltRun(t, agent, double) + + row, ok := runRowOf(agent.graph(), id) + if !ok || !row.State.settled() || row.CostUSD != 2.30 { + t.Fatalf("the run's settled row = %+v, want it to carry $2.30", row) + } + name := "#" + strconv.FormatUint(id, 10) + listing, failed := runTool(t, agent, "tasks", `{}`) + if failed || !strings.Contains(listing, name+" · "+title+" · done · ") || !strings.Contains(listing, " · done · $2.30 · ") { + t.Fatalf("the listing answered %q (failed %v), want the run's price after its state", listing, failed) + } + read, failed := runTool(t, agent, "tasks", `{"id":"`+name+`"}`) + if failed || !strings.HasSuffix(strings.SplitN(read, "\n", 2)[0], " · done · $2.30") { + t.Fatalf("reading the run answered %q (failed %v), want its price on its first line", read, failed) + } + if got := agent.Usage().CostUSD; got > 2.30+1e-9 { + t.Fatalf("the conversation's books hold $%.4f for a $2.30 run: the row's price was charged again", got) + } +} diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go index ff6f361ac..f4bd2a737 100644 --- a/internal/session/task_run_index.go +++ b/internal/session/task_run_index.go @@ -72,7 +72,10 @@ func (a *Agent) indexRunRow(notice TaskNotice) { } entry.ArtifactURI = taskArtifactURI(worktree, notice.Branch, notice.Merge) if notice.State.settled() { - entry.Cost = a.beltRunSpent(notice.ID) + entry.Cost = notice.CostUSD + if entry.Cost == 0 { + entry.Cost = a.beltRunSpent(notice.ID) + } } a.recordTaskIndexEntry(entry) // Another window learns the run started, or ended, now rather than at the diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index 1584e0070..e6e2eb949 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -1283,8 +1283,8 @@ func taskRowText(entry TaskIndexEntry) string { if entry.DurationMS > 0 { parts = append(parts, taskSpanWord(entry.Duration())) } - if entry.Cost > 0 { - parts = append(parts, "$"+strconv.FormatFloat(entry.Cost, 'f', 2, 64)) + if cost := taskDollarWord(entry.Cost); cost != "" { + parts = append(parts, cost) } if via := taskViaWord(entry.Program); via != "" { parts = append(parts, via) @@ -1318,6 +1318,15 @@ func taskViaWord(program string) string { return "via " + program } +// taskDollarWord is a task's spend as the tasks tool spells it on every row, +// `$0.31`, and "" for none, which says nothing rather than `$0.00`. +func taskDollarWord(usd float64) string { + if usd <= 0 { + return "" + } + return "$" + strconv.FormatFloat(usd, 'f', 2, 64) +} + // taskWhereClauses is the trailing line a row may carry: where the work IS, the // record's own verdict when the work did not settle whole, and where the STORY // is. It is ONE builder for a root row and a queried child, so the two can never @@ -1485,9 +1494,10 @@ func planTaskLabels(rows []PlanTaskRow) map[string]string { // Empty when there is no run or nothing in it matches, so the caller's own // listing stands alone. // -// A PROGRAM'S RUN SAYS HOW LONG IT TOOK, off the run's one pair -// ([planRowSpanWord], task_run_clock.go): the tool said no time at all, and a -// model asked how long senior-dev took could only guess. AND IT SAYS WHICH +// A PROGRAM'S RUN SAYS HOW LONG IT TOOK AND WHAT IT COST, off the run's one pair +// ([planRowSpanWord], task_run_clock.go) and its spend rows ([taskDollarWord]): +// the tool said neither, and a model asked how long senior-dev took or what it +// cost could only guess. AND IT SAYS WHICH // PROGRAM HAS IT, after the clock, in [taskViaWord]'s one spelling — the same // place on the line [taskRowText] puts it. func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { @@ -1504,6 +1514,9 @@ func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { if span := planRowSpanWord(row, now); span != "" { fmt.Fprintf(&b, " · %s", span) } + if cost := taskDollarWord(row.USD); cost != "" { + fmt.Fprintf(&b, " · %s", cost) + } if via := taskViaWord(row.Program); via != "" { fmt.Fprintf(&b, " · %s", via) } @@ -1541,6 +1554,9 @@ func (a *Agent) planTaskText(rows []PlanTaskRow, token string) (string, bool) { if span := planRowSpanWord(page.Row, a.taskClockNow()); span != "" { head += " · " + span } + if cost := taskDollarWord(page.Row.USD); cost != "" { + head += " · " + cost + } if via := taskViaWord(page.Row.Program); via != "" { head += " · " + via } diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go index aa83e418d..6f131328a 100644 --- a/internal/tui3/programbadge_test.go +++ b/internal/tui3/programbadge_test.go @@ -632,3 +632,23 @@ func TestAProgramsMentionBlockOffersTheStopAndNoSteer(t *testing.T) { t.Fatalf("an ordinary running task lost its steer:\n%s", block) } } + +// A PROGRAM'S LANDED CARD, OPENED, SAYS WHAT THE RUN COST, from the price its settled +// row carries (session's publishRunRow) — the card drew none while the row +// carried none. +func TestAProgramsLandedCardSaysWhatItCost(t *testing.T) { + a := programRailApp(t, "senior-dev") + settled := programNotice("senior-dev") + settled.CostUSD, settled.EndedAt, settled.Report = 2.30, taskFixtureNow, "submitted and verified" + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, programTitle, session.TaskDone, settled)}) + at := a.doneEntryFor(7) + if at < 0 { + t.Fatal("the program's run drew no landed card") + } + done := a.entries[at].done + done.open = true + card := plain(strings.Join(a.doneRows(done, 120, false), "\n")) + if !strings.Contains(card, "$2.30") { + t.Fatalf("the program's landed card names no price:\n%s", card) + } +} From 0b53366cf2d97e786bc27ba59278e191c5b7d5a9 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:49:31 -0400 Subject: [PATCH 146/195] session, manual: a program's proposal names only finished work in depends_on, and any task may name a program's finished run A program starts the moment it is approved and has nothing to wait in, so its depends_on was read and dropped. It is now refused before the card while what it names has not landed. An ordinary proposal naming a program's run was refused as naming no task; a run that ended done is now a dependency met, and one still going is refused in its own sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/how-tasks-run.md | 3 + internal/manual/chat/senior-dev.md | 17 ++- internal/manual/chat_test.go | 2 + internal/session/program_depends.go | 131 +++++++++++++++++++++++ internal/session/program_depends_test.go | 78 ++++++++++++++ internal/session/spawnfloor.go | 9 +- internal/session/task.go | 3 + 7 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 internal/session/program_depends.go create mode 100644 internal/session/program_depends_test.go diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 0fb3d5adb..604051cfd 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3154,6 +3154,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/senior-dev.md b/internal/manual/chat/senior-dev.md index 3d6238cc8..422b178c8 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -167,12 +167,27 @@ brief that tells senior-dev to make a checkout of its own somewhere else does no 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, no step cap, no Windows +## 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.` + +**It is never retried or carried on.** A run that ended, however it 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; a follow-up is a new `/senior-dev`. + **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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c9750666c..22192882f 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -933,6 +933,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, diff --git a/internal/session/program_depends.go b/internal/session/program_depends.go new file mode 100644 index 000000000..65dc27260 --- /dev/null +++ b/internal/session/program_depends.go @@ -0,0 +1,131 @@ +package session + +// A PROGRAM'S RUN AND depends_on. +// +// depends_on is kept by codeaf's own task graph: a node waits, queued, until +// what it names has landed ([TaskGraph.readinessLocked]). A program's run is +// not a node, and it has nothing to wait in: it starts the moment it is +// approved, in the person's folder, on a branch of its own. So a proposal +// handed to a program that names work not yet finished was started at once, +// with its depends_on read and dropped — the program worked on a folder the +// work it was meant to follow had not reached. +// +// AND THE OTHER WAY ROUND. A program's run is a row, not a node, so an ordinary +// proposal naming one was refused with "no task in this session has that id" +// over a run the rail was drawing. +// +// Both are settled here, before a card: a program's proposal names only work +// that has finished, or is refused in a sentence that says to propose it again +// once it has; an ordinary proposal may name a program's run that ended done +// (its work is on its branch, checked out in its folder, so nothing is left to +// wait for), and is refused while that run is still going, because nothing +// would wake the node when it ends. + +import ( + "fmt" + "strings" +) + +// proposalDependencyRefusal is the sentence a proposal's depends_on is refused +// in, or "" when every dependency it names is one it may name. +func (a *Agent) proposalDependencyRefusal(spec taskSpec) string { + if len(spec.dependsOn) == 0 { + return "" + } + g := a.graph() + missing, failed := g.doomedDependencies(spec.dependsOn) + missing, failed, running := programRunDependencies(g, missing, failed) + if bashBeltAsked() { + missing = a.missingRunDependencies(missing) + } + if len(missing)+len(failed) > 0 { + return dependencyRefusal(missing, failed) + } + if len(running) > 0 { + return programRunWaitRefusal(running) + } + if spec.via != "" { + if waiting := g.unfinishedDependencies(spec.dependsOn); len(waiting) > 0 { + return programCannotWaitRefusal(spec.via, waiting) + } + } + return "" +} + +// programRunDependencies takes the ids the graph did not know out of missing +// when they name a program's run, and sorts them by how that run stands: ended +// done is satisfied and dropped, ended any other way joins failed, and still +// going is running. +func programRunDependencies(g *TaskGraph, missing, failed []uint64) (stillMissing, nowFailed, running []uint64) { + nowFailed = failed + for _, id := range missing { + row, found := runRowOf(g, id) + if !found || strings.TrimSpace(row.Program) == "" { + stillMissing = append(stillMissing, id) + continue + } + switch { + case row.State == TaskDone: + case row.State.settled(): + nowFailed = append(nowFailed, id) + default: + running = append(running, id) + } + } + return stillMissing, nowFailed, running +} + +// withoutEndedProgramRuns is ids with every program run that ended done taken +// out: [Agent.proposalDependencyRefusal] let it through as satisfied, and the +// graph, which knows no such node, would otherwise wait on it for ever. +func (a *Agent) withoutEndedProgramRuns(ids []uint64) []uint64 { + if len(ids) == 0 { + return ids + } + g := a.tasker() + kept := make([]uint64, 0, len(ids)) + for _, id := range ids { + if g != nil && !g.holdsNode(id) { + if row, found := runRowOf(g, id); found && row.Program != "" && row.State == TaskDone { + continue + } + } + kept = append(kept, id) + } + return kept +} + +// unfinishedDependencies is each id whose node has not landed done. +func (g *TaskGraph) unfinishedDependencies(ids []uint64) []uint64 { + g.mu.Lock() + defer g.mu.Unlock() + var waiting []uint64 + for _, id := range ids { + if node := g.nodes[id]; node != nil && node.state != TaskDone { + waiting = append(waiting, id) + } + } + return waiting +} + +// holdsNode is whether id is one of the graph's own nodes. +func (g *TaskGraph) holdsNode(id uint64) bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.nodes[id] != nil +} + +// programCannotWaitRefusal is a program's proposal naming work not yet done. +func programCannotWaitRefusal(program string, ids []uint64) string { + return fmt.Sprintf("Invalid arguments: depends_on names %s, which has not finished, and %s starts the moment it is approved — it cannot wait. "+ + "Propose it again once %s has landed, or with depends_on left out if nothing must finish first.", + numberedTasks(ids), program, numberedTasks(ids)) +} + +// programRunWaitRefusal is an ordinary proposal naming a program's run that +// is still going. +func programRunWaitRefusal(ids []uint64) string { + return fmt.Sprintf("Invalid arguments: depends_on names %s, a program's run that has not ended, and a task cannot wait on one. "+ + "Propose it again once %s has ended, or with depends_on left out if nothing must finish first.", + numberedTasks(ids), numberedTasks(ids)) +} diff --git a/internal/session/program_depends_test.go b/internal/session/program_depends_test.go new file mode 100644 index 000000000..fce686f73 --- /dev/null +++ b/internal/session/program_depends_test.go @@ -0,0 +1,78 @@ +package session + +import ( + "slices" + "strings" + "testing" +) + +// dependsConversation is a program-carrying conversation whose graph holds a +// node 3 in the state given and a program's run row 5 in the state given. +func dependsConversation(t *testing.T, node, run TaskState) *Agent { + t.Helper() + agent := programConversation(t, nil) + g := agent.graph() + g.mu.Lock() + g.nodes[3] = &TaskNode{id: 3, state: node} + g.mu.Unlock() + agent.publishRunRow(g, TaskNotice{ID: 5, Title: "rewrite the auth", State: run, Program: "senior-dev", StartedAt: agent.taskClockNow()}) + return agent +} + +// A PROGRAM'S PROPOSAL NAMING WORK NOT YET DONE IS REFUSED, because a program +// starts the moment it is approved and has nothing to wait in: it used to start +// at once with its depends_on dropped. +func TestAProgramsProposalNamingUnfinishedWorkIsRefused(t *testing.T) { + for _, state := range []TaskState{TaskQueued, TaskRunning, TaskUnverified} { + agent := dependsConversation(t, state, TaskDone) + spec := taskSpec{via: "senior-dev", dependsOn: []uint64{3}} + if got, want := agent.proposalDependencyRefusal(spec), programCannotWaitRefusal("senior-dev", []uint64{3}); got != want { + t.Fatalf("a program's proposal on a %s node answered %q, want %q", state, got, want) + } + if refusal := agent.refuseProposedTask(spec); refusal == nil { + t.Fatalf("the door let a program's proposal on a %s node through", state) + } + } + agent := dependsConversation(t, TaskDone, TaskDone) + if got := agent.proposalDependencyRefusal(taskSpec{via: "senior-dev", dependsOn: []uint64{3, 5}}); got != "" { + t.Fatalf("a program's proposal on landed work was refused: %q", got) + } + // AN ORDINARY PROPOSAL STILL WAITS in the graph, as it always has. + agent = dependsConversation(t, TaskRunning, TaskDone) + if got := agent.proposalDependencyRefusal(taskSpec{dependsOn: []uint64{3}}); got != "" { + t.Fatalf("an ordinary proposal on a running node was refused: %q", got) + } +} + +// AN ORDINARY PROPOSAL MAY NAME A PROGRAM'S RUN. It was refused with "no task +// in this session has that id" over a run the rail was drawing. A run that +// ended done is a dependency met and is taken out before the graph sees it; one +// still going is refused, because nothing would wake the node when it ends; one +// that ended any other way is refused as failed. +func TestAnOrdinaryProposalMayNameAProgramsRun(t *testing.T) { + agent := dependsConversation(t, TaskDone, TaskDone) + spec := taskSpec{dependsOn: []uint64{3, 5}} + if got := agent.proposalDependencyRefusal(spec); got != "" { + t.Fatalf("a proposal naming a program's finished run was refused: %q", got) + } + if kept := agent.withoutEndedProgramRuns(spec.dependsOn); !slices.Equal(kept, []uint64{3}) { + t.Fatalf("the dependencies handed on are %v, want the program's finished run taken out", kept) + } + + agent = dependsConversation(t, TaskDone, TaskRunning) + if got, want := agent.proposalDependencyRefusal(spec), programRunWaitRefusal([]uint64{5}); got != want { + t.Fatalf("a proposal naming a running program's run answered %q, want %q", got, want) + } + if kept := agent.withoutEndedProgramRuns(spec.dependsOn); !slices.Equal(kept, []uint64{3, 5}) { + t.Fatalf("a running program's run was taken out of the dependencies: %v", kept) + } + + agent = dependsConversation(t, TaskDone, TaskFailed) + if got := agent.proposalDependencyRefusal(spec); !strings.Contains(got, "depends_on names task 5, which already failed") { + t.Fatalf("a proposal naming a failed program's run answered %q", got) + } + + if got := agent.proposalDependencyRefusal(taskSpec{dependsOn: []uint64{9}}); !strings.Contains(got, "no task in this session has that id") { + t.Fatalf("an id nothing holds answered %q", got) + } +} diff --git a/internal/session/spawnfloor.go b/internal/session/spawnfloor.go index 727dc4929..33bb1f46d 100644 --- a/internal/session/spawnfloor.go +++ b/internal/session/spawnfloor.go @@ -80,13 +80,8 @@ func (a *Agent) refuseProposedTask(spec taskSpec) bare.Staged { if verb != "" && spec.via == "" { return bare.Settled(spawnFloorRefusal, true) } - if missing, failed := a.graph().doomedDependencies(spec.dependsOn); len(missing)+len(failed) > 0 { - if bashBeltAsked() { - missing = a.missingRunDependencies(missing) - } - if len(missing)+len(failed) > 0 { - return bare.Settled(dependencyRefusal(missing, failed), true) - } + if refusal := a.proposalDependencyRefusal(spec); refusal != "" { + return bare.Settled(refusal, true) } return nil } diff --git a/internal/session/task.go b/internal/session/task.go index 6029a04e4..8efd5fd8b 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -656,6 +656,9 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged if refusal := a.refuseProposedTask(spec); refusal != nil { return refusal } + // A PROGRAM'S RUN THAT ENDED DONE IS A DEPENDENCY MET, and the graph knows + // no node by its id (program_depends.go). + spec.dependsOn = a.withoutEndedProgramRuns(spec.dependsOn) // A DELEGATE IS RESOLVED BEFORE THE CARD, so a name this machine has no // delegate for is answered with the names it has and nobody is asked to // approve work that could not start (delegate_door.go). From 378a0fd3c934e9f49dea674aadf2451d95390bc4 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:51:04 -0400 Subject: [PATCH 147/195] tui3: a senior-dev task opened from another window shows its actions, read-only A program's task writes no worker journal, so the guest page another window opened onto one read the owner's journal, found nothing, and drew the [senior-dev] badge over an empty body. The owner view now carries a TaskPage reader (remote.Agent.ReadPlanTaskPage, which keeps the engine's refusal), the guest page asks the owner's store once whether the task is a program's, and if it is the same room gains the program's body: actions under their steps, the pinned facts line, ctrl+y for the raw calls. It stays a guest page: no stop, no steer (enter says it is reading), the owner's lanes and trail kept, and a replaced conversation is its final answer on the page read as on the journal. The journal beat stops for a program's page, and the owner's landing notice reads the page once more. MethodPlanTaskPage joins the watcher allow-list; the page's verbs do not. Ordinary guest pages are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/chatv3_taskowner.go | 9 +- docs/changes/unreleased/1488-senior-dev.md | 2 +- internal/manual/chat/senior-dev.md | 12 +- internal/manual/chat/tasks.md | 4 +- internal/manual/chat_test.go | 1 + internal/remote/client.go | 23 ++- internal/remote/client_test.go | 15 ++ internal/remote/driver.go | 9 + internal/remote/driver_test.go | 49 +++++ internal/tui3/programroom.go | 26 +++ internal/tui3/room.go | 78 ++++--- internal/tui3/taskguest_test.go | 17 ++ internal/tui3/taskguestprogram_test.go | 228 +++++++++++++++++++++ internal/tui3/taskowner.go | 105 +++++++++- internal/tui3/tui3.go | 15 ++ 15 files changed, 554 insertions(+), 39 deletions(-) create mode 100644 internal/tui3/taskguestprogram_test.go diff --git a/cmd/codeaf/chatv3_taskowner.go b/cmd/codeaf/chatv3_taskowner.go index 4210acf2a..7969ad739 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/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 7000a9fbc..55df5a99f 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -8,7 +8,7 @@ invalidates: - "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 <model>` 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." + - "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." - "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." diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 6db0b3c87..0262b3dc4 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -518,7 +518,7 @@ it, and the branch its work is on. 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 +## 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 @@ -527,6 +527,16 @@ tool, the conversation list's task counts and every other codeaf window on the p 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 <that conversation>` 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 diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index e57030198..3050e6d86 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2728,7 +2728,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 <that conversation>` 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 diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c9750666c..a33334e94 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -937,6 +937,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, diff --git a/internal/remote/client.go b/internal/remote/client.go index 59a8a3c94..81a375900 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -2001,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 } func (a *Agent) PlanNote(id, text string) error { diff --git a/internal/remote/client_test.go b/internal/remote/client_test.go index ffb96eb82..28df7b387 100644 --- a/internal/remote/client_test.go +++ b/internal/remote/client_test.go @@ -1028,6 +1028,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 c19a58ff9..0eadada68 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 64477f9ab..a65060052 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/tui3/programroom.go b/internal/tui3/programroom.go index 01fd89358..755ebb2f1 100644 --- a/internal/tui3/programroom.go +++ b/internal/tui3/programroom.go @@ -179,7 +179,18 @@ func (a *app) programRoomDone() bool { // programRoomRead re-reads the open program room's page off the loop. The // answer lands only on the room that asked. +// +// A PAGE READ THROUGH ANOTHER CONVERSATION READS THE OWNER'S STORE, through +// its own view ([app.guestPageRead]). This window's store holds this +// conversation's task of the same number, and reading it here would draw that +// task's actions under the owner's name. func (a *app) programRoomRead() tea.Cmd { + if a.roomIsGuest() { + if a.room.program == nil { + return nil + } + return a.guestPageRead() + } agent, ok := a.planReader() room := a.room if !ok || room == nil || room.program == nil || room.program.reading || room.id == 0 { @@ -282,6 +293,21 @@ func (a *app) programRoomRows(width int) []row { } } } + // A PAGE READ THROUGH ANOTHER CONVERSATION says what is true of the reading + // under what it read, exactly as its journal page does (room.go's + // [app.roomGuestTail]): that the conversation under it was replaced, that + // it cannot ask the owner what the work is doing now, or that the owner is + // waiting on somebody. + var tail []row + if guest := a.roomGuest(); guest != nil && guest.lost { + tail = append(tail, row{text: pal.dim(fit(taskGuestGoneWord, inner)), entry: -1}) + } + if tail = append(tail, a.roomGuestTail(inner)...); len(tail) > 0 { + if len(out) > 0 { + out = append(out, row{entry: -1}) + } + out = append(out, tail...) + } if a.room.done && !a.roomLandingAsking() { if len(out) > 0 { out = append(out, row{entry: -1}) diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 3fb73d210..e40085e1e 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -918,7 +918,11 @@ func (a *app) readRoomRecord() tea.Cmd { } if guest := a.room.guest; guest != nil { read, id, gen := guest.room, a.room.id, a.room.gen - if read == nil || guest.lost { + // A PROGRAM'S TASK HAS NO JOURNAL TO READ. What it did is its page in the + // owner's store, which the program room reads on its own beat + // ([app.guestPageRead]); asking the owner for a journal as well would be + // four calls a second for nothing. + if read == nil || guest.lost || a.room.program != nil { return nil } return func() tea.Msg { @@ -1517,6 +1521,13 @@ func (a *app) steer() tea.Cmd { if room.orch != nil { return a.orchSteer() } + // A PAGE READ THROUGH ANOTHER CONVERSATION SAYS IT IS READING, a program's + // page included: the program's refusal names this window's main as the door, + // and the words belong in the conversation that owns the work. + if a.roomIsGuest() { + a.roomNote(roomGuestReadingWord) + return nil + } // A PROGRAM READS NO MESSAGE (programroom.go). Nothing is sent and nothing is // taken out of the box: the page says so, names where the words can go, and // leaves the sentence where the person can carry it there. @@ -1524,10 +1535,6 @@ func (a *app) steer() tea.Cmd { a.roomNote(a.programRoomRefusal().line()) return nil } - if a.roomIsGuest() { - a.roomNote(roomGuestReadingWord) - return nil - } if room.done { a.raiseGuard(line, "") return nil @@ -3238,6 +3245,41 @@ func (a *app) roomNodeModel() string { return strings.TrimSpace(node.model) } +// roomGuestTail is what a page read through another conversation says under +// whatever it read, and nothing on every other page. Both of a guest page's +// bodies end with it — the owner's journal, and a program's actions read out +// of the owner's store (programroom.go) — because both lines are about the +// READING, not about what was read. +func (a *app) roomGuestTail(inner int) []row { + var out []row + // A READING PAGE WITH NO WAY TO ASK ITS OWNER SAYS SO, once, under whatever + // it did read. It is not a refusal and not an error — the transcript above it + // is real — it is the one thing the page cannot know, said rather than + // papered over with a state word that stopped being true (taskowner.go's + // [app.roomGuestStale]). + if a.roomGuestStale() { + out = append(out, row{text: a.pal.dim(fit(roomGuestStaleWord, inner)), entry: -1}) + } + // AND A CONVERSATION THAT HAS STOPPED AND IS WAITING ON SOMEBODY SAYS SO, + // under what it has done so far. The roster cannot say it — a node sitting on + // a question is still `running` — so a page reading somebody else's work drew + // a clock over work that had not moved since somebody was asked something + // (taskowner.go's questions lane). + // + // IT IS DIM AND NOT AMBER, AND THAT IS THE HUE LAW RATHER THAN AN OVERSIGHT. + // Amber is waiting on YOU and nothing else (docs/design/questions/DESIGN.md); + // this question is waiting on the window that owns the work, this page has no + // key that would answer it, and a row here in the colour that means "press + // something" would be asking a person for a keystroke that does not exist. + if asked, waiting := a.roomGuest().waiting(); waiting { + if head := strings.TrimSpace(asked.Head); head != "" { + line := a.icon(tokens.GNeedsHuman) + " " + head + railSep + roomGuestAskedWord + out = append(out, row{text: a.pal.dim(fit(line, inner)), entry: -1}) + } + } + return out +} + func (a *app) roomNode() *taskNode { if a.room == nil { return nil @@ -3650,31 +3692,7 @@ func (a *app) roomRows(width int) []row { if call, ok := a.roomCallRow(inner); ok { out = append(out, call) } - // AND A READING PAGE WITH NO WAY TO ASK ITS OWNER SAYS SO, once, under - // whatever it did read. It is not a refusal and not an error — the transcript - // above it is real — it is the one thing the page cannot know, said rather - // than papered over with a state word that stopped being true (taskowner.go's - // [app.roomGuestStale]). - if a.roomGuestStale() { - out = append(out, row{text: a.pal.dim(fit(roomGuestStaleWord, inner)), entry: -1}) - } - // AND A CONVERSATION THAT HAS STOPPED AND IS WAITING ON SOMEBODY SAYS SO, - // under what it has done so far. The roster cannot say it — a node sitting on - // a question is still `running` — so a page reading somebody else's work drew - // a clock over work that had not moved since somebody was asked something - // (taskowner.go's questions lane). - // - // IT IS DIM AND NOT AMBER, AND THAT IS THE HUE LAW RATHER THAN AN OVERSIGHT. - // Amber is waiting on YOU and nothing else (docs/design/questions/DESIGN.md); - // this question is waiting on the window that owns the work, this page has no - // key that would answer it, and a row here in the colour that means "press - // something" would be asking a person for a keystroke that does not exist. - if asked, waiting := a.roomGuest().waiting(); waiting { - if head := strings.TrimSpace(asked.Head); head != "" { - line := a.icon(tokens.GNeedsHuman) + " " + head + railSep + roomGuestAskedWord - out = append(out, row{text: a.pal.dim(fit(line, inner)), entry: -1}) - } - } + out = append(out, a.roomGuestTail(inner)...) if room.done { // THE FOOT. A room on a node that has landed says so once, at the bottom, // where the next thing would have appeared — which is the place a person diff --git a/internal/tui3/taskguest_test.go b/internal/tui3/taskguest_test.go index 2d18c852b..41c1519bc 100644 --- a/internal/tui3/taskguest_test.go +++ b/internal/tui3/taskguest_test.go @@ -74,6 +74,13 @@ type guestDoor struct { asking chan session.Event // leftAsking counts that lane's way out being taken. leftAsking int + // pages is the OWNER'S STORE, keyed by the id a page is read by, and nil + // until a test arms it — a door that cannot read the owner's task pages is a + // real door ([TaskOwnerView.TaskPage]). pageErr is what that read answers + // with instead, and pageAsked is every id it was asked for. + pages map[string]session.PlanTaskPage + pageErr error + pageAsked []string } // watching arms this door with the owner's task lane and hands the test the end @@ -123,6 +130,16 @@ func (d *guestDoor) open(ask TaskOwnerAsk) (TaskOwnerView, error) { } } } + if d.pages != nil { + view.TaskPage = func(id string) (session.PlanTaskPage, bool, error) { + d.pageAsked = append(d.pageAsked, id) + if d.pageErr != nil { + return session.PlanTaskPage{}, false, d.pageErr + } + page, found := d.pages[id] + return page, found, nil + } + } if d.asking != nil { lane := d.asking view.Questions = func() (<-chan session.Event, func()) { diff --git a/internal/tui3/taskguestprogram_test.go b/internal/tui3/taskguestprogram_test.go new file mode 100644 index 000000000..9f0cb0c0d --- /dev/null +++ b/internal/tui3/taskguestprogram_test.go @@ -0,0 +1,228 @@ +package tui3 + +// ── A PROGRAM'S TASK READ THROUGH SOMEBODY ELSE'S CONVERSATION ────────────── +// +// A task handed to senior-dev has no worker journal: what the program did is +// its conversation with codeaf, on the task's stored page, and the page a +// person reads is its actions (taskconversation.go). A guest page onto such a +// task used to read the one thing it knew how to read — the owner's journal — +// and drew a header wearing `[senior-dev]` over a body with nothing in it. +// +// These tests hold the page to the program's room this window draws for its +// own run, READ-ONLY: the actions under their steps, `ctrl+y` to the raw calls, +// no stop, no steer, and every read made through the owner's view and never +// through this window's own store, whose task of the same number is different +// work. + +import ( + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// enterAwayPumped presses the away row and settles the attach AND the page's +// first reads, which [enterAway] leaves unrun. +func enterAwayPumped(t *testing.T, a *app) { + t.Helper() + awayRowOf(t, a) + cmd := a.taskSheetEnter() + if cmd == nil { + t.Fatal("enter over another window's running work did nothing at all") + } + msg, ok := cmd().(taskOwnerMsg) + if !ok { + t.Fatalf("enter did not ask the engine for the owner: %T", cmd()) + } + drain(t, a, a.tookTaskOwner(msg)) +} + +// guestProgramLab is [guestLab] with the owner's task 7 handed to senior-dev: +// its store answers the program's page for it. +func guestProgramLab(t *testing.T) (*app, *guestDoor) { + t.Helper() + a, door := guestLab(t) + door.pages = map[string]session.PlanTaskPage{"7": programPage(programRow(), programTurns())} + return a, door +} + +// A PROGRAM'S TASK OPENED FROM ANOTHER WINDOW IS ITS ACTIONS, and the page is +// read-only exactly as every other guest page is. +func TestAGuestPageOntoAProgramsTaskDrawsItsActions(t *testing.T) { + a, door := guestProgramLab(t) + enterAwayPumped(t, a) + + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if a.programOf() == nil { + t.Fatalf("a program's task opened from another window is not its actions:\n%s", roomText(a)) + } + // THE OWNER'S STORE WAS ASKED, BY THE TASK'S OWN NUMBER. + if len(door.pageAsked) == 0 || door.pageAsked[0] != "7" { + t.Fatalf("the owner's store was asked for %v, want task 7", door.pageAsked) + } + lines := strings.Split(roomText(a), "\n") + if !underStep(lines, "explore", "read internal/auth/middleware.go") { + t.Fatalf("the page does not draw the program's reading under EXPLORE:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(roomText(a), programTabSaid) { + t.Fatalf("the page does not open on the actions:\n%s", roomText(a)) + } + // AND IT STOPS ASKING FOR A JOURNAL THE PROGRAM NEVER WROTE. + if a.readRoomRecord() != nil { + t.Fatal("a program's guest page still reads the owner's journal on its beat") + } + if a.room.loading { + t.Fatal("a program's guest page still says it is loading a conversation") + } + + // THE PAGE STAYS A READING. No stop, no steer, and the box says what it is. + if !a.stopHere().empty() { + t.Fatal("a program's guest page offers a stop, which would end this window's own task 7") + } + box, _, _ := a.inputBlock(80) + if lane := plain(strings.Join(a.roomSteerLaneRows(box, 80), "")); !strings.Contains(lane, roomGuestLane) { + t.Fatalf("a program's guest page's box offers %q", lane) + } + a.input.setText("pause it") + a.steer() + if got := a.input.String(); got != "pause it" { + t.Fatalf("a program's guest page spent the words: %q", got) + } + if body := roomText(a); !strings.Contains(body, roomGuestReadingWord) { + t.Fatalf("enter on a program's guest page does not say it is reading:\n%s", body) + } + if trail := a.roomTrail(); !strings.HasPrefix(trail, roomGuestOwnerWord) { + t.Fatalf("the page's trail hangs off this conversation: %q", trail) + } + + // `ctrl+y` TURNS IT TO THE RAW CALLS AND BACK, AND THE KEY ROW SAYS SO. + a.input.setText("") + if hint := a.roomHint(); hint != programCallsWord { + t.Fatalf("the key row reads %q, want %q", hint, programCallsWord) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") { + t.Fatalf("ctrl+y did not turn the page to the calls:\n%s", text) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); !strings.Contains(text, programTabSaid) { + t.Fatalf("ctrl+y did not turn the page back to the actions:\n%s", text) + } + + // THE LOCAL TASK 7 IS NOT A PROGRAM'S AND WAS NOT TOUCHED. + if node := a.tasks[7]; node == nil || node.state != session.TaskRunning || node.program != "" { + t.Fatalf("the local task 7 was changed by a page that was never about it: %+v", node) + } + a.closeRoom() + if door.closed != 1 { + t.Fatalf("closing the page released the view %d times", door.closed) + } +} + +// AN ORDINARY TASK OPENED FROM ANOTHER WINDOW IS THE PAGE IT ALWAYS WAS. The +// owner's store answering a page with no program on it — or a door with no +// store reader at all — leaves the journal reading exactly where it was. +func TestAGuestPageOntoAnOrdinaryTaskIsUnchanged(t *testing.T) { + for _, tc := range []struct { + name string + pages map[string]session.PlanTaskPage + }{ + {"no store reader", nil}, + {"nothing stored", map[string]session.PlanTaskPage{}}, + {"stored without a program", map[string]session.PlanTaskPage{"7": {Row: session.PlanTaskRow{ID: "t-7", Title: "Port the parser", Status: "running"}}}}, + } { + t.Run(tc.name, func(t *testing.T) { + a, door := guestLab(t) + door.pages = tc.pages + enterAwayPumped(t, a) + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if a.programOf() != nil { + t.Fatal("an ordinary task opened from another window was turned into a program's page") + } + if door.read == 0 { + t.Fatal("an ordinary guest page did not read the owner's journal") + } + if a.readRoomRecord() == nil { + t.Fatal("an ordinary guest page stopped reading the owner's journal") + } + if body := roomText(a); !strings.Contains(body, roomGuestStaleWord) { + t.Fatalf("an ordinary guest page with no owner lane lost its caveat:\n%s", body) + } + }) + } +} + +// A PROGRAM'S GUEST PAGE WHOSE CONVERSATION WAS REPLACED KEEPS WHAT IT READ AND +// STOPS, as every guest page does: the engine's refusal arrives on the store's +// read instead of the journal's, and it is the same final answer. +func TestAProgramsGuestPageWhoseConversationWasReplacedStops(t *testing.T) { + a, door := guestProgramLab(t) + door.watching() + enterAwayPumped(t, a) + if a.programOf() == nil { + t.Fatal("no program's guest page to lose") + } + door.pageErr = errors.New("engine: that conversation is not open here any more") + a.programOf().readAt = a.now().Add(-elsewhereEvery) + drain(t, a, a.programRoomRead()) + + if !a.room.guest.lost { + t.Fatal("the page did not take the engine's final answer") + } + if door.left != 1 { + t.Fatalf("the owner's lane was released %d times at the final answer, want once", door.left) + } + body := roomText(a) + if !strings.Contains(body, taskGuestGoneWord) || !strings.Contains(body, programTabSaid) { + t.Fatalf("the lost page does not keep what it read and say what happened:\n%s", body) + } + asked := len(door.pageAsked) + if cmd := a.programRoomRead(); cmd != nil { + drain(t, a, cmd) + } + if len(door.pageAsked) != asked { + t.Fatal("a lost page went on asking the owner's store") + } + a.closeRoom() + if door.left != 1 || door.closed != 1 { + t.Fatalf("leaving released the lane %d times and the connection %d times, want once each", door.left, door.closed) + } +} + +// THE OWNER'S NOTICE SETTLES A PROGRAM'S GUEST PAGE, and the foot names the +// owner rather than this window's main. +func TestAProgramsGuestPageSettlesOnTheOwnersWord(t *testing.T) { + a, door := guestProgramLab(t) + door.watching() + enterAwayPumped(t, a) + if a.programOf() == nil { + t.Fatal("no program's guest page") + } + // THE STORE HAS ENDED THE RUN BY THE TIME THE OWNER SAYS SO, and the page + // reads it once more at that moment: the landing is on that last page. + landed := programRow() + landed.Status = "done" + door.pages["7"] = programPage(landed, programTurns()) + asked := len(door.pageAsked) + drain(t, a, ownerSays(t, a, session.Event{ + Kind: session.EventTaskUpdate, + Task: &session.TaskNotice{ID: 7, Title: "rewrite the auth middleware", State: session.TaskDone, Program: "senior-dev"}, + })) + if !a.room.done { + t.Fatal("the owner said its work landed and the page went on saying it was running") + } + if len(door.pageAsked) != asked+1 || a.programOf().page.Row.Status != "done" { + t.Fatalf("the landing was not read from the owner's store: %d reads after it, page %q", + len(door.pageAsked)-asked, a.programOf().page.Row.Status) + } + body := roomText(a) + if !strings.Contains(body, refusalOwnerLead+"docs pass") || strings.Contains(body, refusalMainDoor) { + t.Fatalf("the landed program's guest page does not name the owner's door:\n%s", body) + } + a.closeRoom() +} diff --git a/internal/tui3/taskowner.go b/internal/tui3/taskowner.go index 022220999..7f4d9e699 100644 --- a/internal/tui3/taskowner.go +++ b/internal/tui3/taskowner.go @@ -32,7 +32,9 @@ package tui3 // closed, and both conversations go on running. // - ATTACH — a conversation the ENGINE is running that this window can join // as a second view ([Options.OpenTaskOwner]). The page is that task's own -// transcript, live, with `esc` back to where the person was. +// transcript, live, with `esc` back to where the person was — or, for a +// task handed to a program, which writes no transcript, the program's +// actions off the owner's store ([app.guestPageRead]). // // AND WHERE THERE IS NO REACH AT ALL — no capability, an engine that refused, a // conversation on no list this machine keeps — the card says the one short thing @@ -52,6 +54,7 @@ package tui3 import ( "path/filepath" + "strconv" "strings" tea "charm.land/bubbletea/v2" @@ -309,6 +312,14 @@ type taskGuest struct { asking []session.Question questions <-chan session.Event stopAsking func() + + // page reads the task's stored page in the OWNER'S store + // ([TaskOwnerView.TaskPage]), and pageReading says a read is out. It is + // how a page onto a program's task finds out that it is one, and then the + // only thing that page reads: a program writes no worker journal, and what + // it did is its conversation with codeaf on that page ([app.guestPageRead]). + page func(id string) (session.PlanTaskPage, bool, error) + pageReading bool } // waiting is the question this page says the owner is stopped on: the oldest, @@ -478,6 +489,13 @@ func (a *app) tookGuestNotice(msg taskGuestNoticeMsg) tea.Cmd { // nothing else would ever ask that conversation for another line. return tea.Batch(next, farRoomTick(room.gen)) } + if !was && room.done && room.program != nil { + // AND A PROGRAM'S PAGE READS THE OWNER'S STORE ONCE MORE WHEN THE OWNER + // SAYS THE WORK LANDED, for the reason this window's own program room + // does ([app.programRoomFollow]): the page the last beat read is the page + // from before the landing, and nothing reads it after a room is over. + return tea.Batch(next, a.guestPageRead()) + } return next } @@ -851,6 +869,7 @@ func (a *app) tookTaskOwner(msg taskOwnerMsg) tea.Cmd { // way to say what this piece of work was cut out of. trail: a.taskGuestTrail(ask.item), room: msg.view.Room, + page: msg.view.TaskPage, close: msg.view.Close, } if ask.trail != nil { @@ -907,9 +926,93 @@ func (a *app) tookTaskOwner(msg taskOwnerMsg) tea.Cmd { if guest.questions != nil { a.roomPump = tea.Batch(a.roomPump, waitGuestQuestions(guest.questions, room.gen)) } + // AND THE OWNER'S STORE IS ASKED, ONCE, WHETHER THIS IS A PROGRAM'S TASK — + // the question this window's own rooms put to its own store + // ([app.roomProgramCheck]), put to the owner's. The row cannot answer it: + // another window's presence names no program, and the owner's notice that + // does is a badge, not a page. + a.roomPump = tea.Batch(a.roomPump, a.guestPageRead()) return a.takeRoomPump() } +// guestPageRead reads the owner's stored page for the task a guest page is on, +// through the view, and folds it in: the first answer naming a program turns +// the page into that program's room (programroom.go), and every answer after +// it is that room's next page. +// +// IT READS THROUGH THE VIEW AND NEVER THROUGH [app.planReader]. That is THIS +// window's store, where the same number is this conversation's own task — the +// crossover [app.roomIsGuest] exists to prevent, one layer down. And it is +// asked BESIDE this window's line of doors rather than in it: the line keeps +// this window's gestures in order on this window's engine, and a read on +// another conversation's connection is neither a gesture nor on that engine. +// +// THE PAGE IS NOT A NEW ROOM. The room was built by [app.tookTaskOwner] with +// the guest on it, and it keeps the guest — its trail, its lanes, its +// connection, its read-only doors — and gains the program's body. Nothing here +// freezes a node of this window's rail or points the box at a task of it. +// +// A REFUSAL NAMING THE REPLACED CONVERSATION IS THE PAGE'S FINAL ANSWER, as it +// is on a journal read ([app.tookGuestRecord]): the page keeps what it read, +// says why it stopped, and gives the owner's lanes back. Any other failure +// changes nothing — an engine too old to let a reading window read a page +// answers with a refusal of its own, and the page is the journal reading it +// always was. +func (a *app) guestPageRead() tea.Cmd { + room := a.room + if room == nil || room.guest == nil || room.id == 0 { + return nil + } + guest := room.guest + read := guest.page + if read == nil || guest.lost || guest.pageReading { + return nil + } + id, gen, asked := strconv.FormatUint(room.id, 10), room.gen, a.now() + guest.pageReading = true + if p := room.program; p != nil { + p.readAt = asked + } + return a.besideLine(func() func(bool) tea.Cmd { + page, found, err := read(id) + return func(here bool) tea.Cmd { + guest.pageReading = false + if !here || a.room != room || room.gen != gen || room.guest != guest || guest.lost { + return nil + } + if err != nil { + if strings.Contains(err.Error(), taskGuestGoneMark) { + guest.lost = true + guest.dropWatch() + room.dirty = true + a.roomTouched() + a.touch() + } + return nil + } + if !found { + return nil + } + if room.program == nil { + if page.Program == nil && strings.TrimSpace(page.Row.Program) == "" { + return nil + } + // THE JOURNAL IS GIVEN UP WITH THE WORD THAT PROMISED IT. A program + // has none, so `loading` would never be answered, and the beat that + // reads it stops at its next turn ([app.readRoomRecord]). + room.program = &programRoom{page: page, readAt: asked} + room.loading, room.readFailed = false, false + } else { + room.program.page = page + } + room.dirty = true + a.roomTouched() + a.touch() + return nil + } + }) +} + // taskGuestTrail is the work above one row of the record, inside ITS OWN // conversation, outermost first. // diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 07691a5dd..18221c309 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -388,6 +388,21 @@ type TaskOwnerView struct { // Nil is a door that cannot offer it. The page then says exactly what it said // before, which is what a capability that cannot work is owed. Questions func() (<-chan session.Event, func()) + // TaskPage reads ONE TASK'S STORED PAGE in the owner's own store — the page + // a program's task is drawn from, since a program writes no worker journal + // for [TaskOwnerView.Room] to read ([session.PlanTaskPage.Program]). It is + // the same read this window's own program room makes of its own store + // ([session.Agent.PlanTaskPage]), made on this view's connection so the id + // is answered in the owner's numbering and never in this window's. + // + // IT KEEPS THE ENGINE'S REFUSAL, where the agent's own read folds every + // failure into "not found": a page this window is reading learns that the + // conversation under it was replaced from exactly this error + // ([remote.ErrJoinedGone]), and a program's page reads nothing else. + // + // Nil is a door that cannot offer it, and every page opened through that + // door is the journal reading it always was. + TaskPage func(id string) (session.PlanTaskPage, bool, error) // Close gives back THIS VIEW'S connection and nothing else. The conversation // goes on running, the window that owns it keeps its keyboard, and the // engine is untouched. From 89784d1f51d10ed4d001a68635b7ff6bade7ffa4 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:02:58 -0400 Subject: [PATCH 148/195] changes: a senior-dev run cannot wait and is never carried on, and its price is on its card and in the tasks tool Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 55df5a99f..11c461b29 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -21,6 +21,7 @@ invalidates: - "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: <title>`), 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." + - "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] <brief>` 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." From a80a0b5f4b4089e8e3960c58992fa83e23c5163f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:16:22 -0400 Subject: [PATCH 149/195] tui3: a window on the engine reads the other conversations' presence, so their rows open The tasks place drew another conversation's running work only from a presence reading it asked of the agent, and only the in-process agent could answer. Bare `codeaf` holds a connection to its engine, so on every ordinary launch no such row was drawn (another window's senior-dev task read `enter go inside it`) and the reading page behind `enter read it as it runs` could not be reached from any real window; --no-host drew the row but has no engine to attach to. The engine launch now binds Options.Elsewhere to session.ElsewhereOf, which reads the presence files beside this window's transcript on this machine's disk with this conversation left out, and refreshElsewhere falls back to it when the agent cannot answer. --host binds nothing and keeps the card. Proven live with two windows on one engine and the stub model: the row reads `another window`, enter opens the program's actions read-only, ctrl+y turns to the raw calls, and the landing reads when the run ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/chatv3_local.go | 7 +++++ docs/changes/unreleased/1488-senior-dev.md | 2 +- internal/session/taskelsewhere.go | 27 ++++++++++++++++++ internal/session/taskelsewhere_test.go | 26 +++++++++++++++++ internal/tui3/app.go | 5 ++++ internal/tui3/taskguestprogram_test.go | 33 ++++++++++++++++++++++ internal/tui3/taskview.go | 12 ++++++++ internal/tui3/tui3.go | 17 +++++++++++ 8 files changed, 128 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index 404a97b89..3f152882b 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/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 55df5a99f..d743006a4 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -8,7 +8,7 @@ invalidates: - "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 <model>` 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." + - "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." diff --git a/internal/session/taskelsewhere.go b/internal/session/taskelsewhere.go index 332068f78..9b8297408 100644 --- a/internal/session/taskelsewhere.go +++ b/internal/session/taskelsewhere.go @@ -163,6 +163,33 @@ func (a *Agent) ElsewhereExcept(others ...string) Elsewhere { return ReadElsewhere(bucket, time.Now(), append([]string{a.config.Place.ID()}, others...)...) } +// ElsewhereOf is [Agent.Elsewhere] asked by a surface that holds a +// conversation's TRANSCRIPT and not its agent: the reading of the bucket that +// conversation's folder is in, with that conversation left out. +// +// IT EXISTS BECAUSE THE ORDINARY WINDOW HOLDS NO AGENT. Bare `codeaf` is a +// surface talking to this workspace's engine over a socket, and what it holds is +// a connection ([remote.Agent]), which has no reading of the disk to offer. The +// engine is on THIS machine, though, and the presence files are on this +// machine's disk beside the transcript the surface was handed — so the answer is +// the same arithmetic [Agent.ElsewhereExcept] does on its [Place], done on the +// path: the transcript's folder is the session, and its parent is the bucket. +// +// A transcript with no folder of its own has no bucket to look in and no id to +// leave out, and answers the empty reading, as a memory-only agent does. +func ElsewhereOf(transcript string, now time.Time) Elsewhere { + transcript = strings.TrimSpace(transcript) + if transcript == "" { + return Elsewhere{Read: now} + } + dir := filepath.Dir(transcript) + bucket := filepath.Dir(dir) + if dir == "." || bucket == "" || bucket == "." || bucket == dir { + return Elsewhere{Read: now} + } + return ReadElsewhere(bucket, now, filepath.Base(dir)) +} + // Any reports whether another window is open on this project at all. It is the // cheapest form of the question and the one a surface asks before it decides // whether a section exists. diff --git a/internal/session/taskelsewhere_test.go b/internal/session/taskelsewhere_test.go index e39b1770b..849923c19 100644 --- a/internal/session/taskelsewhere_test.go +++ b/internal/session/taskelsewhere_test.go @@ -201,3 +201,29 @@ func TestElsewhereIsEmptyForASessionWithNoFolder(t *testing.T) { t.Fatal("a memory-only conversation found other windows on a project it is not in") } } + +// A SURFACE THAT HOLDS ONLY A TRANSCRIPT READS THE SAME OTHER WINDOWS its agent +// would. The ordinary window talks to its engine over a socket and holds no +// agent; the presence files are on this machine's disk beside the transcript it +// was handed, and the reading off that path is the reading off the [Place]. +func TestElsewhereOfATranscriptIsTheReadingItsAgentWouldTake(t *testing.T) { + bucket := t.TempDir() + writeWindow(t, bucket, "mine", "", time.Second, + PresenceTask{ID: "1", Title: "My own task", State: string(TaskRunning)}) + writeWindow(t, bucket, "theirs", "docs pass", time.Second, + PresenceTask{ID: "7", Title: "Their task", State: string(TaskRunning)}) + + away := ElsewhereOf(Place{Dir: filepath.Join(bucket, "mine")}.Transcript(), time.Now()) + tasks := away.Tasks() + if len(tasks) != 1 || tasks[0].Task.Title != "Their task" || tasks[0].SessionID != "theirs" { + t.Fatalf("the reading is %+v, want only the other window's work", tasks) + } + if got := tasks[0].Session; got != "docs pass" { + t.Fatalf("the other window is called %q", got) + } + for _, empty := range []string{"", "transcript.jsonl"} { + if ElsewhereOf(empty, time.Now()).Any() { + t.Fatalf("a transcript with no folder (%q) found other windows", empty) + } + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index e1226e982..7a960cc30 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -873,6 +873,10 @@ type app struct { // states the whole contract, taskowner.go is the only caller). Nil is a // window with no engine road, which answers with the card instead. openTaskOwner func(TaskOwnerAsk) (TaskOwnerView, error) + // elsewhereOf reads the other conversations' presence off this machine's + // disk for a window whose agent cannot (tui3.go's [Options.Elsewhere]; + // taskview.go's [app.refreshElsewhere] is the only caller). + elsewhereOf func(transcript string, now time.Time) session.Elsewhere // taskOwnerGen numbers the attaches this window has asked for and taskOwnerAt // is the one still in flight. An answer carrying an older number is a view // nobody wants any more: it is CLOSED on arrival rather than drawn, which is @@ -2776,6 +2780,7 @@ func newApp(ctx context.Context, opts Options) *app { open: opts.Open, engineAnswers: opts.EngineAnswers, openTaskOwner: opts.OpenTaskOwner, + elsewhereOf: opts.Elsewhere, anchorWorkspace: opts.AnchorWorkspace, errand: opts.Errand, standingRoot: opts.StandingRoot, diff --git a/internal/tui3/taskguestprogram_test.go b/internal/tui3/taskguestprogram_test.go index 9f0cb0c0d..ec4d4ec2c 100644 --- a/internal/tui3/taskguestprogram_test.go +++ b/internal/tui3/taskguestprogram_test.go @@ -18,6 +18,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/session" ) @@ -226,3 +227,35 @@ func TestAProgramsGuestPageSettlesOnTheOwnersWord(t *testing.T) { } a.closeRoom() } + +// A WINDOW ON THE ENGINE ROAD DRAWS THE OTHER CONVERSATIONS' WORK AND OPENS IT. +// Bare `codeaf` holds a connection to its engine, and a connection has no +// reading of the disk ([elsewhereAgent] is the agent's own), so the rows of work +// another conversation was running were never drawn there — and the door behind +// them, [app.openOwnerRoom], could not be reached from any real window. The +// launch now hands the surface the reading ([Options.Elsewhere]), asked with the +// transcript this window is drawing, and the row opens its reading page. +func TestAnEngineWindowReadsTheOtherConversationsOffTheDiskAndOpensThem(t *testing.T) { + a, door := guestLab(t) + door.pages = map[string]session.PlanTaskPage{"7": programPage(programRow(), programTurns())} + // NOTHING READ YET, and the agent under this window answers no reading. + a.away = elsewhereCache{} + if _, answers := a.agent.(elsewhereAgent); answers { + t.Fatal("the fixture's agent reads the disk itself, so this test would prove nothing") + } + var asked []string + a.elsewhereOf = func(file string, now time.Time) session.Elsewhere { + asked = append(asked, file) + return session.NewElsewhere(now, map[string]string{"the-other-window": "docs pass"}, + window("the-other-window", session.PresenceTask{ + ID: "7", Title: "Port the parser", State: string(session.TaskRunning)})) + } + enterAwayPumped(t, a) + if len(asked) == 0 || asked[0] != a.file { + t.Fatalf("the reading was asked for %q, want this window's own transcript %q", asked, a.file) + } + if !a.roomIsGuest() || a.programOf() == nil { + t.Fatalf("the other conversation's program task did not open its reading page:\n%s", roomText(a)) + } + a.closeRoom() +} diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go index 51bb94991..e85b3c684 100644 --- a/internal/tui3/taskview.go +++ b/internal/tui3/taskview.go @@ -398,6 +398,18 @@ func (a *app) refreshElsewhere() { a.away = elsewhereCache{at: a.now(), read: true} agent, ok := a.agent.(elsewhereAgent) if !ok { + // A WINDOW WHOSE AGENT IS A CONNECTION READS THE DISK THROUGH THE LAUNCH. + // That is every ordinary window: bare `codeaf` talks to this workspace's + // engine over a socket, and a connection has no reading to give — so + // without this the other conversations' work was never drawn, and the + // door behind those rows ([app.openOwnerRoom]) could not be reached at + // all. The launch knows the disk is the engine's and says so by binding + // the reader (tui3.go's [Options.Elsewhere]); it is asked about the + // conversation on screen, which it leaves out, exactly as the agent's + // own reading does. + if a.elsewhereOf != nil { + a.away.held = a.elsewhereOf(a.file, a.now()) + } return } // THE WHOLE READING, MINUS THIS CONVERSATION. The engine leaves the session diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 18221c309..e7dcf9455 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -623,6 +623,23 @@ type Options struct { // the far machine. Every one of them keeps the road it had. EngineAnswers func(workspace string) bool + // Elsewhere reads what the project's OTHER conversations have out right + // now — the presence files beside the transcript this window is drawing — + // for a window whose agent cannot answer that itself + // ([session.ElsewhereOf] is the shape). + // + // IT IS THE HALF OF THE TASKS PAGE THE ENGINE ROAD HAD LOST. The rows of + // work another conversation is running are minted from that reading + // ([app.refreshElsewhere]), and it was asked of the agent alone: the + // in-process agent reads its own disk, and the connection bare `codeaf` + // holds to its engine does not ([remote.Agent] has no such method). So on + // the ordinary launch no such row was ever drawn, and [Options.OpenTaskOwner] + // — the door behind exactly those rows — could never be reached. + // + // Nil is a window whose disk is not the engine's (--host) or whose agent + // answers for itself (the in-process door); both keep the road they had. + Elsewhere func(transcript string, now time.Time) session.Elsewhere + // OpenTaskOwner attaches a SECOND VIEW onto a conversation that is ALREADY // RUNNING, for as long as one task page is on screen: a reader for that // task's journal, and the close that gives the view back. From 5f8c691fed9fd630ca14a42d698a2c1d6f33a144 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:45:08 -0400 Subject: [PATCH 150/195] =?UTF-8?q?session,=20tui3,=20manual:=20the=20merg?= =?UTF-8?q?e's=20loose=20ends=20=E2=80=94=20the=20page's=20trail=20wears?= =?UTF-8?q?=20the=20program's=20badge,=20the=20ratchet=20holds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task page's trail (dev's new head) draws the program's badge after the task's crumb. The program's join refusal and the carried program move out of joinOrWait and publishRunRow, back under the complexity ceiling. Tests that meant the switch off now say off, since an empty switch is the bash belt; a limit-ended ordinary run is archived as it ended (dev's one road). The manual says a /task in a conversation where senior-dev works is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/delegates.md | 5 ++- internal/session/delegate_door_test.go | 2 +- internal/session/task_run_belt.go | 49 ++++++++++++++++-------- internal/session/task_run_clock_test.go | 4 +- internal/session/task_run_orphan_test.go | 13 ++++--- internal/tui3/planrail.go | 12 ++++-- internal/tui3/taskconversation_test.go | 4 +- 7 files changed, 58 insertions(+), 31 deletions(-) diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 1f8fdef62..61baa98cb 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -177,7 +177,10 @@ 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 <folder>; <name> runs alone, so propose it again when that work has ended` (`in a copy of -<folder>` when the work underway is a task of codeaf's own). +<folder>` 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 <name>; it carries …`. diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index ff94128c6..612c59407 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -476,7 +476,7 @@ func TestEachProgramIsListedWithItsOwnGuideInNameOrder(t *testing.T) { // gated on the switch, a person on the default belt clicked into senior-dev's // task and got a room that said it would fill in, for the whole run. func TestAProgramsRunIsReadableWithTheSwitchOff(t *testing.T) { - t.Setenv("CODEAF_TASK_BELT", "") + t.Setenv("CODEAF_TASK_BELT", "off") if bashBeltAsked() { t.Fatal("the switch is still on, so this test would prove nothing") } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 49125fcb8..88138d8a7 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -640,15 +640,9 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } over := live.over if !live.ending && !live.closing && !live.stopped { - if via != nil || live.delegate != nil { - where := "in a copy of " + live.ground - if live.folder != nil { - where = "in " + live.ground - } - name := aloneName(via, live.delegate) + if refusal := programJoinRefusal(via, live); refusal != nil { a.beltMu.Unlock() - return nil, errors.New("work is already underway " + where + - "; " + name + " runs alone, so propose it again when that work has ended") + return nil, refusal } if canonicalPath(stand.dir) != live.ground { a.beltMu.Unlock() @@ -715,6 +709,21 @@ func (a *Agent) readyRunFolder(id uint64, title, sessionDir string, stand taskSt }) } +// programJoinRefusal is why a hand-off may not join the live run because a +// program is on one side of it, and nil when neither is a program's: a program +// never joins a run, and nothing joins a program's ([Agent.joinOrWait]). +func programJoinRefusal(via *delegate.Delegate, live *beltRun) error { + if via == nil && live.delegate == nil { + return nil + } + where := "in a copy of " + live.ground + if live.folder != nil { + where = "in " + live.ground + } + return errors.New("work is already underway " + where + + "; " + aloneName(via, live.delegate) + " runs alone, so propose it again when that work has ended") +} + // aloneName is the program a refused join is about: the one asked for, or the // one already running. func aloneName(via, running *delegate.Delegate) string { @@ -1307,14 +1316,7 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { break } } - if notice.Program == "" { - for _, kept := range g.runRows(notice.ID) { - if kept.ID == notice.ID && kept.Program != "" { - notice.Program = kept.Program - break - } - } - } + notice.Program = keptRunProgram(g, notice) if notice.Elapsed == 0 { notice.Elapsed = runSpan(notice.StartedAt, notice.EndedAt) } @@ -1331,6 +1333,21 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { a.indexRunRow(notice) } +// keptRunProgram is the program a run row names: its own when it names one, +// and otherwise the one the row it replaces was published with +// ([Agent.publishRunRow] says why it is carried). +func keptRunProgram(g *TaskGraph, notice TaskNotice) string { + if notice.Program != "" { + return notice.Program + } + for _, kept := range g.runRows(notice.ID) { + if kept.ID == notice.ID && kept.Program != "" { + return kept.Program + } + } + return "" +} + // cutBeltRun ends the live run because the CONVERSATION is ending. It is what // makes a run's life the conversation's rather than the process's, and it is // called from exactly one place ([Agent.Close]). diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go index d78fd17d7..4bb9b00ec 100644 --- a/internal/session/task_run_clock_test.go +++ b/internal/session/task_run_clock_test.go @@ -339,7 +339,7 @@ func TestAProgramsRunNothingIsDrivingEndsAtItsLastActivity(t *testing.T) { // said how long a run had taken. The same span reaches the note the // conversation is handed when the run lands. func TestTheTasksToolSeesAProgramsRunAndSaysHowLongItTook(t *testing.T) { - t.Setenv("CODEAF_TASK_BELT", "") + t.Setenv("CODEAF_TASK_BELT", "off") if bashBeltAsked() { t.Fatal("the switch is still on, so this test would prove nothing") } @@ -473,7 +473,7 @@ func TestACheckpointHoldingAnInterruptedRunRowIsNotRefused(t *testing.T) { // conversation's books take the run's calls once, through the fold, and a row // that carries the total must not add it again. func TestAProgramsRowAndTheTasksToolSayWhatItCost(t *testing.T) { - t.Setenv("CODEAF_TASK_BELT", "") + t.Setenv("CODEAF_TASK_BELT", "off") double := newBeltRunDouble("submitted and verified") double.summary.USD = 2.30 registerBeltRunEngine(t, double) diff --git a/internal/session/task_run_orphan_test.go b/internal/session/task_run_orphan_test.go index d1f9c8563..6073a1da9 100644 --- a/internal/session/task_run_orphan_test.go +++ b/internal/session/task_run_orphan_test.go @@ -104,10 +104,11 @@ func TestANewHandOffNeverRunsInsideADeadProgramsStore(t *testing.T) { } } -// AN ORDINARY RUN A LIMIT ENDED IS ARCHIVED INTACT. Its store is the record of -// what it did, so it is not ended; and the next hand-off runs its own brief -// under its own number rather than resuming the old one under the new title. -func TestALimitEndedRunIsArchivedIntactAndTheNextHandOffRunsItsOwnBrief(t *testing.T) { +// AN ORDINARY RUN A LIMIT ENDED IS ARCHIVED AS IT ENDED: its own task failed in +// the limit's words (the run road ends it by one road and names the limit), its +// brief kept; and the next hand-off runs its own brief under its own number +// rather than resuming the old one under the new title. +func TestALimitEndedRunIsArchivedAsItEndedAndTheNextHandOffRunsItsOwnBrief(t *testing.T) { t.Setenv("CODEAF_TASK_BELT", "bash") conversation := beltRunCommittedRepo(t) dir := t.TempDir() @@ -146,8 +147,8 @@ func TestALimitEndedRunIsArchivedIntactAndTheNextHandOffRunsItsOwnBrief(t *testi t.Fatalf("the limited run's store was not archived: %v", err) } defer archived.Close() - if root := archived.Task("91"); root == nil || terminalStoreStatus(root.Status) || root.Description != "brief one" { - t.Fatalf("the limited run's task = %+v, want it archived as it was left", root) + if root := archived.Task("91"); root == nil || root.Status != plandb.StatusFailed || root.Error != "a limit you set stopped it" || root.Description != "brief one" { + t.Fatalf("the limited run's task = %+v, want it archived as it ended, in the limit's words", root) } } diff --git a/internal/tui3/planrail.go b/internal/tui3/planrail.go index 8e91bd47e..01f726568 100644 --- a/internal/tui3/planrail.go +++ b/internal/tui3/planrail.go @@ -273,9 +273,15 @@ func (a *app) taskPlanTrail(width int) string { for _, back := range a.taskSheet.planBack { crumbs = append(crumbs, roomCrumb{word: strings.TrimSpace(back.Row.Title), kind: crumbAncestor}) } - crumbs = append(crumbs, roomCrumb{word: strings.TrimSpace(a.taskSheet.plan.Row.Title), kind: crumbHere}) + title := strings.TrimSpace(a.taskSheet.plan.Row.Title) + crumbs = append(crumbs, roomCrumb{word: title, kind: crumbHere}) back := " " + taskCardBackWord + " " room := width - headLabelAt - ansi.StringWidth(back) - 3 + // A PROGRAM'S PAGE WEARS ITS BADGE AFTER THE TASK'S CRUMB, the one its row + // wears on the side list (programbadge.go), spoken for before the crumbs are + // fitted so the page says whose work it is before a line under it is read. + wears := programSpelling(programBadge(pageProgram(a.taskSheet.plan)), title, max(room, 0), railTitleFloor) + room -= programCells(wears) label, hits, _ := fitCrumbChain(crumbs, max(room, 0)) if label == "" { label, hits, _ = fitCrumbChain(crumbs, max(width-headLabelAt, 0)) @@ -285,8 +291,8 @@ func (a *app) taskPlanTrail(width int) string { hit.span = hudSpan{from: hit.span.from + headLabelAt, to: hit.span.to + headLabelAt} placed[i] = hit } - line := strings.Repeat(" ", headLabelAt) + a.paintCrumbHits(label, headLabelAt, placed, crumbHit{}, false) - used := headLabelAt + ansi.StringWidth(label) + line := strings.Repeat(" ", headLabelAt) + a.paintCrumbHits(label, headLabelAt, placed, crumbHit{}, false) + a.pal.programAfter(wears) + used := headLabelAt + ansi.StringWidth(label) + programCells(wears) if used+2+ansi.StringWidth(back)+1 <= width { from := width - ansi.StringWidth(back) - 1 return line + strings.Repeat(" ", from-used) + a.pal.dim(back) + " " diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go index b5084d65c..96017f5f2 100644 --- a/internal/tui3/taskconversation_test.go +++ b/internal/tui3/taskconversation_test.go @@ -160,8 +160,8 @@ func TestAProgramsPageDrawsItsActionsUnderItsSteps(t *testing.T) { page := strings.Join(lines, "\n") t.Logf("a program's page, mid-way:\n%s", page) - if lines[0] != "rewrite the auth middleware [senior-dev]" { - t.Fatalf("the head's first row is %q, want the task's title and its program's badge", lines[0]) + if trail := strings.TrimSpace(lines[0]); !strings.Contains(trail, "rewrite the auth middleware [senior-dev]") || !strings.HasSuffix(trail, taskCardBackWord) { + t.Fatalf("the head's first row is %q, want the trail to the task with its program's badge, and the way back", lines[0]) } if lines[1] != "implement · $1.24 · 3 calls · 14m 3s" { t.Fatalf("the pinned line is %q, want the step, the spend, the calls and the age", lines[1]) From 06e129a5caccd1d2bd870225f8de8fde1d969eea Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:28:49 -0400 Subject: [PATCH 151/195] =?UTF-8?q?seniordev:=20git=20if=20it=20is=20there?= =?UTF-8?q?=20=E2=80=94=20a=20folder=20with=20no=20git=20history=20runs=20?= =?UTF-8?q?on=20the=20snapshot=20recorder,=20flag=20or=20no=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit senior-dev chose its git recorder unless told --in-place and ended at once with "workspace is not a git repository" otherwise. codeaf passed the flag for plain folders, but that made two readers of one question, and any launch that missed the flag (an older build, a hand-typed run) met an ending the chat had no lever for. newWorkspaceRecorder now picks git only for a work tree whose HEAD is a commit, and the snapshot recorder for everything short of that. --in-place still forces the snapshot recorder over a real repository, which codeaf keeps doing under a home-folder repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 2 +- docs/design/delegate/PROTOCOL.md | 5 +- internal/manual/chat/delegates.md | 3 +- internal/manual/chat/senior-dev.md | 30 ++++--- internal/seniordev/app/args.go | 5 +- internal/seniordev/app/workspace_recorder.go | 29 ++++-- .../seniordev/app/workspace_recorder_git.go | 6 +- .../seniordev/app/workspace_recorder_test.go | 88 +++++++++++++++++-- 8 files changed, 132 insertions(+), 36 deletions(-) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index e224bf059..d56bb4a90 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -14,7 +14,7 @@ invalidates: - "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: <folder>, 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 <folder> 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. codeaf now starts it with `--in-place` there (and in a folder under a repository rooted at the home folder or above): 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 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/<title>-<id>` (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 <branch> in <folder>, N files, and that branch is checked out there; your branch main is as it was: `git -C '<folder>' switch main` goes back to it, and `git -C '<folder>' merge <branch>` from there brings the work in`). A run that changed nothing switches back and deletes its branch (`it changed nothing, so <folder> is back on your branch <b> and its branch <task> was deleted`). A checkout with changes not committed, or in the middle of a merge or rebase, is refused before its card: `<folder> 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 (`<folder> is busy: senior-dev, task 1 (<title>), 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." diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index 8bb805ef1..fb54b8de7 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -54,7 +54,10 @@ with `git switch -c task/<title>-<id>`; 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. codeaf never learns a program's flag by +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. diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 61baa98cb..f2ba7bdc3 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -128,7 +128,8 @@ A folder with no git history (a plain folder, a repository with no commit yet, o 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. +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 <name>` inside it, or name the folder with `--dir`. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 79c91b6e9..f467a6ba8 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -202,9 +202,10 @@ refuse any path outside the folder it was handed, including one reached through 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**, on its own branch, unless it runs `--in-place`; codeaf -chooses that for a folder with no git history, from the chat and at a shell alike (see -the section on folders that are not a git repository). +**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 @@ -212,12 +213,12 @@ 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. The folder is read before senior-dev starts. **A folder with no git history — a -plain folder, or a repository with no first commit yet — is worked in as it is**, and -senior-dev is started with `--in-place`, from the chat and at a shell alike: it keeps its -checkpoints outside the folder and makes no commits. So is a folder inside a git -repository whose root is your home folder (a dotfiles repository): no branch is ever cut -there. +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 <folder>, which has no git history, so nothing was committed` (or, under a repository at @@ -235,9 +236,9 @@ skipped like any other. It is never started on your home folder or a folder abov (see the programs page): to check what it changed, it reads every file in the folder, and your home folder is not one project. -A shell run used to stop at once there with `workspace is not a git repository: -<folder>; run with --in-place to work in a plain folder`. It no longer does: `--in-place` -is passed for you. +senior-dev used to stop at once there with `workspace is not a git repository: +<folder>; 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 @@ -497,8 +498,9 @@ 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 in a folder without git: no commits, and its checkpoints kept - outside the folder. codeaf passes it itself for a folder with no git history; +- `--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; diff --git a/internal/seniordev/app/args.go b/internal/seniordev/app/args.go index 4cc67a624..9cd72c4f5 100644 --- a/internal/seniordev/app/args.go +++ b/internal/seniordev/app/args.go @@ -29,8 +29,9 @@ type cliArgs struct { // Variant is sent as `reasoning.effort`. Empty sends no `reasoning` key, // so the service's own default applies. Variant string - // InPlace selects the snapshot recorder: senior-dev edits the workspace - // without requiring a repository and without writing to one. + // 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 diff --git a/internal/seniordev/app/workspace_recorder.go b/internal/seniordev/app/workspace_recorder.go index 6e64c11a7..f5feef10e 100644 --- a/internal/seniordev/app/workspace_recorder.go +++ b/internal/seniordev/app/workspace_recorder.go @@ -73,16 +73,33 @@ type workspaceRecorder interface { CommitsOnWrite() bool } -// newWorkspaceRecorder picks the recorder for a run. Git is the default and -// the only recorder chosen by inspecting the workspace; --in-place is an -// explicit choice, never inferred. Inference would be wrong in the case that -// matters most: a run inside a real repository that must not touch its -// history is indistinguishable, from the filesystem, from one that should. +// 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 { + 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 index 39fab44ce..52867ba8b 100644 --- a/internal/seniordev/app/workspace_recorder_git.go +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -52,9 +52,9 @@ func (recorder *gitRecorder) git(args ...string) (string, error) { func (recorder *gitRecorder) Prepare(ctx context.Context) error { if gitOutput(ctx, recorder.workspace, "rev-parse", "--show-toplevel") == "" { - // A PERSON AT A SHELL CAN ANSWER THIS, so the sentence names the flag. - // The chat never meets it: codeaf reads the folder first and passes - // the flag itself (seniordev.Program's PlainFolder). + // 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 diff --git a/internal/seniordev/app/workspace_recorder_test.go b/internal/seniordev/app/workspace_recorder_test.go index a5e232936..0ac9c19af 100644 --- a/internal/seniordev/app/workspace_recorder_test.go +++ b/internal/seniordev/app/workspace_recorder_test.go @@ -5,6 +5,7 @@ package app import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -287,18 +288,89 @@ func TestInPlaceRunNeedsNoRepository(t *testing.T) { } } -// Without --in-place the workspace must still be a repository. The default -// path is unchanged, and this is what says so. -func TestDefaultRunStillRequiresARepository(t *testing.T) { +// 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", - }, &strings.Builder{}, &coderOnlyBackend{}) - if ending.Status != "crashed" { - t.Fatalf("a non-repository workspace was accepted without --in-place: %+v", ending) + }, ¬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) } - if !strings.Contains(ending.Message, "not a git repository") { - t.Fatalf("the ending does not name the cause: %q", ending.Message) + 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) + } } } From 7f6f6d638908bcacab07af79d3bcaee2fd2f8652 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:47:22 -0400 Subject: [PATCH 152/195] =?UTF-8?q?session,=20run,=20tui3:=20senior-dev's?= =?UTF-8?q?=20ending=20is=20codeaf's=20to=20act=20on=20=E2=80=94=20checked?= =?UTF-8?q?,=20sent=20back=20at=20most=20twice,=20one=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A program's ending was a sentence on a row, read by the chat only if the person asked something next. Every program run's landing now wakes the conversation (programLandingNote) with a verdict — passed, unverified, failed, limit, crashed — which run in its line it was, its cost and one next step, under a playbook role page (prompts/program-outcome.md) and a wider bound than a settle turn (16 calls, 15 minutes). The chat checks, fixes on the branch, or hands the work back with a sharper brief. Two bounds are code: a hand-off made in the turn an ending woke is refused past programAutoRetries (2), and after a dollar or time limit, which waits for the person's word. A hand-off the person asks for starts a new line. The program's finished verdict (pass / pass-unverified) now reaches the session (Report.Verdict -> Summary.Verdict -> RunSummary.ProgramVerdict). A run handed a folder the last run of the same program left on its branch carries on on that branch (ProgramFolder.carryOn) with that run's home and start: the ending names the person's real branch, and a run that adds nothing never deletes what an earlier one committed. The landed card says `ended` and "senior-dev's ending went to the chat" instead of the program's status, and is never painted as a fault. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 2 + internal/delegate/protocol.go | 5 + internal/manual/chat/senior-dev.md | 45 +++- internal/namelaw/namelaw_test.go | 2 +- internal/run/delegateworker.go | 1 + internal/run/enginewire.go | 3 + internal/run/run.go | 9 + internal/run/worker.go | 4 + internal/session/agent.go | 12 + internal/session/delegate_door.go | 6 +- internal/session/loop.go | 8 +- internal/session/principal_structure_test.go | 6 + internal/session/program_outcome.go | 238 +++++++++++++++++++ internal/session/program_outcome_test.go | 179 ++++++++++++++ internal/session/programfolder.go | 52 +++- internal/session/programfolder_test.go | 7 +- internal/session/prompt.go | 6 + internal/session/prompts/program-outcome.md | 13 + internal/session/recovery_law_test.go | 1 + internal/session/session.go | 7 + internal/session/spawnfloor.go | 3 + internal/session/task.go | 3 + internal/session/task_run.go | 4 + internal/session/task_run_belt.go | 11 +- internal/session/task_run_copy.go | 22 +- internal/session/wakecause.go | 5 +- internal/tui3/taskdone.go | 37 +++ 27 files changed, 669 insertions(+), 22 deletions(-) create mode 100644 internal/session/program_outcome.go create mode 100644 internal/session/program_outcome_test.go create mode 100644 internal/session/prompts/program-outcome.md diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index d56bb4a90..a6c046e4a 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -21,6 +21,8 @@ invalidates: - "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: <title>`), 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." + - "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] <brief>` 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." diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go index ab4420119..e5b659f7a 100644 --- a/internal/delegate/protocol.go +++ b/internal/delegate/protocol.go @@ -176,6 +176,11 @@ func (t Terminal) Observed() string { 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") } diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index f467a6ba8..1866a5aee 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -183,10 +183,47 @@ run that ended done in its `depends_on` (the run's work is on its branch, in its 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.` -**It is never retried or carried on.** A run that ended, however it ended, is not +**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; a follow-up is a new `/senior-dev`. +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 <folder> itself, carrying on on its branch <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 @@ -569,8 +606,8 @@ 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. The task row shows this sentence as its reason; it is - not drawn as a fault, and what it made is still on its branch; + 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; diff --git a/internal/namelaw/namelaw_test.go b/internal/namelaw/namelaw_test.go index 88c2659d5..733c6ec4d 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/run/delegateworker.go b/internal/run/delegateworker.go index 7f78d749c..1a1144d0d 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -559,6 +559,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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: diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index b35a53d9a..16fe055d8 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -92,6 +92,9 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar // 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 diff --git a/internal/run/run.go b/internal/run/run.go index 113096294..4b78fb89a 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -166,6 +166,9 @@ type Supervisor struct { // 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 @@ -887,6 +890,7 @@ func (s *Supervisor) absorb(ret workerReturn) { } } 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 @@ -1772,6 +1776,10 @@ type Summary struct { // 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 @@ -1882,6 +1890,7 @@ func Start(ctx context.Context, spec Spec) (Outcome, Summary) { 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/worker.go b/internal/run/worker.go index 0879fb982..995fef5af 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 diff --git a/internal/session/agent.go b/internal/session/agent.go index 1eeac29d4..5a2a6e361 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1262,6 +1262,14 @@ type userMessage struct { // seat and dedicated role page; ordinary settle wakes leave both empty. settleModel string settlePrompt string + // settleWindow widens the turn's window past the settle turn's own + // ([Agent.settleWindow]) — a program's ending may be checked by running the + // project's tests ([programOutcomeWindow]); zero leaves it as it is. + settleWindow time.Duration + // programOutcome is a program run's ending, on the note its landing wakes + // the conversation with ([Agent.programLandingNote]); nil on every other + // message. + programOutcome *programOutcome // landingQuestion and landingOutcome preserve the two roles inside an owed // landing document: what was asked and the evidence the run returned. @@ -3726,6 +3734,7 @@ type settleWake struct { ceiling int model string prompt string + window time.Duration } type settleWakeKey struct{} @@ -3762,6 +3771,9 @@ func (a *Agent) settleWakeLocked() (settleWake, bool) { if note.settlePrompt != "" { wake.model, wake.prompt = note.settleModel, note.settlePrompt } + if note.settleWindow > wake.window { + wake.window = note.settleWindow + } } return wake, wake.ceiling != 0 } diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index 239e8fb1b..df1305383 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -230,7 +230,11 @@ func delegateFolderReceipt(ground string, via delegate.Delegate, record *TaskCop stays = "your branch " + record.Home + " has already moved, from " + shortSha(record.HomeSha) + " to " + shortSha(tip) + ", and codeaf does not move it" } - return "It is " + via.Name + "'s: it works alone in " + ground + " itself, on a new branch " + record.Branch + "; " + + on := "on a new branch " + record.Branch + if record.Continues { + on = "carrying on on its branch " + record.Branch + ", where the last run left it" + } + return "It is " + via.Name + "'s: it works alone in " + ground + " itself, " + on + "; " + stays + ", and when it ends " + record.Branch + " stays checked out there with its work." } diff --git a/internal/session/loop.go b/internal/session/loop.go index 9ffda4b21..f7de5fa8d 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -308,8 +308,12 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // and the deadline it puts on the context both tells every request how long is // left and cuts the turn itself ([Agent.settleBoundTripped] reads the ceiling // at the loop's boundary). Every other turn is left exactly as it was. - if _, settle := settleWakeFrom(ctx); settle { - windowed, closeWindow := openCallWindow(ctx, a.settleWindow(), callWindow{}) + if wake, settle := settleWakeFrom(ctx); settle { + window := a.settleWindow() + if wake.window > window { + window = wake.window + } + windowed, closeWindow := openCallWindow(ctx, window, callWindow{}) defer closeWindow() ctx = windowed } diff --git a/internal/session/principal_structure_test.go b/internal/session/principal_structure_test.go index cc5b91bdf..a5e75792a 100644 --- a/internal/session/principal_structure_test.go +++ b/internal/session/principal_structure_test.go @@ -74,6 +74,12 @@ var wakeRoads = map[string]string{ "session's goal owner answered about that landing ([Agent.addressLanding] → " + "[Principal.Report]), so a run that lands while nobody is watching is told to whoever the " + "run was started for rather than to an empty room", + "programLandingNote": "a program's run's landing, which [Agent.deliverBeltRunLanding] hands " + + "here instead of its own note. It is addressed to THE CONVERSATION'S OWN MODEL, not to a " + + "person: the program's ending is codeaf's to act on (program_outcome.go) — check the work, " + + "send it back within the retry cap, or stop and say where it stands — so an unattended " + + "session still has somebody to act on it, and the person reads the summary that turn writes, " + + "owed their question when the hand-off carried one ([owedLandingDocument])", } // TestEveryWakeRoadSaysWhoItIsAddressedTo fails when a new road into the wake diff --git a/internal/session/program_outcome.go b/internal/session/program_outcome.go new file mode 100644 index 000000000..714040c21 --- /dev/null +++ b/internal/session/program_outcome.go @@ -0,0 +1,238 @@ +package session + +// A PROGRAM'S ENDING IS FOR CODEAF TO ACT ON, NOT FOR THE PERSON TO DECODE. +// +// senior-dev ends with a status — its change passed the project's own build and +// tests, nothing finished checking it, it did not pass, it hit a ceiling, it +// broke — and until this file that status was a sentence on a row and in the +// conversation's record, read by the model only if the person happened to ask +// something next. The owner asked on 2026-09-25 that the status inform codeaf +// rather than the person: the chat reads it the moment the run ends, checks +// what it can, sends senior-dev back with a sharper brief when the work did not +// stand, and tells the person where the work is in one plain summary. +// +// So every program run's landing WAKES a bounded turn ([Agent.deliverBeltRunLanding]) +// carrying the ending as a fact ([programVerdict]) and the one line of what to +// do about it, under a role page that says the whole playbook +// (prompts/program-outcome.md) — the per-event vehicle, which costs the fixed +// prefix nothing. +// +// TWO BOUNDS ARE CODE, NOT ADVICE. codeaf sends a program back on its own at +// most [programAutoRetries] times in a line of runs on one piece of work, and +// never after a run that ended on a dollar or time ceiling: that re-run spends +// more of the person's money, so it waits for their word ([Agent.programRetryRefusal]). +// A hand-off the person asks for in their own turn is theirs, and starts the +// count again. + +import ( + "fmt" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// programVerdict is how a program's run came out, as codeaf acts on it. +type programVerdict string + +const ( + // programPassed is finished work the program's own run of the project's + // build and tests passed. + programPassed programVerdict = "passed" + // programUnverified is finished work nothing finished checking. + programUnverified programVerdict = "unverified" + // programFailed is work the program handed in that does not pass, or work + // it did not finish. + programFailed programVerdict = "failed" + // programLimit is a run that stopped on a ceiling: the program's own, or a + // dollar or time limit the person set. + programLimit programVerdict = "limit" + // programCrashed is a run that broke — the program crashed, or ended + // without an ending of its own. + programCrashed programVerdict = "crashed" +) + +// programAutoRetries is how many times codeaf sends a program back to one +// piece of work on its own, after the first run: the owner's cap. +const programAutoRetries = 2 + +// programOutcomeCallCeiling bounds the turn a program's landing wakes. It is +// wider than a settle turn's ([settleCallCeiling]), because this turn may run +// the project's checks on the program's branch and hand the work back, and +// narrow enough that a turn cannot become an unbounded session of its own. +const programOutcomeCallCeiling = 16 + +// programOutcomeWindow is how long that turn has: a project's test suite has +// to fit in it. +const programOutcomeWindow = 15 * time.Minute + +// programVerdictOf reads how a program's run came out off the run's summary: +// a limit first (the person's own or the program's ceiling), then the +// program's own unfinished ending, then the word it finished on. +func programVerdictOf(summary RunSummary) programVerdict { + if summary.Limit != "" { + return programLimit + } + if ended := summary.Program; ended != nil { + switch ended.Status { + case delegate.StatusBudget: + return programLimit + case delegate.StatusCrashed: + return programCrashed + } + return programFailed + } + if summary.Outcome == beltRunOutcomeDone { + if summary.ProgramVerdict == "pass" { + return programPassed + } + return programUnverified + } + // A run that did not finish and carried no ending of the program's own: + // it exited without one, or the road under it failed. + return programCrashed +} + +// programAttempt is one run's place in a line of runs on one piece of work. +type programAttempt struct { + // attempt counts the runs in the line, 1 for the first. + attempt int + // auto counts the runs in it codeaf started on its own after an ending. + auto int +} + +// programOutcome is a program run's ending as the turn it wakes holds it +// ([Agent.programOutcomeNow]): which run, what it came to, and where in its +// line it stands. +type programOutcome struct { + row uint64 + program string + verdict programVerdict + programAttempt +} + +// programOutcomeNote is the note a program's landing wakes the conversation +// with: the landing's own line, then the ending as a fact and the one thing to +// do about it now. +func programOutcomeNote(outcome programOutcome, line string, costUSD float64) string { + var b strings.Builder + b.WriteString(line) + b.WriteString("\n\n") + fmt.Fprintf(&b, "[%s ended — for you to act on] task %d · %s · run %d", outcome.program, outcome.row, outcome.verdict, outcome.attempt) + if costUSD > 0 { + fmt.Fprintf(&b, " · $%.2f", costUSD) + } + b.WriteString("\n") + b.WriteString(programNextStep(outcome)) + return b.String() +} + +// programNextStep is what to do about one ending, in one sentence the model +// reads with the playbook it expands. +func programNextStep(o programOutcome) string { + left := programAutoRetries - o.auto + switch o.verdict { + case programPassed: + return "Its change passed the project's own checks. Check the result against what was asked, then tell the person in one short summary where the work is and offer to merge it." + case programUnverified: + return "Nothing finished checking its change. Run the project's checks on its branch yourself, then act on what they show as you would on a pass or a failure." + case programLimit: + return "It stopped on a limit, so another run spends more of the person's money: do not hand it back. Tell the person briefly what is done and what is left, and ask whether to spend more." + } + if left <= 0 { + return fmt.Sprintf("It has been sent back %d times already, which is the most codeaf does on its own: do not hand it back. Tell the person plainly what still does not work, where the work is, and what you would try next.", programAutoRetries) + } + if o.verdict == programCrashed { + return fmt.Sprintf("It broke rather than finished. If the cause looks passing (a network or provider failure), hand the same work to %s again; otherwise tell the person plainly. You may send it back %d more time%s on your own.", o.program, left, plural(left)) + } + return fmt.Sprintf("Its work does not stand yet. Read what failed; fix a small gap on its branch yourself, or hand the work back to %s with a brief sharpened by what failed. You may send it back %d more time%s on your own.", o.program, left, plural(left)) +} + +// rememberProgramOutcomeLocked keeps a program's ending for the turn it +// arrives in, so a hand-off that turn makes is known as a re-attempt of it. +// The caller holds a.mu. +func (a *Agent) rememberProgramOutcomeLocked(user userMessage) { + if user.programOutcome != nil { + outcome := *user.programOutcome + a.programOutcomeNow = &outcome + } +} + +// programRetryRefusal is why a hand-off to a program made in the turn a +// program's ending woke may not go, and "" when it may. It is the code half +// of the playbook's two bounds. +func (a *Agent) programRetryRefusal(via string) string { + if strings.TrimSpace(via) == "" { + return "" + } + a.mu.Lock() + now := a.programOutcomeNow + a.mu.Unlock() + if now == nil { + return "" + } + switch { + case now.verdict == programLimit: + return fmt.Sprintf("task %d stopped on a limit, and another %s run spends more of the person's money: ask the person first, and hand it over only on their word", now.row, via) + case now.auto >= programAutoRetries: + return fmt.Sprintf("%s has been sent back to this work %d times already, the most codeaf does on its own: tell the person where the work stands and let them decide", now.program, programAutoRetries) + } + return "" +} + +// programAttemptOf is the place in its line of a hand-off to a program made +// now: the next run of the line the turn's ending belongs to, counted as +// codeaf's own, or the first run of a new line when the person's turn made it. +func (a *Agent) programAttemptOf() programAttempt { + a.mu.Lock() + defer a.mu.Unlock() + if now := a.programOutcomeNow; now != nil { + return programAttempt{attempt: now.attempt + 1, auto: now.auto + 1} + } + return programAttempt{attempt: 1} +} + +// keepProgramAttempt writes down a started hand-off's place in its line, by +// the row the run is published under. +func (a *Agent) keepProgramAttempt(row uint64, attempt programAttempt) { + a.mu.Lock() + defer a.mu.Unlock() + if a.programAttempts == nil { + a.programAttempts = map[uint64]programAttempt{} + } + a.programAttempts[row] = attempt +} + +// programAttemptFor is a run's place in its line, the first run of one when +// nothing was written down (a typed `/senior-dev`, or a conversation reopened +// since). +func (a *Agent) programAttemptFor(row uint64) programAttempt { + a.mu.Lock() + defer a.mu.Unlock() + if attempt, ok := a.programAttempts[row]; ok { + return attempt + } + return programAttempt{attempt: 1} +} + +// programLandingNote is a program run's landing as the note that wakes the +// conversation to act on it ([programOutcomeNote]), owing the person's +// question when the hand-off carried one. +func (a *Agent) programLandingNote(run *beltRun, summary RunSummary, line string) userMessage { + outcome := programOutcome{ + row: run.row, program: programName(run.delegate), verdict: programVerdictOf(summary), + programAttempt: a.programAttemptFor(run.row), + } + text := programOutcomeNote(outcome, line, a.beltRunSpent(run.row)) + document := userText(text) + if task := run.store.Task(run.root); landingOwesAnswer(task) { + document = owedLandingDocument(task, text) + } + note := wakeNote(document.text()) + note.landingQuestion, note.landingOutcome = document.landingQuestion, document.landingOutcome + note.batch = false + note.settle, note.settleCeiling, note.settleWindow = true, programOutcomeCallCeiling, programOutcomeWindow + note.settlePrompt = programOutcomePrompt + note.programOutcome = &outcome + return note +} diff --git a/internal/session/program_outcome_test.go b/internal/session/program_outcome_test.go new file mode 100644 index 000000000..56af69729 --- /dev/null +++ b/internal/session/program_outcome_test.go @@ -0,0 +1,179 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// EVERY WAY A PROGRAM'S RUN ENDS READS AS ONE VERDICT codeaf acts on. +func TestAProgramsEndingReadsAsTheVerdictCodeafActsOn(t *testing.T) { + for _, tc := range []struct { + name string + summary RunSummary + want programVerdict + }{ + {"checked and passing", RunSummary{Outcome: beltRunOutcomeDone, ProgramVerdict: "pass"}, programPassed}, + {"nothing finished checking it", RunSummary{Outcome: beltRunOutcomeDone, ProgramVerdict: "pass-unverified"}, programUnverified}, + {"a finish that named no verdict", RunSummary{Outcome: beltRunOutcomeDone}, programUnverified}, + {"handed in work that fails", RunSummary{Outcome: "incomplete", Program: &ProgramEnding{Status: delegate.StatusFail}}, programFailed}, + {"its own ceiling", RunSummary{Outcome: "incomplete", Program: &ProgramEnding{Status: delegate.StatusBudget}}, programLimit}, + {"a limit the person set", RunSummary{Outcome: "incomplete", Limit: RunLimitCost}, programLimit}, + {"it crashed", RunSummary{Outcome: "incomplete", Program: &ProgramEnding{Status: delegate.StatusCrashed}}, programCrashed}, + {"it left no ending", RunSummary{Outcome: "incomplete"}, programCrashed}, + } { + if got := programVerdictOf(tc.summary); got != tc.want { + t.Errorf("%s: verdict %q, want %q", tc.name, got, tc.want) + } + } +} + +// THE LINE UNDER THE ENDING SAYS WHAT TO DO NOW, and the two bounds are in it: +// a limit is never handed back on codeaf's own, and neither is a third retry. +func TestTheOutcomeNoteSaysWhatToDoNowAndKeepsBothBounds(t *testing.T) { + note := func(verdict programVerdict, auto int) string { + return programOutcomeNote(programOutcome{row: 3, program: "senior-dev", verdict: verdict, + programAttempt: programAttempt{attempt: auto + 1, auto: auto}}, "the landing line", 1.5) + } + first := note(programFailed, 0) + for _, want := range []string{"the landing line", "[senior-dev ended — for you to act on] task 3 · failed · run 1 · $1.50", "hand the work back to senior-dev", "2 more times"} { + if !strings.Contains(first, want) { + t.Fatalf("a first failure's note lacks %q:\n%s", want, first) + } + } + if last := note(programFailed, programAutoRetries); strings.Contains(last, "hand the work back") || !strings.Contains(last, "do not hand it back") { + t.Fatalf("a failure after the last retry is still told to hand it back:\n%s", last) + } + if limit := note(programLimit, 0); !strings.Contains(limit, "ask whether to spend more") || strings.Contains(limit, "hand the work back") { + t.Fatalf("a limit's note does not say to ask the person first:\n%s", limit) + } + if unverified := note(programUnverified, 0); !strings.Contains(unverified, "Run the project's checks on its branch yourself") { + t.Fatalf("an unverified ending is not told to check the branch:\n%s", unverified) + } + if passed := note(programPassed, 0); !strings.Contains(passed, "offer to merge") { + t.Fatalf("a pass is not told to offer the merge:\n%s", passed) + } +} + +// THE BOUNDS ARE CODE. In the turn a program's ending woke, a hand-off to a +// program after a limit is refused, and so is one past the retry cap; a +// person's own turn is never refused by either, and starts a new line. +func TestARetryPastTheCapOrAfterALimitIsRefusedOnlyInTheOutcomesTurn(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + set := func(o *programOutcome) { + agent.mu.Lock() + agent.programOutcomeNow = o + agent.mu.Unlock() + } + if got := agent.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("a person's turn was refused a hand-off: %q", got) + } + if got := agent.programAttemptOf(); got != (programAttempt{attempt: 1}) { + t.Fatalf("a person's hand-off starts at %+v, want the first run of a new line", got) + } + set(&programOutcome{row: 4, program: "senior-dev", verdict: programFailed, programAttempt: programAttempt{attempt: 1}}) + if got := agent.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("a first failure's retry was refused: %q", got) + } + if got := agent.programAttemptOf(); got != (programAttempt{attempt: 2, auto: 1}) { + t.Fatalf("the retry of a first run is %+v, want run 2, codeaf's first", got) + } + if got := agent.programRetryRefusal(""); got != "" { + t.Fatalf("a hand-off to no program was refused: %q", got) + } + set(&programOutcome{row: 4, program: "senior-dev", verdict: programFailed, programAttempt: programAttempt{attempt: 3, auto: programAutoRetries}}) + if got := agent.programRetryRefusal("senior-dev"); !strings.Contains(got, "sent back to this work 2 times already") { + t.Fatalf("a third retry was not refused: %q", got) + } + set(&programOutcome{row: 4, program: "senior-dev", verdict: programLimit, programAttempt: programAttempt{attempt: 1}}) + if got := agent.programRetryRefusal("senior-dev"); !strings.Contains(got, "ask the person first") { + t.Fatalf("a re-run after a limit was not refused: %q", got) + } + agent.mu.Lock() + agent.forgetOwedLocked() + agent.mu.Unlock() + if got := agent.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("the next turn still carries the last one's ending: %q", got) + } +} + +// A PROGRAM'S LANDING ALWAYS WAKES THE CONVERSATION, owed or not, with the +// playbook as its role page, the conversation's own model, and the ending on +// the note as a fact. +func TestAProgramsLandingWakesATurnWithThePlaybook(t *testing.T) { + completer := &scriptedCompleter{steps: []step{finalText("It passes; its branch is task/x.")}} + agent, _ := newTestAgent(t, completer, func(config *Config) { config.AskConsent = false }) + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "the run", "1", "Repair", "repair the parser") + if err != nil { + t.Fatal(err) + } + defer store.Close() + program := testPrograms("senior-dev")[0] + run := &beltRun{store: store, root: store.RootID(), row: 7, delegate: &program} + summary := RunSummary{Outcome: beltRunOutcomeDone, Result: "submitted a change", ProgramVerdict: "pass-unverified"} + + agent.deliverBeltRunLanding(run, summary, RunLanding{}) + beltRunWaitFor(t, "the program outcome turn", func() bool { return completer.requests() == 1 }) + + request := completer.request(0) + var playbook bool + for _, message := range request { + playbook = playbook || strings.Contains(messageText(message), strings.TrimSpace(programOutcomePrompt)) + } + if !playbook { + t.Fatal("the program's outcome turn was not handed the playbook") + } + last := messageText(request[len(request)-1]) + if !strings.Contains(last, "task 7 · unverified · run 1") || !strings.Contains(last, "Run the project's checks on its branch yourself") { + t.Fatalf("the outcome note = %q, want the verdict and what to do now", last) + } + if got := completer.model(0); got != agent.model { + t.Fatalf("the outcome turn ran on %q, want the conversation's own model %q", got, agent.model) + } +} + +// A SECOND RUN IN A FOLDER THE FIRST LEFT ON ITS BRANCH CARRIES ON THERE. It +// names the person's own branch, keeps every run's work on one branch, and a +// run that adds nothing never deletes what the first one committed. +func TestASecondRunCarriesOnOnTheFirstRunsBranch(t *testing.T) { + repo := newTestRepo(t) + home := strings.TrimSpace(gitOut(t, repo, "rev-parse", "--abbrev-ref", "HEAD")) + fake := testPrograms("fake")[0] + first, err := PrepareProgramFolder(ProgramFolderOrder{Program: fake, Dir: repo, Title: "Build the parser", Holder: "task 1", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "parser.go"), []byte("package p\n"), 0o644); err != nil { + t.Fatal(err) + } + if end := first.Finish("did not finish"); !end.Kept { + t.Fatalf("the first run's work was not kept: %s", end.Sentence()) + } + + second, err := PrepareProgramFolder(ProgramFolderOrder{Program: fake, Dir: repo, Title: "Finish the parser", Holder: "task 2", Keep: t.TempDir()}) + if err != nil { + t.Fatalf("the second run was refused: %v", err) + } + if second.Branch != first.Branch || second.Home != home || second.Start != first.Start || !second.Continues { + t.Fatalf("the second run = branch %q home %q continues %v, want the first run's branch %q and the person's %q", + second.Branch, second.Home, second.Continues, first.Branch, home) + } + if receipt := delegateReceipt(repo, fake, runCopyOf(second.tree())); !strings.Contains(receipt, "carrying on on its branch "+first.Branch) || + !strings.Contains(receipt, "your branch "+home+" does not move") { + t.Fatalf("the second run's receipt = %q", receipt) + } + end := second.Finish("finished") + if end.Dropped || !end.Kept || strings.TrimSpace(gitOut(t, repo, "rev-parse", "--abbrev-ref", "HEAD")) != first.Branch { + t.Fatalf("a second run that added nothing threw the first run's work away: %s", end.Sentence()) + } + if said := end.Sentence(); !strings.Contains(said, "your branch "+home+" is as it was") { + t.Fatalf("the ending does not name the person's own branch: %s", said) + } + if files := gitOut(t, repo, "ls-tree", "--name-only", first.Branch); !strings.Contains(files, "parser.go") { + t.Fatalf("the first run's work is gone from its branch:\n%s", files) + } +} diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index 86231c279..b319b0d3d 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -155,6 +155,12 @@ type ProgramFolder struct { // Ended is the sentence the run's folder was finished with. Empty is a // folder still owed its ending. Ended string `json:"ended,omitempty"` + // Continues says this run carries on on the branch an earlier finished + // run of the same program left checked out in the folder, rather than + // cutting one of its own ([ProgramFolder.carryOn]): Branch, Home and Start + // are that run's, so the person's branch is still the one named and a run + // that adds nothing never deletes what the earlier run left. + Continues bool `json:"continues,omitempty"` key string place Place @@ -224,7 +230,11 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { folder.write() return folder, nil } - if err := folder.cutBranch(); err != nil { + carried, err := folder.carryOn() + if !carried && err == nil { + err = folder.cutBranch() + } + if err != nil { folder.release() if earlier != nil && earlier.Folder.Branch != "" && !earlier.Moved { // AND THE REFUSAL SAYS WHOSE THE CHANGES MAY BE. codeaf cannot tell a @@ -238,6 +248,45 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { return folder, nil } +// carryOn takes up the branch an earlier finished run of the same program left +// checked out in this folder, and answers false when there is none to take up. +// +// A SECOND RUN STACKED A BRANCH ON THE FIRST AND CALLED THE FIRST "YOUR +// BRANCH". The earlier run's branch is left checked out and nothing merges it, +// so a run handed the same folder next — codeaf sending senior-dev back to +// finish what it left, or a person asking for more — met a clean checkout on +// `task/<first>` and cut `task/<second>` from it: its landing told the person +// their branch was `task/<first>`, and a second attempt that changed nothing +// deleted its own branch and switched the folder back to the first run's. The +// run now carries on on that branch, with the person's branch and the commit +// it stood on read from the earlier run's record: the page names the person's +// real branch, every attempt's work is on one branch, and "changed nothing" is +// measured from where the first run started, so it can never throw away what +// an earlier attempt committed. +// +// Only a record whose run ENDED is taken up, and only while its branch is the +// one checked out: a person who switched away has chosen where the next run +// starts, and a record still owed its ending was settled above and is refused +// by the checkout's own changes if it left any. +func (f *ProgramFolder) carryOn() (bool, error) { + earlier, ok := readProgramFolder(f.key) + if !ok || earlier.Ended == "" || earlier.Branch == "" || earlier.Program != f.Program { + return false, nil + } + if canonicalPath(earlier.Dir) != f.key || currentBranch(f.Dir) != earlier.Branch { + return false, nil + } + if strings.TrimSpace(f.place.Dir) != "" { + defer lockGitRoot(f.place, f.key)() + } + if refusal := programCheckoutInTheWay(f.Dir, f.Notes); refusal != "" { + return true, errors.New(refusal) + } + f.Branch, f.Home, f.Start, f.Continues = earlier.Branch, earlier.Home, earlier.Start, true + f.write() + return true, nil +} + // cutBranch reads the person's checkout and cuts the program's branch in it, // under the repository's git lock when there is a session to keep one. func (f *ProgramFolder) cutBranch() error { @@ -800,6 +849,7 @@ func (f *ProgramFolder) tree() taskTree { tree := taskTree{dir: f.Dir, merge: mergeInPlace, ground: f.Dir, mode: TaskModeInPlace, rung: GroundRungHere} if f.Branch != "" { tree.root, tree.branch, tree.home, tree.homeSha = f.Dir, f.Branch, f.Home, f.Start + tree.continues = f.Continues } return tree } diff --git a/internal/session/programfolder_test.go b/internal/session/programfolder_test.go index fabcc13c8..878407738 100644 --- a/internal/session/programfolder_test.go +++ b/internal/session/programfolder_test.go @@ -349,8 +349,11 @@ func TestTheNextRunCarriesOnInAFolderTheOneThatWentAwayLeftClean(t *testing.T) { t.Fatalf("the next run was refused a clean folder: %v", err) } defer next.Finish("") - if next.Home != dead.Branch { - t.Fatalf("the next run was cut from %q, want the dead run's branch %q, which was left checked out", next.Home, dead.Branch) + // IT CARRIES ON ON THE DEAD RUN'S BRANCH, which was left checked out, and + // the person's branch it names is still theirs ([ProgramFolder.carryOn]). + if next.Branch != dead.Branch || next.Home != dead.Home || next.Start != dead.Start || !next.Continues { + t.Fatalf("the next run = branch %q home %q start %q continues %v, want it on the dead run's branch %q with its home %q and start %q", + next.Branch, next.Home, shortSha(next.Start), next.Continues, dead.Branch, dead.Home, shortSha(dead.Start)) } if files := gitOut(t, repo, "ls-tree", "--name-only", dead.Branch); !strings.Contains(files, "done.txt") { t.Fatalf("the dead run's committed work is not on its branch:\n%s", files) diff --git a/internal/session/prompt.go b/internal/session/prompt.go index d0729c1ca..6739bc99d 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -123,6 +123,12 @@ var quickPrompt string //go:embed prompts/landing-answer.md var landingAnswerPrompt string +// programOutcomePrompt is the role page for the turn a program's ending wakes +// (program_outcome.go): the playbook for each way a program's run can end. +// +//go:embed prompts/program-outcome.md +var programOutcomePrompt string + // shapePrompt is what the BRIEF-SHAPER is told (task_shape.go): how to reason // its way from the words a person typed after /task to the brief a worker with // nobody to ask is actually given. diff --git a/internal/session/prompts/program-outcome.md b/internal/session/prompts/program-outcome.md new file mode 100644 index 000000000..2f6da6c2d --- /dev/null +++ b/internal/session/prompts/program-outcome.md @@ -0,0 +1,13 @@ +A program you handed work to has just ended. Its ending is for you to act on, not for the person to decode: decide what happens next, do it, and then tell the person in plain words where the work stands. + +The note ends with a line in brackets: the task, how it came out (passed, unverified, failed, limit, crashed), and which run on this work it was. The line under it says what to do now. Follow it. + +- passed: its own run of the project's build and tests passed. Look at what it changed against what was asked (git diff on its branch). If it is right, say so in one short summary and offer to merge its branch. If it plainly misses part of the ask, treat it as failed. +- unverified: nothing finished checking it. Run the project's own checks on its branch yourself, then act on the result as passed or failed. +- failed: read what failed (the ending names the failing checks; the branch holds the work). A small, clear gap you fix yourself on its branch, then check again. Anything bigger you hand back to the same program with propose_task and via, as a new brief that says what it built, what still fails and exactly what to change, so it starts from its own branch and does not repeat itself. +- limit: it stopped on a dollar or time ceiling. Never hand it back on your own: say briefly what is done and what is left, and ask the person whether to spend more. +- crashed: it broke rather than finished. Hand it back once if the cause looks passing (network, provider, a timeout); otherwise tell the person what broke. + +codeaf sends a program back to one piece of work at most twice on its own; a hand-off past that, or after a limit, is refused, and then the person decides. + +Keep the person's view simple. Do not paste the program's status words or its log; say what now works, what does not, where the work is (the branch and folder), and the one thing they might do next. If the work still does not pass after the last attempt, say so plainly: never report unfinished work as done. diff --git a/internal/session/recovery_law_test.go b/internal/session/recovery_law_test.go index c2071108e..cecefdbd9 100644 --- a/internal/session/recovery_law_test.go +++ b/internal/session/recovery_law_test.go @@ -58,6 +58,7 @@ var boundsACount = map[string]string{ "SilentCutAttempts": "internal/taxonomy's, named here only where a test states the same figure", "DegenerateCutAttempts": "internal/taxonomy's, as above", "BlindCutAttempts": "internal/taxonomy's, as above", + "programAutoRetries": "hand-offs of one piece of work to a program that codeaf starts on its own, each a new billed run on a sharper brief — the owner's cap on spending without them, not a patience for one call", } // TestNoAttemptCountingLoopInTheSession refuses a loop that counts its own diff --git a/internal/session/session.go b/internal/session/session.go index 9cdfbb39c..5b060cf1b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2661,6 +2661,13 @@ type Agent struct { // landingOutcomes are owed landing reports returned in this turn. They are // completion evidence, not another part of the person's ask. landingOutcomes []string + // programOutcomeNow is the program run's ending the turn now running was + // woken with, nil for every other turn: a hand-off it makes is a re-attempt + // of that run ([Agent.programRetryRefusal]). Cleared with owedAsks. + programOutcomeNow *programOutcome + // programAttempts is each started program run's place in its line of runs, + // by row ([Agent.keepProgramAttempt]). + programAttempts map[uint64]programAttempt // turnResults are the tasks whose RESULTS ARRIVED IN THIS TURN, by id, in // arrival order and cleared with owedAsks when a turn opens. // diff --git a/internal/session/spawnfloor.go b/internal/session/spawnfloor.go index 33bb1f46d..fa7481207 100644 --- a/internal/session/spawnfloor.go +++ b/internal/session/spawnfloor.go @@ -83,6 +83,9 @@ func (a *Agent) refuseProposedTask(spec taskSpec) bare.Staged { if refusal := a.proposalDependencyRefusal(spec); refusal != "" { return bare.Settled(refusal, true) } + if refusal := a.programRetryRefusal(spec.via); refusal != "" { + return bare.Settled(refusal, true) + } return nil } diff --git a/internal/session/task.go b/internal/session/task.go index b99198285..ebebb4b99 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -911,6 +911,9 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec stand = delegateStand(stand.dir) } asked := programAsked(spec) + if via != nil { + a.keepProgramAttempt(p.id, a.programAttemptOf()) + } joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { return refusal.Error(), true, true diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 0a809c487..faeac975b 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -8143,6 +8143,10 @@ type taskTree struct { // about the run the ground law was written from. rung GroundRung seal string + // continues says a program's run carries on on the branch an earlier run + // of it left checked out ([ProgramFolder.Continues]); false for every + // other tree. + continues bool // base is the machine commit the parent's world was sealed into, when a rung // made one. It is the replay point the landing takes the inheritance back out // at ([taskTree.replayOwnWork]) and it is empty for a parent that had nothing diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 88138d8a7..e52e8728d 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -194,6 +194,10 @@ type RunSummary struct { // Program is how a delegated run's program ended when it did not finish, // nil otherwise ([ProgramEnding]). Program *ProgramEnding + // ProgramVerdict is a delegated run's program's own word for the work it + // FINISHED — senior-dev's `pass` or `pass-unverified` — and empty for + // every other ending and every other run. + ProgramVerdict string // Cut is every task the run's own ending cut mid-flight, by store id: the // same typed fact as the limit, read where the run recorded it. A joined // row in this set is drawn with the run's own ending and never as a fault. @@ -1593,9 +1597,14 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { } // deliverBeltRunLanding writes the run's digest into the conversation record. -// A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED. +// A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED — or when a program ended it, +// whose ending is always the conversation's to act on (program_outcome.go). func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { line := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) + if run.delegate != nil { + a.accept(delivery{origin: fromRuntime, kind: msgResult, note: a.programLandingNote(run, summary, line)}) + return + } if task := run.store.Task(run.root); landingOwesAnswer(task) { document := owedLandingDocument(task, line) note := wakeNote(document.text()) diff --git a/internal/session/task_run_copy.go b/internal/session/task_run_copy.go index 99f7cfcd8..454033bb8 100644 --- a/internal/session/task_run_copy.go +++ b/internal/session/task_run_copy.go @@ -58,6 +58,9 @@ type TaskCopyRecord struct { // landing outlives the run that made the world. Rung GroundRung `json:"rung,omitempty"` Seal string `json:"seal,omitempty"` + // Continues says a program's run carries on on the branch an earlier run + // of it left ([ProgramFolder.Continues]), which its receipt says. + Continues bool `json:"continues,omitempty"` } // runCopyOf writes a live run's copy down. It is taken from the tree the run is @@ -68,15 +71,16 @@ func runCopyOf(tree taskTree) *TaskCopyRecord { return nil } return &TaskCopyRecord{ - Dir: tree.dir, - Branch: tree.branch, - Root: tree.root, - Ground: tree.ground, - Mode: tree.mode, - Home: tree.home, - HomeSha: tree.homeSha, - Rung: tree.rung, - Seal: tree.seal, + Dir: tree.dir, + Branch: tree.branch, + Root: tree.root, + Ground: tree.ground, + Mode: tree.mode, + Home: tree.home, + HomeSha: tree.homeSha, + Rung: tree.rung, + Seal: tree.seal, + Continues: tree.continues, } } diff --git a/internal/session/wakecause.go b/internal/session/wakecause.go index 2cb39ac06..d7392cc01 100644 --- a/internal/session/wakecause.go +++ b/internal/session/wakecause.go @@ -71,6 +71,7 @@ const ( // places a message reaches the transcript — the turn's opening and the steering // drain — so a landing that arrives mid-turn is owed by the turn it lands in. func (a *Agent) rememberOwedLocked(user userMessage) { + a.rememberProgramOutcomeLocked(user) if question := strings.TrimSpace(user.landingQuestion); question != "" { a.oweLocked(owedAsk{text: question, from: owedByPerson}) if outcome := strings.TrimSpace(user.landingOutcome); outcome != "" { @@ -146,7 +147,9 @@ func (a *Agent) oweLocked(ask owedAsk) { // forgetOwedLocked clears the previous turn's owed asks and the results they // arrived with. Called once, where a turn opens. -func (a *Agent) forgetOwedLocked() { a.owedAsks, a.landingOutcomes, a.turnResults = nil, nil, nil } +func (a *Agent) forgetOwedLocked() { + a.owedAsks, a.landingOutcomes, a.turnResults, a.programOutcomeNow = nil, nil, nil, nil +} // turnAsk is the ask this turn's endings are read against. // diff --git a/internal/tui3/taskdone.go b/internal/tui3/taskdone.go index 786d222e6..7df857034 100644 --- a/internal/tui3/taskdone.go +++ b/internal/tui3/taskdone.go @@ -51,6 +51,11 @@ import ( type taskDone struct { id uint64 ident taskIdent + // program is the program the work was handed to, "" for codeaf's own. + // Its ending is the conversation's to act on (session's program_outcome.go), + // so its card says it ended and that the chat has the rest + // ([doneProgramUnder]), never the program's own status. + program string // title and subtitle are the identity (taskident.go), frozen at landing. title, subtitle string // status is THE READING, taken once at landing from the node's own facts @@ -225,6 +230,7 @@ func (a *app) landedCard(node *taskNode) { card := &taskDone{ id: node.id, ident: node.ident, + program: a.nodeProgram(node), title: title, subtitle: taskSubtitleOf(title, node.assignment), status: session.ProjectTask(doneNodeFacts(node)), @@ -483,6 +489,11 @@ func (a *app) doneMark(card *taskDone) string { // A person's own stop is not a finding, so it is neither a tick nor a cross. return a.pal.dim(mark) case session.TaskPresenceIncomplete: + // A PROGRAM'S ENDING IS NEVER PAINTED AS A FAULT: the chat acts on it + // and says what became of the work. + if card.program != "" { + return a.pal.dim(mark) + } // THE CROSS IS DIM UNLESS SOMETHING BROKE. Running out of steps, losing the // wire and a check that named gaps are all work that did not finish, and // colouring them as failures reports a fault nobody found @@ -511,6 +522,9 @@ func (a *app) doneMark(card *taskDone) string { func (a *app) doneTail(card *taskDone) string { tail := "" if word := strings.TrimSpace(card.status.Word); word != "" { + if card.program != "" && card.status.Presence == session.TaskPresenceIncomplete { + word = doneProgramEnded + } tail = " · " + word } // ONE SEPARATOR MEANS ONE THING ON THIS ROW. The span used to be joined to @@ -629,6 +643,9 @@ func (a *app) doneUnder(card *taskDone, width int) string { } return "" } + if card.program != "" { + return a.doneProgramUnder(card, width) + } // AND AN INCOMPLETE LANDING'S SECOND ROW IS WHY, dim, in the engine's own // sentence ([session.TaskReasonOf] spells the table once). It stands INSTEAD // of the quoted report and never beside it: two accounts of one landing on one @@ -1246,3 +1263,23 @@ func (a *app) rollupRow(card *taskDone, width int, sel bool) string { } return lead + a.pal.ink(fit(card.title, room)) + a.pal.dim(tail) } + +// doneProgramEnded is the head's word for a program's run that did not finish: +// it ended, and what became of the work is the chat's to say. +const doneProgramEnded = "ended" + +// doneProgramUnder is a program's card's second row: that its ending went to +// the chat, which acts on it and says where the work stands, and where the +// whole of it is. THE PROGRAM'S STATUS IS NOT ON IT. It is codeaf's to act on +// (session's program_outcome.go), and the person reads the chat's summary of +// what came of it; the program's own words are one key away. +func (a *app) doneProgramUnder(card *taskDone, width int) string { + said := card.program + "'s ending went to the chat" + if !card.started.IsZero() { + said += " · " + doneStartWord + card.started.Format("15:04") + } + if a.doneHasDetail(card) && !card.open { + said += " · " + doneOutputKey + } + return a.pal.dim(" " + fit(said, width-4)) +} From 92742cf877376625d100b523ed40b0dfbd08e3c2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:23:32 -0400 Subject: [PATCH 153/195] tui3, session, seniordev: senior-dev's page names its task once, keeps its brief behind a dropdown, and opens every step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The program's room opened under three titles: the trail's crumb (the conversation's name, which a conversation named after its work spells the same), the bold title row, and `Reading: <title>` over the box. The head is now the title row alone — title, badge, a `▸ brief` dropdown, the pinned facts — and the dropdown (or ctrl+o) draws the whole brief in the dim ink between the head's rules, capped at half the frame. The body no longer opens on a clipped brief, and the Reading label is gone. Every action with more to show is a press (hitAction): it opens the whole step under its line — the command or argument and what came back (delegate.Shown.Detail, filled by senior-dev's presenter from the log's command and observation) — and the same press folds it. The page's notes no longer carry the program's status sentence, claim and observation: a program run keeps `<program>'s ending went to the chat` and where the work is (programPageNote). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 1 + internal/delegate/actions.go | 4 + internal/manual/chat/senior-dev.md | 16 ++- internal/seniordev/actions.go | 15 +++ internal/session/program_outcome.go | 14 +++ internal/session/task_run_belt.go | 6 +- internal/tui3/app.go | 5 + internal/tui3/chattabs.go | 5 + internal/tui3/programroom.go | 115 ++++++++++++++++++--- internal/tui3/programroom_test.go | 82 ++++++++++++--- internal/tui3/render.go | 4 + internal/tui3/room.go | 9 +- internal/tui3/roompanel.go | 27 ++++- internal/tui3/taskconversation.go | 102 +++++++++++++++--- 14 files changed, 359 insertions(+), 46 deletions(-) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index a6c046e4a..ce65e2b64 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -22,6 +22,7 @@ invalidates: - "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: <title>`), 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: <title>` 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." - "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." diff --git a/internal/delegate/actions.go b/internal/delegate/actions.go index fda7ac07f..819575873 100644 --- a/internal/delegate/actions.go +++ b/internal/delegate/actions.go @@ -165,6 +165,10 @@ type Shown struct { // 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"` // 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"` diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 1866a5aee..e965c5d9b 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -57,12 +57,18 @@ tab**: the tab strip stays on top, with the conversation's tab selected and the 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 — its brief, 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 +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. The line over it pins 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. While the +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. + +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. diff --git a/internal/seniordev/actions.go b/internal/seniordev/actions.go index 58d695c02..3ad4e51ed 100644 --- a/internal/seniordev/actions.go +++ b/internal/seniordev/actions.go @@ -154,9 +154,24 @@ func presentStep(action delegate.Action) (delegate.Shown, bool) { default: shown.Text = strings.TrimSpace(action.Command) } + shown.Detail = stepDetail(action) 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 { diff --git a/internal/session/program_outcome.go b/internal/session/program_outcome.go index 714040c21..761a2a451 100644 --- a/internal/session/program_outcome.go +++ b/internal/session/program_outcome.go @@ -236,3 +236,17 @@ func (a *Agent) programLandingNote(run *beltRun, summary RunSummary, line string note.programOutcome = &outcome return note } + +// programPageNote is what a program run's own page keeps as its ending: that +// the ending went to the conversation, and where the work is. THE PROGRAM'S +// STATUS IS NOT ON IT. The page's notes used to carry the whole outcome line — +// the program's status sentence, what its model claimed, what it observed — +// which is the account codeaf acts on, not one a person reads; the chat's reply +// says what came of the work, and the program's own words are on its actions. +func programPageNote(program string, landing RunLanding) string { + said := program + "'s ending went to the chat" + if line := beltLandingLine(landing); line != "" { + said += " · " + line + } + return said +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index e52e8728d..40c1d6153 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1502,7 +1502,11 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun refreshCtx, cancelRefresh := context.WithTimeout(ctx, beltRunSummaryDeadline) a.RefreshRunSummary(refreshCtx, run.root, time.Time{}) cancelRefresh() - if _, err := run.store.AddNote(run.root, run.root, beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run))); err != nil { + note := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) + if run.delegate != nil { + note = programPageNote(programName(run.delegate), landing) + } + if _, err := run.store.AddNote(run.root, run.root, note); err != nil { if g := a.graph(); g != nil { g.planNote("the run's outcome note failed: " + err.Error()) } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 6ac105c3e..ca3dce85b 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -4227,6 +4227,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if a.roomBackPress(msg.Mouse().X, msg.Mouse().Y) { return a, nil } + if a.programBriefPress(msg.Mouse().X, msg.Mouse().Y) { + return a, nil + } // THE TASK STRIP IS READ BEFORE THE RAIL, because the strip spans the // WHOLE window and the rail claims every press in its own columns // whether or not one landed on a row (room.go) — asked the other way @@ -6833,6 +6836,8 @@ func (a *app) press(x, y int) (cmd tea.Cmd) { a.togglePictureAt(r.entry, r.pictureIndex) case hitBrief: a.toggleBriefFoldAt(r.entry) + case hitAction: + a.toggleProgramAction(int64(r.turn)) case hitTask: // A CLICK ON A SPAWN CARD IS THE DOOR INTO THE NODE. It used to open the // brief, which is the card's own text one fold down — and the question a diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index 21af29bf4..40d4b3d11 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -516,6 +516,11 @@ func (a *app) roomFactsRow() int { if a.roomHeadHeight(width) < a.roomHeadCount() { return -1 } + // A PROGRAM'S ROOM HAS ONE HEAD ROW WITH FACTS ON IT, its title row, and + // the brief's rows under it are the dropdown's (programroom.go). + if a.programHeadsRoom() { + return a.roomHeadRow() + } return a.roomHeadRow() + a.roomHeadCount() - 1 } diff --git a/internal/tui3/programroom.go b/internal/tui3/programroom.go index 755ebb2f1..5b91effdd 100644 --- a/internal/tui3/programroom.go +++ b/internal/tui3/programroom.go @@ -44,10 +44,19 @@ import ( // of the brief, the width its body was last laid out at (which `ctrl+o` // measures the brief against), and the lines the page itself has said. type programRoom struct { - page session.PlanTaskPage - readAt time.Time - reading bool + page session.PlanTaskPage + readAt time.Time + reading bool + // briefFull says the head's dropdown is open: the whole brief the program + // was handed, drawn between the head's rules ([app.programHeadBriefRows]). + // It opens shut, and `ctrl+o` or a press on the dropdown turns it. briefFull bool + // briefSpan is where the dropdown was drawn on the title row, for the + // press that turns it ([app.programBriefPress]). + briefSpan hudSpan + // open is the actions whose whole step is shown under their one line, by + // the moment each was received ([app.toggleProgramAction]). + open map[int64]bool // calls says the room shows the program's raw calls instead of its actions // ([programCallsKey]); a room opens on the actions. calls bool @@ -282,8 +291,15 @@ func (a *app) programRoomRows(width int) []row { p.inner = inner pal := a.pal var out []row - for _, line := range a.programBody(p.page, inner, p.briefFull, p.calls) { - out = append(out, row{text: line, entry: -1}) + // THE BRIEF IS THE HEAD'S (its dropdown), so the actions open the body; and + // every action with more to show is a press that opens its whole step. + lines, keys := a.programBodyRows(p.page, inner, p.briefFull, p.calls, !a.programHeadsRoom(), p.open) + for i, line := range lines { + r := row{text: line, entry: -1} + if keys[i] != 0 { + r.hit, r.turn = hitAction, int(keys[i]) + } + out = append(out, r) } if len(p.said) > 0 { out = append(out, row{entry: -1}) @@ -438,13 +454,9 @@ func (a *app) programRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } switch msg.String() { case "ctrl+o": - width := p.inner - if width <= 0 { - width = gutterInner(a.bodyWidth()) - } - if !convBriefFolds(p.page, width, p.calls) { - return nil, false - } + // THE KEY TURNS THE HEAD'S DROPDOWN, whatever the brief's length: the + // brief is drawn whole up there or not at all ([app.programHeadBriefRows]). + // On the raw calls it still unfolds the brief those draw in their body. p.briefFull = !p.briefFull a.room.dirty = true a.touch() @@ -459,3 +471,82 @@ func (a *app) programRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { } return nil, false } + +// ── THE HEAD: ONE TITLE, AND THE BRIEF BEHIND A DROPDOWN ───────────────────── +// +// A program's room used to open under two titles: the trail's crumb — the +// conversation's name, which a conversation named after its work spells the +// same as the task — and the task's own bold title under it, and a third in the +// box's `Reading:` label. The owner asked on 2026-09-25 for one: the head is +// the title row alone (the task's name, its badge, the dropdown, the pinned +// facts), and the brief the program was handed is behind the dropdown, drawn +// whole in the dim ink between the head's rules. The way back is `esc`, named +// on the key line, and the side list's `‹ Back to main`. + +// programBriefChevron is the dropdown on the title row: shut, or open. +func programBriefChevron(open bool) string { + if open { + return glyphOpen + " brief" + } + return glyphShut + " brief" +} + +// programHeadsRoom says the open room is a program's in the frame that draws +// the head as its own rows ([app.roomOrganized]): the one layout the dropdown +// lives in. A frame too short for it keeps the compact trail, which names the +// task already. +func (a *app) programHeadsRoom() bool { + return a.programOf() != nil && a.roomOrganized() +} + +// programHeadBriefRows is the brief, whole, between the head's rules while the +// dropdown is open, and nothing while it is shut. It is pinned with the head, +// so a brief longer than half the frame gives up its tail to a count rather +// than the body its rows. +func (a *app) programHeadBriefRows(width int) []string { + p := a.programOf() + if p == nil || !p.briefFull || !a.roomOrganized() { + return nil + } + text := max(width-headLabelAt-2, 1) + lines := planBriefRows(p.page.Description, text) + if len(lines) == 0 { + return nil + } + _, height := a.size() + if most := max(height/2, 3); len(lines) > most { + cut := len(lines) - (most - 1) + lines = append(append([]string(nil), lines[:most-1]...), bandFoldWord(cut, briefFoldWhat, true)) + } + rows := make([]string, len(lines)) + for i, line := range lines { + rows[i] = strings.Repeat(" ", headLabelAt) + a.pal.dim(fit(line, text)) + } + return rows +} + +// programBriefPress turns the dropdown when the press landed on it. +func (a *app) programBriefPress(x, y int) bool { + p := a.programOf() + if p == nil || !a.programHeadsRoom() || a.headHeight() == 0 || y != a.roomHeadRow() || !p.briefSpan.holds(x) { + return false + } + p.briefFull = !p.briefFull + a.room.dirty = true + a.touch() + return true +} + +// toggleProgramAction opens or shuts one action's whole step under its line. +func (a *app) toggleProgramAction(key int64) { + p := a.programOf() + if p == nil { + return + } + if p.open == nil { + p.open = map[int64]bool{} + } + p.open[key] = !p.open[key] + a.room.dirty = true + a.touch() +} diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go index 6fa45282e..d1b17c615 100644 --- a/internal/tui3/programroom_test.go +++ b/internal/tui3/programroom_test.go @@ -14,6 +14,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/session" ) @@ -90,25 +91,82 @@ func TestAProgramsRoomSendsNothingAndSaysSo(t *testing.T) { } } -// A LONG BRIEF FOLDS, AND ctrl+o OPENS AND CLOSES IT, in the room exactly as on -// the stored page: the fold line names how many lines and the key. -func TestCtrlOFoldsAProgramRoomsBrief(t *testing.T) { +// THE ROOM NAMES ITS TASK ONCE, AND ITS BRIEF IS BEHIND A DROPDOWN. The head is +// the title row alone — no trail crumb repeating the conversation's name, no +// `Reading:` label over the box — with `▸ brief` after the badge; the brief is +// not in the body, and ctrl+o or a press on the dropdown draws it whole between +// the head's rules, and shuts it again. +func TestAProgramRoomNamesItsTaskOnceAndHidesItsBriefBehindADropdown(t *testing.T) { a, agent := programRoomApp(t, 120, 40) page := agent.planFake.pages["7"] - page.Description = strings.Repeat("the store interface changes and every caller moves with it. ", 12) + page.Description = strings.Repeat("the store interface changes and every caller moves with it. ", 12) + "THE LAST WORDS" agent.planFake.pages["7"] = page openProgramRoomNow(t, a) - fold := briefFoldWhat + railSep + briefFoldKey - if !strings.Contains(roomText(a), fold) { - t.Fatalf("a long brief is not folded with its key:\n%s", roomText(a)) + width, _ := a.size() + head := func() []string { + var out []string + for _, line := range a.roomHeadRows(width) { + out = append(out, plain(line)) + } + return out } - drive(t, a, key("ctrl+o")) - if strings.Contains(roomText(a), fold) { - t.Fatalf("ctrl+o did not unfold the brief:\n%s", roomText(a)) + shut := head() + if len(shut) < 1 || !strings.Contains(shut[0], "rewrite the auth middleware") || !strings.Contains(shut[0], programBriefChevron(false)) { + t.Fatalf("the head's first row is not the title with its dropdown: %q", shut) + } + if strings.Contains(strings.Join(shut, "\n"), "THE LAST WORDS") || strings.Contains(roomText(a), "THE LAST WORDS") { + t.Fatal("the brief is drawn while its dropdown is shut") + } + if a.roomRecipientHeight() != 0 { + t.Fatal("the box still carries a `Reading:` label naming the task a third time") } drive(t, a, key("ctrl+o")) - if !strings.Contains(roomText(a), fold) { - t.Fatalf("a second ctrl+o did not fold the brief again:\n%s", roomText(a)) + open := strings.Join(head(), "\n") + if !strings.Contains(open, "THE LAST WORDS") || !strings.Contains(open, programBriefChevron(true)) { + t.Fatalf("ctrl+o did not draw the whole brief in the head:\n%s", open) + } + if strings.Contains(roomText(a), "THE LAST WORDS") { + t.Fatal("the open brief is drawn in the body as well as the head") + } + p := a.programOf() + if !a.programBriefPress(p.briefSpan.from, a.roomHeadRow()) || strings.Contains(strings.Join(head(), "\n"), "THE LAST WORDS") { + t.Fatal("a press on the dropdown did not shut the brief") + } +} + +// A STEP OPENS TO ITS WHOLE SELF. An action with more to show is a press on +// the room: it draws the step's command and what came back under its line, and +// the same press shuts it. +func TestAProgramsStepOpensToItsWholeStepAndShutsAgain(t *testing.T) { + a, agent := programRoomApp(t, 120, 40) + page := agent.planFake.pages["7"] + program := *page.Program + program.Actions = append([]delegate.Shown(nil), program.Actions...) + at := program.Actions[len(program.Actions)-1].At.Add(time.Second) + program.Actions = append(program.Actions, delegate.Shown{At: at, Step: "explore", Text: "ran go test ./...", + Outcome: "fails · exit 1", Detail: "bash: go test ./...\n\n--- FAIL: TestTheWholeOutput"}) + page.Program = &program + agent.planFake.pages["7"] = page + openProgramRoomNow(t, a) + var target row + for _, r := range a.roomRows(a.bodyWidth()) { + if strings.Contains(plain(r.text), "ran go test ./...") { + target = r + } + } + if target.hit != hitAction { + t.Fatalf("the step is not a press: %+v", target) + } + if strings.Contains(roomText(a), "TestTheWholeOutput") { + t.Fatal("the step's whole output is drawn before it was opened") + } + a.toggleProgramAction(int64(target.turn)) + if !strings.Contains(roomText(a), "TestTheWholeOutput") { + t.Fatalf("opening the step did not draw its whole step:\n%s", roomText(a)) + } + a.toggleProgramAction(int64(target.turn)) + if strings.Contains(roomText(a), "TestTheWholeOutput") { + t.Fatal("the same press did not shut the step") } } diff --git a/internal/tui3/render.go b/internal/tui3/render.go index c64b134dd..c5ba2c100 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -67,6 +67,10 @@ const ( // row nothing in the conversation produced — the line is drawn between two // blocks, and it exists only while the mode is up. hitRewind + // hitAction is one action on a program's room (programroom.go): a press + // opens its whole step under its line, and a press on it again — or on the + // step it opened — shuts it. The action's key rides in [row.turn]. + hitAction ) // row is one visible screen row and what it points at. It is the single diff --git a/internal/tui3/room.go b/internal/tui3/room.go index e40085e1e..2b66b9d33 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -2638,7 +2638,10 @@ func (a *app) roomHeadRows(width int) []string { return []string{a.roomTrailRow(width)} } head := []string{a.roomTrailRow(width), a.roomFactsLine(width)} - if a.roomOrganized() { + switch { + case a.programHeadsRoom(): + head = append([]string{a.roomTitleRow(width)}, a.programHeadBriefRows(width)...) + case a.roomOrganized(): head = []string{a.roomTrailRow(width), a.roomTitleRow(width)} } if rows > a.roomHeadCount() { @@ -2659,6 +2662,10 @@ const roomHeadRowCount = 2 // Compact frames already name the task in their navigation row. func (a *app) roomHeadCount() int { + if a.programHeadsRoom() { + width, _ := a.size() + return 1 + len(a.programHeadBriefRows(width)) + } if a.roomOrganized() { return roomHeadRowCount } diff --git a/internal/tui3/roompanel.go b/internal/tui3/roompanel.go index a0ed6c2e2..52a752fa7 100644 --- a/internal/tui3/roompanel.go +++ b/internal/tui3/roompanel.go @@ -310,12 +310,27 @@ func (a *app) roomTitleRow(width int) string { } } room := max(width-headLabelAt-2-ansi.StringWidth(right)-3, 1) + // A PROGRAM'S ROOM HANGS THE BRIEF'S DROPDOWN AFTER THE BADGE, paid for + // before the title is fitted (programroom.go's [app.programHeadBriefRows]). + chevron := "" + if p := a.programOf(); p != nil && a.programHeadsRoom() { + chevron = " " + programBriefChevron(p.briefFull) + room -= ansi.StringWidth(chevron) + } // A PROGRAM'S TASK WEARS ITS PROGRAM'S BADGE BESIDE ITS TITLE, the one its row // wears on the side list (programbadge.go), paid for out of the title's half // of the row and never the facts'. An ordinary task spends nothing on it. - wears := programSpelling(programBadge(a.roomProgram()), left, room, railTitleFloor) - left = fit(left, room-programCells(wears)) - return strings.Repeat(" ", headLabelAt) + a.pal.bold(a.pal.ink(left)) + a.pal.programAfter(wears) + strings.Repeat(" ", max(width-headLabelAt-2-ansi.StringWidth(left)-programCells(wears)-ansi.StringWidth(right), 1)) + painted + " " + wears := programSpelling(programBadge(a.roomProgram()), left, max(room, 1), railTitleFloor) + left = fit(left, max(room-programCells(wears), 1)) + used := headLabelAt + ansi.StringWidth(left) + programCells(wears) + shownChevron := "" + if chevron != "" { + p := a.programOf() + p.briefSpan = hudSpan{from: used + 1, to: used + ansi.StringWidth(chevron)} + shownChevron = a.pal.dim(chevron) + used += ansi.StringWidth(chevron) + } + return strings.Repeat(" ", headLabelAt) + a.pal.bold(a.pal.ink(left)) + a.pal.programAfter(wears) + shownChevron + strings.Repeat(" ", max(width-used-2-ansi.StringWidth(right), 1)) + painted + " " } // roomProgram is the program the open room's task was handed to: the name its @@ -369,6 +384,12 @@ func (a *app) roomAncestorParts(width int) (string, []crumbHit) { // Rendering and height accounting share the recipient row's one predicate. func (a *app) roomRecipientHeight() int { + // A PROGRAM'S ROOM NAMES ITS TASK ONCE, on its title row: the box's + // `Reading: <title>` label was the third spelling of it on one screen, and + // its placeholder already says the program reads no messages. + if a.programOf() != nil { + return 0 + } if a.roomOrganized() && a.breathingRows() > 0 && !a.welcomeHolds() { return 1 } diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go index f97ceb168..166aa9872 100644 --- a/internal/tui3/taskconversation.go +++ b/internal/tui3/taskconversation.go @@ -215,8 +215,20 @@ func (a *app) taskProgramBody(width int) []string { // reported: the actions draw them in their own shape, and the calls, which // have none to draw, list them under their own heading as they always did. func (a *app) programBody(page session.PlanTaskPage, width int, briefFull, calls bool) []string { + out, _ := a.programBodyRows(page, width, briefFull, calls, true, nil) + return out +} + +// programBodyRows is [app.programBody] with the two things only the program's +// room asks for: whether the actions open under the brief (the room draws the +// brief behind its head's dropdown instead, programroom.go), and which actions +// are open to their whole step. Beside every line it answers the action the +// line belongs to — its key, zero for a line that belongs to none — so the +// room can make the line a press that opens or shuts that action. +func (a *app) programBodyRows(page session.PlanTaskPage, width int, briefFull, calls, brief bool, open map[int64]bool) ([]string, []int64) { pal := a.pal var out []string + var keys []int64 if calls { out = a.programCalls(page, width, briefFull) if len(convProgramOf(page).Turns) == 0 && len(page.Steps) > 0 { @@ -231,7 +243,7 @@ func (a *app) programBody(page session.PlanTaskPage, width int, briefFull, calls } } } else { - out = a.taskConversation(page, width, briefFull) + out, keys = a.taskConversationRows(page, width, briefFull, brief, open) } if len(page.Notes) > 0 { if len(out) > 0 { @@ -240,7 +252,10 @@ func (a *app) programBody(page session.PlanTaskPage, width int, briefFull, calls out = append(out, pal.dim("notes")) out = append(out, a.taskPlanNoteRows(page.Notes, width)...) } - return out + for len(keys) < len(out) { + keys = append(keys, 0) + } + return out, keys } // The words this page says in its own voice, each quoted in the manual as it is @@ -281,6 +296,11 @@ type actLine struct { outcome string steer bool quiet bool + // detail is the whole of the step ([delegate.Shown.Detail]) and key the + // action's identity for opening it — the moment it was received — both + // empty for a line with nothing more to show. + detail string + key int64 } // taskConversation is a program's page where an ordinary page draws its steps: @@ -294,8 +314,16 @@ type actLine struct { // line instead, and its actions hang under it. The column is as wide as the // widest word on the page, so it does not move as the run goes on. func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull bool) []string { + out, _ := a.taskConversationRows(page, width, briefFull, true, nil) + return out +} + +// taskConversationRows is [app.taskConversation] with the brief left out when +// brief is false, the actions in open drawn with their whole step under them, +// and beside each line the key of the action it belongs to (zero for none). +func (a *app) taskConversationRows(page session.PlanTaskPage, width int, briefFull, brief bool, open map[int64]bool) ([]string, []int64) { if width < 1 { - return nil + return nil, nil } program := convProgramOf(page) pal := a.pal @@ -303,15 +331,19 @@ func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull b column, text := actColumns(lines, width) var out []string + var keys []int64 // THE BRIEF OPENS THE PAGE, under its own word: it is what the program was // handed, in the person's own words, folded to the brief's own three lines - // with the key that unfolds it, the way every other page folds a brief. - for i, line := range taskConversationBrief(page, text, briefFull) { - word := "" - if i == 0 { - word = actBriefWord + // with the key that unfolds it, the way every other page folds a brief. The + // program's room draws it behind its head's dropdown instead. + if brief { + for i, line := range taskConversationBrief(page, text, briefFull) { + word := "" + if i == 0 { + word = actBriefWord + } + out = append(out, actRow(pal, word, pal.ink(fit(line, text)), column, width)...) } - out = append(out, actRow(pal, word, pal.ink(fit(line, text)), column, width)...) } // THE ACTIONS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, spelled // the way every fold line on this surface is ([bandFoldWord]). @@ -328,13 +360,31 @@ func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull b case len(program.Actions) == 0 && program.Earlier > 0: out = append(out, pal.dim(fit(glyphMore+itoa(program.Earlier)+" "+convEarlierWord, width))) } + for len(keys) < len(out) { + keys = append(keys, 0) + } current := "" for _, line := range lines { word := "" if line.step != "" && line.step != current { word, current = line.step, line.step } - out = append(out, actRow(pal, word, a.actBody(line, text), column, width)...) + key := int64(0) + if strings.TrimSpace(line.detail) != "" { + key = line.key + } + for _, drawn := range actRow(pal, word, a.actBody(line, text), column, width) { + out, keys = append(out, drawn), append(keys, key) + } + // AN OPEN ACTION SHOWS ITS WHOLE STEP UNDER ITS LINE, in the dim ink, + // hung where the actions' words start; the same press shuts it. + if key != 0 && open[key] { + for _, detail := range actDetailRows(line.detail, text) { + for _, drawn := range actRow(pal, "", pal.dim(detail), column, width) { + out, keys = append(out, drawn), append(keys, key) + } + } + } } // THE CALL IN FLIGHT IS THE LIVE EDGE, drawn only while the task can still be // waiting on it: a call whose ending never reached the log before the run @@ -346,7 +396,34 @@ func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull b } } } - return out + for len(keys) < len(out) { + keys = append(keys, 0) + } + return out, keys +} + +// actDetailMost is how many rows one open action may spend; the rest is +// counted, the way every capped expansion on this surface is. +const actDetailMost = 40 + +// actDetailRows is an action's whole step laid out at the room the actions' +// words get: each line of it wrapped, a blank line kept, and a step longer +// than [actDetailMost] rows cut with the count of what was left out. +func actDetailRows(detail string, width int) []string { + var rows []string + for _, line := range strings.Split(strings.TrimRight(detail, "\n"), "\n") { + line = strings.TrimRight(line, " \t\r") + if line == "" { + rows = append(rows, "") + continue + } + rows = append(rows, railWrap(line, max(width, 1))...) + } + if len(rows) > actDetailMost { + cut := len(rows) - (actDetailMost - 1) + rows = append(rows[:actDetailMost-1], bandFoldWord(cut, "lines", true)) + } + return rows } // actRow lays one row out: the step's word in the column and the body beside @@ -461,7 +538,8 @@ func actLines(page session.PlanTaskPage) []actLine { // the model's side; the program's line gives it its reason. continue } - lines = append(lines, actLine{at: shown.At, step: shown.Step, text: shown.Text, outcome: shown.Outcome, steer: shown.Steer}) + lines = append(lines, actLine{at: shown.At, step: shown.Step, text: shown.Text, outcome: shown.Outcome, steer: shown.Steer, + detail: shown.Detail, key: shown.At.UnixNano()}) } lines = append(lines, actFromCalls(program)...) if len(program.Actions) == 0 && len(program.Turns) == 0 { From 86a8c5066aa45faa56603c0796a634e2aeae7134 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:38:14 -0400 Subject: [PATCH 154/195] seniordev, delegate, tui3: every change to the work wears git's +N,-M on senior-dev's page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit senior-dev's page said `wrote a.go` and `edited b.go` with nothing about how much each moved. The file tools already counted lines for edit and apply_patch; write now counts its own (writeLineCounts, metadata only — the model reads nothing new). senior-dev's step record reads the counts off the tool's metadata (lineCounts, apply_patch summed over its files), the protocol's step record carries them as `added` and `removed` — the emitter wrote a fixed list of a step's fields and dropped them until it named them — and the action log keeps them. The presenter marks a change to the work (write, edit, apply_patch, never senior-dev's own spec, pinned check or checklist) with its lines, and the page draws `+N,-M` at the action's right edge, the added lines in the diff's green and the removed in its red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 1 + docs/design/delegate/PROTOCOL.md | 4 +- internal/delegate/actions.go | 11 +++++ internal/delegate/emit.go | 6 +++ internal/delegate/protocol.go | 8 ++++ internal/delegate/protocol_test.go | 44 ++++++++++++++++++ internal/manual/chat/senior-dev.md | 5 +- internal/seniordev/actions.go | 6 +++ internal/seniordev/actions_test.go | 26 +++++++++++ internal/seniordev/app/events.go | 5 ++ internal/seniordev/app/step_records.go | 51 +++++++++++++++++++++ internal/seniordev/app/step_records_test.go | 41 +++++++++++++++++ internal/seniordev/tool/write.go | 21 +++++++++ internal/seniordev/tool/write_test.go | 4 +- internal/tui3/programroom_test.go | 35 ++++++++++++++ internal/tui3/taskconversation.go | 25 +++++++++- 16 files changed, 286 insertions(+), 7 deletions(-) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index ce65e2b64..0f6e829f5 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -23,6 +23,7 @@ invalidates: - "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: <title>`), 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: <title>` 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." diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index fb54b8de7..f9af6ca27 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -143,7 +143,7 @@ and the command's own flags survive. | --- | --- | --- | | `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) and `exit` (a command's exit code, only for an action that ran one) | +| `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 @@ -155,7 +155,7 @@ 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` or `exit` of another JSON shape is left off and +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. diff --git a/internal/delegate/actions.go b/internal/delegate/actions.go index 819575873..9f95e93e6 100644 --- a/internal/delegate/actions.go +++ b/internal/delegate/actions.go @@ -56,6 +56,10 @@ type Action struct { 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"` } @@ -70,6 +74,7 @@ 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, } } @@ -169,6 +174,12 @@ type Shown struct { // 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"` diff --git a/internal/delegate/emit.go b/internal/delegate/emit.go index d36acc836..1480158e3 100644 --- a/internal/delegate/emit.go +++ b/internal/delegate/emit.go @@ -107,6 +107,12 @@ func (e *Emitter) Step(step StepRecord) error { 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) } diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go index e5b659f7a..c6f0566d5 100644 --- a/internal/delegate/protocol.go +++ b/internal/delegate/protocol.go @@ -117,6 +117,10 @@ type StepRecord struct { // 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 @@ -322,6 +326,8 @@ func Read(r io.Reader, sink Sink) (Reading, error) { 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++ @@ -335,6 +341,8 @@ func Read(r io.Reader, sink Sink) (Reading, error) { Tool: label(rawText(rec.Tool)), Step: label(rawText(rec.Step)), Exit: rawWhole(rec.Exit), + Added: rawWhole(rec.Added), + Removed: rawWhole(rec.Removed), }) } case RecordTerminal: diff --git a/internal/delegate/protocol_test.go b/internal/delegate/protocol_test.go index 0207c3211..385c11489 100644 --- a/internal/delegate/protocol_test.go +++ b/internal/delegate/protocol_test.go @@ -6,6 +6,7 @@ import ( "strings" "sync" "testing" + "time" ) // recorder is a Sink that keeps what it was told, in order. It is read after @@ -233,3 +234,46 @@ func TestTheReaderCarriesTheOptionalFieldsAndForgivesTheirShape(t *testing.T) { } } } + +// 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/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index e965c5d9b..9f8eba106 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -61,7 +61,10 @@ process it served — the workspace it set up, what it read and ran and changed, 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. +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 diff --git a/internal/seniordev/actions.go b/internal/seniordev/actions.go index 3ad4e51ed..43ae3ccbf 100644 --- a/internal/seniordev/actions.go +++ b/internal/seniordev/actions.go @@ -155,6 +155,12 @@ func presentStep(action delegate.Action) (delegate.Shown, bool) { 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) != "" } diff --git a/internal/seniordev/actions_test.go b/internal/seniordev/actions_test.go index dacfd2560..feffd7ad5 100644 --- a/internal/seniordev/actions_test.go +++ b/internal/seniordev/actions_test.go @@ -168,3 +168,29 @@ func TestASwitchAndACompactionSayWhatHappened(t *testing.T) { 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/events.go b/internal/seniordev/app/events.go index 9acac6ab7..9d00f849e 100644 --- a/internal/seniordev/app/events.go +++ b/internal/seniordev/app/events.go @@ -35,6 +35,9 @@ type event struct { 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 @@ -139,6 +142,7 @@ func (writer *eventWriter) emit(value event) { 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, }) } } @@ -194,6 +198,7 @@ func (writer *eventWriter) busEvent(value bus.Payload) { 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: diff --git a/internal/seniordev/app/step_records.go b/internal/seniordev/app/step_records.go index 66ac51174..db35dcade 100644 --- a/internal/seniordev/app/step_records.go +++ b/internal/seniordev/app/step_records.go @@ -45,6 +45,9 @@ type stepRecord struct { 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, @@ -70,6 +73,9 @@ func toolStepRecord(value bus.Payload) (stepRecord, bool) { 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 { @@ -100,6 +106,51 @@ func stepTarget(input map[string]any) string { 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 { diff --git a/internal/seniordev/app/step_records_test.go b/internal/seniordev/app/step_records_test.go index 34c954ab4..f53de7806 100644 --- a/internal/seniordev/app/step_records_test.go +++ b/internal/seniordev/app/step_records_test.go @@ -140,3 +140,44 @@ func TestNonToolPayloadsAreNotSteps(t *testing.T) { 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/tool/write.go b/internal/seniordev/tool/write.go index 93d91e396..44304394a 100644 --- a/internal/seniordev/tool/write.go +++ b/internal/seniordev/tool/write.go @@ -20,6 +20,11 @@ type writeMetadata struct { Diff string `json:"diff"` FilePath string `json:"filepath"` Exists bool `json:"exists"` + // Additions and Deletions are the lines the write added and removed, the + // counts edit and apply_patch already report. Nothing reaches the model: + // they are metadata, read by the run's step record (app/step_records.go). + Additions int `json:"additions"` + Deletions int `json:"deletions"` } func (r *Registry) executeWrite(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { @@ -78,6 +83,7 @@ func (r *Registry) executeWrite(ctx context.Context, call steploop.ToolCall) (st title = resolved } diff := TrimDiff(patchpkg.GenerateTwoFilesPatch(resolved, contentOld, content)) + additions, deletions := writeLineCounts(exists, contentOld, content) return steploop.ToolResult{ Title: title, Output: "Wrote file successfully.", @@ -86,10 +92,25 @@ func (r *Registry) executeWrite(ctx context.Context, call steploop.ToolCall) (st Diff: diff, FilePath: resolved, Exists: exists, + Additions: additions, + Deletions: deletions, }), }, nil } +// writeLineCounts is a write's lines added and removed. A new file is all +// additions, counted without the line-by-line comparison, which a large new +// file would pay for with nothing to compare against. +func writeLineCounts(exists bool, oldContent, newContent string) (int, int) { + if !exists || oldContent == "" { + if newContent == "" { + return 0, 0 + } + return strings.Count(strings.TrimSuffix(newContent, "\n"), "\n") + 1, 0 + } + return lineChangeCounts(oldContent, newContent) +} + func splitBOM(value string) (bool, string) { if strings.HasPrefix(value, "\ufeff") { return true, strings.TrimPrefix(value, "\ufeff") diff --git a/internal/seniordev/tool/write_test.go b/internal/seniordev/tool/write_test.go index b0ba24dc8..7f966b110 100644 --- a/internal/seniordev/tool/write_test.go +++ b/internal/seniordev/tool/write_test.go @@ -27,7 +27,7 @@ func TestWriteBOMAndMetadata(t *testing.T) { } // write results expose the unified diff of the actual change. diffPrefix := "Index: " + path + "\n===================================================================\n--- " + path + "\n+++ " + path + "\n" - wantMetadata := `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -0,0 +1,1 @@\n+first\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":false}` + wantMetadata := `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -0,0 +1,1 @@\n+first\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":false,"additions":1,"deletions":0}` if string(result.Metadata) != wantMetadata { t.Fatalf("Metadata = %s, want %s", result.Metadata, wantMetadata) } @@ -46,7 +46,7 @@ func TestWriteBOMAndMetadata(t *testing.T) { if err != nil { t.Fatalf("Execute overwrite: %v", err) } - wantMetadata = `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -1,1 +1,1 @@\n-first\n\\ No newline at end of file\n+second\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":true}` + wantMetadata = `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -1,1 +1,1 @@\n-first\n\\ No newline at end of file\n+second\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":true,"additions":1,"deletions":1}` if string(result.Metadata) != wantMetadata { t.Fatalf("Metadata = %s", result.Metadata) } diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go index d1b17c615..565255d3c 100644 --- a/internal/tui3/programroom_test.go +++ b/internal/tui3/programroom_test.go @@ -456,3 +456,38 @@ func TestAProgramRoomTurnsToItsRawCallsAndBack(t *testing.T) { t.Fatalf("the key did not turn the room back to its actions:\n%s", text) } } + +// A STEP THAT CHANGED A FILE WEARS git's `+N,-M` at its right edge, the added +// lines in the diff's green and the removed in its red; a step with no count +// wears none. +func TestAStepThatChangedAFileWearsItsLinesInTheDiffsColours(t *testing.T) { + a, agent := programRoomApp(t, 120, 40) + page := agent.planFake.pages["7"] + program := *page.Program + program.Actions = append([]delegate.Shown(nil), program.Actions...) + at := program.Actions[len(program.Actions)-1].At.Add(time.Second) + program.Actions = append(program.Actions, + delegate.Shown{At: at, Step: "implement", Text: "edited internal/auth/middleware.go", Lines: true, Added: 123, Removed: 21}, + delegate.Shown{At: at.Add(time.Second), Step: "implement", Text: "read internal/auth/store.go"}) + page.Program = &program + agent.planFake.pages["7"] = page + openProgramRoomNow(t, a) + var edited, read string + for _, r := range a.roomRows(a.bodyWidth()) { + switch { + case strings.Contains(plain(r.text), "edited internal/auth/middleware.go"): + edited = r.text + case strings.Contains(plain(r.text), "read internal/auth/store.go"): + read = r.text + } + } + if !strings.HasSuffix(strings.TrimSpace(plain(edited)), "+123,-21") { + t.Fatalf("the edit's row = %q, want +123,-21 at its edge", plain(edited)) + } + if !strings.Contains(edited, a.pal.add("+123")) || !strings.Contains(edited, a.pal.del("-21")) { + t.Fatalf("the edit's lines are not in the diff's colours: %q", edited) + } + if strings.Contains(plain(read), "+") { + t.Fatalf("a read wears lines: %q", plain(read)) + } +} diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go index 166aa9872..c8e0334c9 100644 --- a/internal/tui3/taskconversation.go +++ b/internal/tui3/taskconversation.go @@ -301,6 +301,11 @@ type actLine struct { // empty for a line with nothing more to show. detail string key int64 + // lines says the action changed a file and counted how: added and removed + // are drawn `+N,-M` at its right edge in the diff's own colours + // ([delegate.Shown.Lines]). + lines bool + added, removed int } // taskConversation is a program's page where an ordinary page draws its steps: @@ -471,13 +476,28 @@ func (a *app) actBody(line actLine, width int) string { ink = pal.narr } text, outcome := convHead(line.text), convHead(line.outcome) + painted := pal.dim(outcome) + if line.lines { + // THE LINES A CHANGE ADDED AND REMOVED, git's `+N,-M`, in the diff's own + // green and red: the one figure on the page a person reads for how much + // the work moved at each step. + plus, minus := "+"+itoa(line.added), "-"+itoa(line.removed) + badge := plus + "," + minus + painted = pal.add(plus) + pal.dim(",") + pal.del(minus) + if outcome != "" { + painted = pal.dim(outcome+railSep) + painted + outcome += railSep + badge + } else { + outcome = badge + } + } if outcome == "" { return ink(fit(text, width)) } cells := ansi.StringWidth(outcome) if room := width - cells - convGap; room >= convTextLeast/2 { words, measured := fitWidth(text, room) - return ink(words) + strings.Repeat(" ", width-measured-cells) + pal.dim(outcome) + return ink(words) + strings.Repeat(" ", width-measured-cells) + painted } return ink(fit(text+railSep+outcome, width)) } @@ -539,7 +559,8 @@ func actLines(page session.PlanTaskPage) []actLine { continue } lines = append(lines, actLine{at: shown.At, step: shown.Step, text: shown.Text, outcome: shown.Outcome, steer: shown.Steer, - detail: shown.Detail, key: shown.At.UnixNano()}) + detail: shown.Detail, key: shown.At.UnixNano(), + lines: shown.Lines, added: shown.Added, removed: shown.Removed}) } lines = append(lines, actFromCalls(program)...) if len(program.Actions) == 0 && len(program.Turns) == 0 { From ecc279309e7689c3f3d515b77befab8c642b8101 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 12:59:57 -0400 Subject: [PATCH 155/195] session: fence the chat's remaining file writers while a program holds a folder The folder hold refused write, edit and apply_patch inside a folder senior-dev was working in, but workspace_restore, workspace_merge and a generate_image or edit_video with no path named (whose default output folder can sit inside the held folder) all went through. They are now refused in the same sentence the fence already uses. A structural test enumerates every tool registered on the chat's belt and fails when one is neither fenced nor on the commented list of tools that do not write into the folder, so the next file-writing tool cannot slip past. bash stays open, because its effects cannot be read from its arguments. Review of #1488, lane 1 finding F1.5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/session/programhold.go | 46 ++++++++++++++--- internal/session/programhold_test.go | 75 +++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/internal/session/programhold.go b/internal/session/programhold.go index a11b2c75b..646b6e26c 100644 --- a/internal/session/programhold.go +++ b/internal/session/programhold.go @@ -248,11 +248,11 @@ func (h programHold) where(dir string) string { // every guard here is one: [Agent.executeTool] is the single door every call // passes through (hooks.go). // -// IT BINDS EVERY HAND THAT PUTS A FILE ON THE PERSON'S DISK AT A PATH THE CALL -// NAMES ([savingPath]): write and edit, edit_video's writing actions, and the -// generated picture, music, video and speech a path was given for. A -// generation that names no path lands in this session's own folders, which no -// program holds. +// IT BINDS EVERY HAND THAT PUTS A FILE ON THE PERSON'S DISK AT A NAMED OR +// IMPLIED PATH ([savingPath]): write and edit, workspace restore and merge, +// edit_video's writing actions, and the generated picture, music, video and +// speech. An unnamed generation uses its real default folder, which can be +// inside the held workspace. type programHoldGuard struct{ agent *Agent } func (programHoldGuard) Name() string { return "program-hold" } @@ -283,11 +283,23 @@ func programHoldWriteRefusal(shown string, hold programHold) string { // against the workspace the way [Agent.mutatingPath] does, which answers for // the hands it knows. func (a *Agent) savingPath(call ai.ToolCall) (string, string, bool) { + name := call.Function.Name + workspace := strings.TrimSpace(a.config.Workspace) + if name == "workspace_restore" || name == "workspace_merge" { + var args struct { + Confirm bool `json:"confirm"` + Preview bool `json:"preview"` + } + if json.Unmarshal([]byte(call.Function.Arguments), &args) != nil || workspace == "" || + (name == "workspace_restore" && !args.Confirm) || (name == "workspace_merge" && args.Preview) { + return "", "", false + } + return workspace, workspace, true + } if path, shown, ok := a.mutatingPath(call); ok { return path, shown, true } - name := call.Function.Name - if _, known := mutatingTools[name]; known || !producedAFile(name, call.Function.Arguments) { + if !producedAFile(name, call.Function.Arguments) { return "", "", false } var args struct { @@ -296,7 +308,25 @@ func (a *Agent) savingPath(call ai.ToolCall) (string, string, bool) { if err := decodeToolArguments(json.RawMessage(call.Function.Arguments), &args); err != nil { return "", "", false } - path, workspace := strings.TrimSpace(args.Path), strings.TrimSpace(a.config.Workspace) + path := strings.TrimSpace(args.Path) + if path == "" { + // A media hand with no named path still writes into its default folder. + // Resolve that folder before applying the same hold as a named output. + var destination string + switch name { + case "generate_image": + destination = ImagesDir(a.config.Place, workspace) + case "speak": + destination = AudioDir(a.config.Place, workspace) + case "generate_music": + destination = MusicDir(a.config.Place, workspace) + case "generate_video", "edit_video": + destination = VideoDir(a.config.Place, workspace) + } + if destination != "" { + return destination, destination, true + } + } if path == "" || (workspace == "" && !filepath.IsAbs(path)) { return "", "", false } diff --git a/internal/session/programhold_test.go b/internal/session/programhold_test.go index 931400cdf..8f25a4a42 100644 --- a/internal/session/programhold_test.go +++ b/internal/session/programhold_test.go @@ -139,6 +139,10 @@ func TestTheChatsFileToolsAreRefusedAFolderAProgramHolds(t *testing.T) { withdrawnCall("c5", "speak", `{"text":"hello","path":"clips/hello"}`), withdrawnCall("c6", "generate_music", `{"description":"a tune","path":"tune"}`), withdrawnCall("c7", "generate_video", `{"prompt":"a boat","path":"boat"}`), + withdrawnCall("c12", "generate_image", `{"prompt":"a harbour"}`), + withdrawnCall("c13", "workspace_restore", `{"snapshot":"before","confirm":true}`), + withdrawnCall("c14", "workspace_merge", `{"fork":"try-it"}`), + withdrawnCall("c18", "edit_video", `{"action":"join"}`), } { if _, refusal, ok := guard.PreAction(context.Background(), nil, nil, call); ok || !refusal.isError || !strings.Contains(refusal.text, "where fake, task 4 (Fix the parser), is working, so nothing was written") { t.Fatalf("%s into the held folder = %+v (let through %v)", call.Function.Name, refusal, ok) @@ -147,7 +151,6 @@ func TestTheChatsFileToolsAreRefusedAFolderAProgramHolds(t *testing.T) { elsewhere := t.TempDir() for _, call := range []ai.ToolCall{ scopedCall("write", filepath.Join(elsewhere, "notes.md")), - withdrawnCall("c8", "generate_image", `{"prompt":"a harbour"}`), withdrawnCall("c9", "edit_video", `{"action":"measure","path":"cut.mp4"}`), withdrawnCall("c10", "bash", `{"command":"echo hi > note.txt"}`), } { @@ -156,12 +159,82 @@ func TestTheChatsFileToolsAreRefusedAFolderAProgramHolds(t *testing.T) { } } held.Finish("") + for _, call := range []ai.ToolCall{ + withdrawnCall("c15", "workspace_restore", `{"snapshot":"before","confirm":true}`), + withdrawnCall("c16", "workspace_merge", `{"fork":"try-it"}`), + withdrawnCall("c17", "generate_image", `{"prompt":"a harbour"}`), + } { + if _, refusal, ok := guard.PreAction(context.Background(), nil, nil, call); !ok { + t.Fatalf("%s was refused after the hold ended: %q", call.Function.Name, refusal.text) + } + } if again := agent.executeTool(context.Background(), agent.newEpisode(), nil, withdrawnCall("c11", "write", `{"path":"NOTES.md","content":"the chat's note\n"}`), ""); again.isError { t.Fatalf("the write was still refused after the run ended: %q", again.text) } } +// Every registered chat hand has an explicit disposition at a folder hold. +// Bash is deliberately open because its command's effects cannot be known +// from its arguments; task tools have their separate folder admission guard. +func TestProgramHoldClassifiesEveryRegisteredChatTool(t *testing.T) { + workspace := t.TempDir() + agent := &Agent{config: Config{Workspace: workspace}} + fenced := map[string]bool{ + "write": true, "edit": true, "edit_video": true, + "generate_image": true, "generate_music": true, "generate_video": true, "speak": true, + "workspace_restore": true, "workspace_merge": true, + } + // These verbs read, write codeaf's state outside the held folder, or start + // work whose own admission guard refuses a held folder. Bash is the one + // unguarded file writer because a shell command has no knowable path set. + notFolderWrites := map[string]bool{ + "bash": true, "read": true, "ls": true, "find": true, "grep": true, + "manual": true, "ask": true, "jobs": true, "watch": true, + "track": true, "commit": true, "recall": true, "remember": true, "forget": true, + "propose_task": true, "tasks": true, "quick_task": true, + "use_skill": true, "load_capability": true, "view_image": true, + "read_document": true, "search_conversations": true, + "workspace": true, "workspace_snapshots": true, "workspace_fork": true, + "services": true, "use_service": true, + "settings": true, "change_setting": true, + "stand": true, "items": true, "revise_assignment": true, "divide_work": true, + "build_harness": true, "list_harnesses": true, "list_subharnesses": true, + "propose_subharness": true, "revise_design": true, + "web_search": true, "web_fetch": true, + "gmail_read": true, "gmail_search": true, "gmail_send": true, + "calendar_list": true, "calendar_create": true, + "slack_search": true, "slack_read_thread": true, "slack_send": true, "slack_list_channels": true, + } + for name := range universeToolNames(t) { + if !fenced[name] && !notFolderWrites[name] { + t.Errorf("registered tool %q has no folder hold disposition", name) + } + } + for _, name := range []string{"workspace_restore", "workspace_merge", "generate_image"} { + if !fenced[name] { + t.Fatalf("%s lost its folder hold", name) + } + } + for _, call := range []ai.ToolCall{ + withdrawnCall("h1", "write", `{"path":"file.txt","content":"x"}`), + withdrawnCall("h2", "edit", `{"path":"file.txt","old":"x","new":"y"}`), + withdrawnCall("h3", "edit_video", `{"action":"join","path":"cut.mp4"}`), + withdrawnCall("h10", "edit_video", `{"action":"join"}`), + withdrawnCall("h4", "generate_image", `{"prompt":"a harbour"}`), + withdrawnCall("h5", "generate_music", `{"description":"a song","path":"song.mp3"}`), + withdrawnCall("h6", "generate_video", `{"prompt":"a boat","path":"boat.mp4"}`), + withdrawnCall("h7", "speak", `{"text":"hello","path":"voice.wav"}`), + withdrawnCall("h8", "workspace_restore", `{"snapshot":"before","confirm":true}`), + withdrawnCall("h9", "workspace_merge", `{"fork":"try-it"}`), + } { + path, _, ok := agent.savingPath(call) + if !ok || !strings.HasPrefix(canonicalPath(path), canonicalPath(workspace)+string(filepath.Separator)) && canonicalPath(path) != canonicalPath(workspace) { + t.Errorf("%s has no path inside the workspace for its folder hold: %q, %v", call.Function.Name, path, ok) + } + } +} + // AN ORDINARY TASK IS REFUSED A FOLDER A PROGRAM'S RUN HOLDS, OR ONE AROUND // IT, BEFORE IT STARTS. A task cut from the held repository recorded the // program's branch as the person's, sealed its unfinished edits in as theirs, From c31bad3b14a81168e3a0be7a1dfd831bb62fad39 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:00:32 -0400 Subject: [PATCH 156/195] seniordev, session: the person's folder keeps their edits, their branch and their ignored files After it submitted, senior-dev restored the frozen candidate whenever the folder had changed since, with checkout --force, reset and clean -fd in a repository or the snapshot recorder's equivalent in a plain folder. A file the person edited, or created, in the folder while the run was going was overwritten or deleted without a word. Before either recorder restores now, every path that differs from the candidate is copied into a private folder under codeaf's state root; a copy that fails stops the restore. The terminal record carries that folder, and the ending names it in one sentence, which reaches the shell and the chat alike. The git recorder also keeps paths that were ignored when the run began out of the frozen tree, so a restore leaves them where they are. Review of #1488, lane 1 finding F1.1. Three ways a run in the person's repository could leave it in a state nobody asked for. Each per-write commit followed whatever branch was checked out, so a git checkout main mid-run put wip commits on main, and the ending then said that nothing was committed. The eager commit now requires the run's own branch before it stages and again before it commits, and the ending accounts for the task branch's commits, loose files on a moved checkout, and the finishing commit codeaf did not make. The finishing git add -A staged a secret that was ignored at the start once the run rewrote .gitignore; the paths git ignored when the run began are now recorded with it and never staged, by the finishing commit or an eager one, and a short declared list of the caches a run's own tests leave (__pycache__, .pytest_cache, *.pyc) stays out too. .gitignore itself is still committed. And a run asked to work in a folder git ignores inside a repository no longer widens to the repository root, cuts a branch and deletes it while its files sit there: it takes the plain-folder road and says so. The manual says what happens to edits made while a run works, where set-aside files go, and all of the above. Review of #1488, lane 1 findings F1.2, F1.3, F1.4, F1.6, and lane 5's note on committed bytecode. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- cmd/codeaf/carried.go | 2 +- docs/changes/unreleased/1488-senior-dev.md | 5 + docs/design/delegate/PROTOCOL.md | 2 +- internal/manual/chat/how-tasks-run.md | 2 +- internal/manual/chat/senior-dev.md | 69 ++++++-- internal/manual/chat/worker-harness.md | 2 +- internal/run/delegateworker.go | 6 +- internal/run/delegateworker_test.go | 21 +++ internal/run/enginewire.go | 2 + internal/seniordev/app/pipeline.go | 3 + internal/seniordev/app/run.go | 3 + internal/seniordev/app/solo_finalize.go | 71 +++++++++ .../seniordev/app/solo_restore_rescue_test.go | 149 ++++++++++++++++++ internal/seniordev/app/solo_ship.go | 8 +- internal/seniordev/app/workspace_recorder.go | 3 + .../seniordev/app/workspace_recorder_git.go | 94 +++++++++-- .../app/workspace_recorder_snapshot.go | 31 ++++ internal/seniordev/util/eagercommit.go | 79 ++++++++++ internal/seniordev/util/gitutils_test.go | 89 +++++++++++ internal/session/delegate_landing_test.go | 6 +- internal/session/programfolder.go | 143 +++++++++++++---- internal/session/programfolder_safety_test.go | 145 +++++++++++++++++ internal/session/task_run_belt.go | 13 +- 23 files changed, 883 insertions(+), 65 deletions(-) create mode 100644 internal/seniordev/app/solo_restore_rescue_test.go create mode 100644 internal/session/programfolder_safety_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index ce5f18cef..90c68eda3 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -345,7 +345,7 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { 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()), + Env: append(delegate.ChildEnv(api.API()), "SENIOR_DEV_EXPECTED_BRANCH="+folder.Branch, "SENIOR_DEV_IGNORED_AT_START="+folder.IgnoredFile()), Dir: here, StderrPath: filepath.Join(record, carriedStderrName), Grace: grace, diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 0f6e829f5..a37fc552c 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -31,6 +31,11 @@ invalidates: - "`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." + - "A restore after submission or a failed suite could erase later edits and new files; it now copies each differing file outside the project before restoring and names the rescue folder in the ending." + - "An eager write followed HEAD onto the person's branch, and the moved-HEAD ending denied existing task commits; eager commits now require the run's branch and the ending names committed and uncommitted work truthfully." + - "Changing `.gitignore` could commit a secret ignored when the run began, and test caches entered the task commit; the start-time ignored paths and the narrow generated-path list are excluded from eager and finishing commits." + - "The folder hold let workspace restore, workspace merge and unnamed generated output write inside the held folder; it now fences those writes with the other file tools." + - "A gitignored subfolder widened to its enclosing repository and an empty branch was deleted while its files remained; it now runs as a plain folder and says that nothing was committed." --- `docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate` diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md index f9af6ca27..8d59e560d 100644 --- a/docs/design/delegate/PROTOCOL.md +++ b/docs/design/delegate/PROTOCOL.md @@ -144,7 +144,7 @@ and the command's own flags survive. | `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 | +| `terminal` | last, exactly once, on every path | `status` (`pass`, `fail`, `budget-exhausted`, `crashed`), `message`, `data`: `reason`, `claim`, `observed`, `deliverable`, `rescue_path` (optional absolute directory where files were copied before a restore), 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 diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 21104f600..47bf163b5 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -457,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, 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 +## codeaf committed to dev — why my task's branch was kept, has codeaf committed to dev or main, 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 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 diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 9f8eba106..c12435533 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -271,9 +271,9 @@ When it ends its changes are already in the folder. The task's page says `its wo your home folder, `its work is in <folder>; the git repository around it is at <repo>, 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. +It works in your folder itself. Edits saved while it runs can join its work; +after submission, edits the restore would replace are set aside as described +under "Can I keep editing while senior-dev works?" below. **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 @@ -286,15 +286,25 @@ senior-dev used to stop at once there with `workspace is not a git repository: <folder>; 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. +## Can senior-dev work in a gitignored folder inside a repo? + +Yes. A folder git ignores inside a larger repository is worked in place like a +plain folder. codeaf does not widen it to the enclosing repository, cut or +delete a branch there, or commit any of its files. What senior-dev writes +stays in the folder. The ending says `its work is in <folder>; git ignores +this folder inside <repo>, so codeaf cut no branch and nothing was committed`. + ## 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 +and a file added there is removed after a copy is kept outside the folder. 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: `<file> is in <folder>, where senior-dev, task 4 +- the chat's `write` and `edit`, `edit_video`, `workspace_restore`, `workspace_merge`, + and a picture, music, video or speech saved there, including unnamed default + outputs for `edit_video` and generation, are refused: `<file> is in <folder>, 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 @@ -308,6 +318,21 @@ nothing else of codeaf's writes there meanwhile, from any conversation, window o **`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. +## Can I keep editing while senior-dev works? + +Yes. In a git repository, edits saved before senior-dev submits can join its task +branch's commits; the ending commit includes non-ignored files left in the folder. +In a plain folder they stay in place, with no commit. If a submitted change or an +earlier checkpoint has to be restored, codeaf first copies every changed tracked +file and new non-ignored file that restore would replace into a rescue folder under +codeaf's state root, outside your project. The submitted candidate is then put +back. The ending says exactly: `Files that changed in the folder before senior-dev +restored its checkpoint were set aside in <path>`. The path holds the bytes as they +were before the restore; a later restore in the same run has its own subfolder. +Files git ignored when the run started are not committed even if senior-dev +changes `.gitignore`. Python `__pycache__/`, `.pytest_cache/` and `*.pyc` files +made by its checks are not committed either. Those files stay in your folder. + ## 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, @@ -323,11 +348,15 @@ left where it is, and never ends up on a branch either. In a git repository, codeaf cuts a branch of its own for the run (`task/<title>-<id>`) in your folder and checks it out there, and senior-dev works on it. senior-dev commits -every file it writes (`wip(write): <path>`, `wip(edit): <path>`) on that branch, which is +each file it writes, except initially ignored files and test caches (`wip(write): +<path>`, `wip(edit): <path>`), on that branch, which is how it keeps a record to restore from; they stay there, and nothing squashes them. +If HEAD leaves that branch, the per-write commit is skipped and the write stays +uncommitted in the checkout. -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 +When the run ends — finished or not, stopped, or crashed — and HEAD is still on that +branch, codeaf commits eligible work it left uncommitted, excluding paths ignored at +the start and known test caches, 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 @@ -349,7 +378,7 @@ page says what it left `could not be committed (<folder> is in the middle of a m empty branch is deleted, and the page says `it changed nothing, so <folder> is back on your branch <yours> and its branch <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 +## Does senior-dev change my branch — your branch never moves, going back 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 @@ -359,12 +388,18 @@ names `git -C '<folder>' switch --detach <commit>`. codeaf's own switches run wi repository's hooks turned off: both go between two names for one commit, so a hook has nothing to do there. +## What if HEAD moves to main or detaches, or my branch moves, while senior-dev is working? + 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 <folder> on the branch <other> instead of its own -branch <branch>, so codeaf changed nothing there: nothing was committed and nothing was -switched; <branch> holds N files` (or `on no branch, at <commit>`). +one.** If HEAD is not on its branch when the run ends, codeaf makes no finishing +commit and does not switch branches. Work left uncommitted stays in that checkout. +The page says where HEAD is and what its task branch already +holds: `senior-dev left <folder> on the branch <other> instead of its own branch +<branch>, so codeaf made no finishing commit and did not switch branches; the +checkout has N files uncommitted; <branch> holds N files; your +branch <yours> was not given a commit by codeaf` (or `on no branch, at <commit>`). +For a clean checkout it says `no uncommitted files were left in that checkout`. **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 <yours> moved during the run, from <commit> @@ -648,12 +683,14 @@ reads done, with its result. 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 +out when that is where HEAD was left, its notes are moved out, and the page adds `its work so far is on its branch <branch> in <folder>, 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 <yours>`. 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 <branch> is checked -out there`. +out there`. If HEAD moved to another branch or detached before the worker went away, +codeaf leaves that checkout alone too. The ending names where HEAD is, the uncommitted +files left there, and any commits already on the task branch. **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 diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index a01a9539c..0499e1b36 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -542,7 +542,7 @@ answer for those rows too, in words that say what happened: Neither ever answers that the row does not exist. A number no run of this conversation holds still goes on to the ordinary task reader. -## Does a note actually reach the worker, when does it read it, does it have to ask for it +## I left a note and the task ignored it — does a note reach the worker, when does it read it, does it have to ask? Yes, and it does not have to ask. The run looks for unread notes each time the worker finishes a step, and hands them over at once, through the same door your diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 1a1144d0d..5f4fd96c0 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -107,6 +107,10 @@ type DelegateSetup struct { // (session.RunSpec.PlainFolder), so the program's line carries its own // flags for that (delegate.Delegate.PlainFolder). PlainFolder bool + // Branch is the run's own task branch; IgnoredFile is its start-time + // ignore list. Both are passed to the child before any eager commit. + Branch string + IgnoredFile string // 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. @@ -476,7 +480,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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()), + Env: append(delegate.ChildEnv(api.API()), "SENIOR_DEV_EXPECTED_BRANCH="+w.setup.Branch, "SENIOR_DEV_IGNORED_AT_START="+w.setup.IgnoredFile), Dir: w.workspace, StderrPath: filepath.Join(taskDir, delegateStderrName), Grace: w.setup.Grace, diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go index 875f497e2..a94dcba18 100644 --- a/internal/run/delegateworker_test.go +++ b/internal/run/delegateworker_test.go @@ -346,6 +346,27 @@ func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { } } +// The real child receives the branch and frozen ignore record that its eager +// file writes need; a missing field would silently commit on current HEAD. +func TestDelegateWorkerPassesTheRunBranchAndIgnoreRecordToItsChild(t *testing.T) { + store := runOpenStore(t) + seen := filepath.Join(t.TempDir(), "seen") + t.Setenv("FAKE_ENV_PATH", seen) + m, setup := fakeDelegate(t, "printf '%s\\n%s\\n' \"$SENIOR_DEV_EXPECTED_BRANCH\" \"$SENIOR_DEV_IGNORED_AT_START\" > \"$FAKE_ENV_PATH\"\n"+passLine("done")) + setup.Branch = "task/fix-123" + setup.IgnoredFile = filepath.Join(t.TempDir(), "ignored-at-start") + if _, err := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0).Run(runContext(t), *store.Task(store.RootID())); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(seen) + if err != nil { + t.Fatal(err) + } + if string(body) != setup.Branch+"\n"+setup.IgnoredFile+"\n" { + t.Fatalf("child branch and ignore record = %q", body) + } +} + // 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. diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 16fe055d8..01b2a6241 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -59,6 +59,8 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar Serves: spec.Serves, Seat: WorkSeat(spec.ProfileDir, spec.WorkModel), PlainFolder: spec.PlainFolder, + Branch: spec.ProgramBranch, + IgnoredFile: spec.ProgramIgnoredFile, 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 diff --git a/internal/seniordev/app/pipeline.go b/internal/seniordev/app/pipeline.go index 45afd5c9f..35820672d 100644 --- a/internal/seniordev/app/pipeline.go +++ b/internal/seniordev/app/pipeline.go @@ -51,6 +51,9 @@ type pipeline struct { budgetRun *runbudget.BudgetTracker budgetCost float64 + // rescuePath is the durable place later edits are copied before a restore. + rescuePath string + rescueCount int fingerprintMu sync.Mutex fingerprintFiles map[string]worktreeFileFingerprint diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go index e438fc53f..eeafe6d29 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -292,6 +292,9 @@ func endingOf(result pipelineResult) delegate.Ending { Message: messageOf(result, extra), CostUSD: result.CostUSD, } + if rescue, _ := extra["rescue_path"].(string); rescue != "" { + ending.Message += ". Files that changed in the folder before senior-dev restored its checkpoint were set aside in " + rescue + } if reason, _ := extra["reason"].(string); reason != "" && reason != ending.Message { ending.Reason = reason } diff --git a/internal/seniordev/app/solo_finalize.go b/internal/seniordev/app/solo_finalize.go index dfc08e183..eb11d6733 100644 --- a/internal/seniordev/app/solo_finalize.go +++ b/internal/seniordev/app/solo_finalize.go @@ -5,7 +5,13 @@ package app import ( "context" "errors" + "fmt" + "os" + "path/filepath" + "strings" "time" + + "github.com/Agent-Field/codeaf/internal/home" ) // soloLandingReserve sizes the landing window: two fifteenths of the wall @@ -232,5 +238,70 @@ func (runner *pipeline) soloRestoreCheckpoint(checkpoint soloCheckpoint) error { // 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 { + paths, err := runner.recorder.DifferentPaths(commitSHA) + if err != nil { + return fmt.Errorf("read files that a restore would replace: %w", err) + } + if len(paths) > 0 { + if err := runner.rescueBeforeRestore(paths); err != nil { + return fmt.Errorf("keep later files before restoring: %w", err) + } + } return runner.recorder.Restore(commitSHA, wantTree) } + +// rescueBeforeRestore copies the current bytes outside the repository BEFORE +// a recorder's forceful restore. The state root is durable and separate from +// the person's tracked tree; a copy failure refuses the destructive restore. +func (runner *pipeline) rescueBeforeRestore(paths []string) error { + root := home.Join("v3", "carried", "senior-dev", "rescued") + if relative, err := filepath.Rel(runner.workspace, root); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) { + root = filepath.Join(os.TempDir(), "codeaf-rescued") + } + if err := os.MkdirAll(root, 0o700); err != nil { + return err + } + if runner.rescuePath == "" { + created, err := os.MkdirTemp(root, "run-") + if err != nil { + return err + } + runner.rescuePath = created + } + destination := runner.rescuePath + if runner.rescueCount > 0 { + destination = filepath.Join(destination, fmt.Sprintf("later-%d", runner.rescueCount+1)) + } + for _, path := range paths { + from := filepath.Join(runner.workspace, filepath.FromSlash(path)) + info, err := os.Lstat(from) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + to := filepath.Join(destination, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil { + return err + } + switch { + case info.Mode().IsRegular(): + if err := copyFile(from, to, info.Mode()); err != nil { + return err + } + case info.Mode()&os.ModeSymlink != 0: + link, err := os.Readlink(from) + if err != nil { + return err + } + if err := os.Symlink(link, to); err != nil { + return err + } + default: + return fmt.Errorf("cannot preserve %s before restore", from) + } + } + runner.rescueCount++ + return nil +} diff --git a/internal/seniordev/app/solo_restore_rescue_test.go b/internal/seniordev/app/solo_restore_rescue_test.go new file mode 100644 index 000000000..32f1bc22d --- /dev/null +++ b/internal/seniordev/app/solo_restore_rescue_test.go @@ -0,0 +1,149 @@ +//go:build !windows + +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Edits and new files saved after a checkpoint cannot disappear when the +// submitted candidate or an earlier coherent checkpoint is restored. The +// rescue lives in codeaf's state root and its path reaches the ending. +func TestRestoreSetsAsideLaterEditsAndNamesTheirLocation(t *testing.T) { + for _, plain := range []bool{false, true} { + name := "git" + if plain { + name = "plain" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + var workspace string + if plain { + workspace = t.TempDir() + if err := writeFile(filepath.Join(workspace, "README.md"), "base\n"); err != nil { + t.Fatal(err) + } + } else { + workspace, _ = guardWorkspace(t) + } + args := cliArgs{InPlace: plain} + if err := writeFile(filepath.Join(workspace, ".gitignore"), ".env\n"); err != nil { + t.Fatal(err) + } + if !plain { + if err := gitRun(workspace, "add", ".gitignore"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "ignore secret"); err != nil { + t.Fatal(err) + } + } + if err := writeFile(filepath.Join(workspace, ".env"), "ignored secret\n"); err != nil { + t.Fatal(err) + } + runner := newPipeline(args, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "README.md"), "person's edit\n"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "notes.txt"), "person's note\n"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, " leading.txt"), "spaced name\n"); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + rescueRoot := filepath.Join(root, "v3", "carried", "senior-dev", "rescued") + rescues, err := os.ReadDir(rescueRoot) + if err != nil || len(rescues) != 1 { + t.Fatalf("rescue folders = %v, %v", rescues, err) + } + rescue := filepath.Join(rescueRoot, rescues[0].Name()) + for file, want := range map[string]string{"README.md": "person's edit\n", "notes.txt": "person's note\n", " leading.txt": "spaced name\n"} { + body, err := os.ReadFile(filepath.Join(rescue, file)) + if err != nil || string(body) != want { + t.Fatalf("rescued %s = %q, %v", file, body, err) + } + } + if body, err := os.ReadFile(filepath.Join(workspace, "README.md")); err != nil || string(body) != "base\n" { + t.Fatalf("candidate was not restored: %q, %v", body, err) + } + if _, err := os.Stat(filepath.Join(workspace, "notes.txt")); !os.IsNotExist(err) { + t.Fatalf("later file remains in candidate: %v", err) + } + if body, err := os.ReadFile(filepath.Join(workspace, ".env")); err != nil || string(body) != "ignored secret\n" { + t.Fatalf("ignored file was touched: %q, %v", body, err) + } + outcome := soloOutcome{Status: "pass"} + runner.soloTerminal(&outcome, "submitted") + ending := endingOf(pipelineResult{Status: "pass", Terminal: outcome.TerminalData}) + if !strings.Contains(ending.Message, "Files that changed in the folder before senior-dev restored its checkpoint were set aside in "+rescue) { + t.Fatalf("ending did not name rescue: %q", ending.Message) + } + }) + } +} + +// The frozen candidate cannot acquire a secret that was ignored when the +// run began merely because the run rewrote .gitignore before submitting. +func TestGitCandidateKeepsStartTimeIgnoredFilesOutOfItsTree(t *testing.T) { + workspace, _ := guardWorkspace(t) + if err := writeFile(filepath.Join(workspace, ".gitignore"), ".env\n"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "add", ".gitignore"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "ignore secret"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, ".env"), "PERSON_SECRET=private\n"); err != nil { + t.Fatal(err) + } + ignored := filepath.Join(t.TempDir(), "ignored-at-start") + if err := os.WriteFile(ignored, []byte(".env\x00"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SENIOR_DEV_IGNORED_AT_START", ignored) + if err := writeFile(filepath.Join(workspace, ".gitignore"), "# changed\n"); err != nil { + t.Fatal(err) + } + recorder := newGitRecorder(workspace, func(string) {}) + tree, err := recorder.Snapshot() + if err != nil { + t.Fatal(err) + } + if _, err := recorder.git("cat-file", "-e", tree+":.env"); err == nil { + t.Fatal("the candidate tree includes a secret ignored at the start") + } + if _, err := os.Stat(filepath.Join(workspace, ".env")); err != nil { + t.Fatalf("the secret was removed: %v", err) + } + handle, err := recorder.Record(tree, "candidate") + if err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "README.md"), "later edit\n"); err != nil { + t.Fatal(err) + } + if err := recorder.Restore(handle, tree); err != nil { + t.Fatal(err) + } + if body, err := os.ReadFile(filepath.Join(workspace, ".env")); err != nil || string(body) != "PERSON_SECRET=private\n" { + t.Fatalf("restore touched an initially ignored secret: %q, %v", body, err) + } +} diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go index 140ed9349..b3647755b 100644 --- a/internal/seniordev/app/solo_ship.go +++ b/internal/seniordev/app/solo_ship.go @@ -140,8 +140,9 @@ func (runner *pipeline) soloRestoreIfDiverged(state *soloState, outcome *soloOut }) return } - // Diverged. Restoring is unconditional: post-submission edits are not part - // of the answer by definition, whether they look like improvements or not. + // Diverged. Later edits are not part of the submitted answer, whether + // they look like improvements or not. The restore first copies their bytes + // outside the workspace and refuses to proceed if that copy fails. // 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. @@ -185,6 +186,9 @@ func (runner *pipeline) soloTerminal(outcome *soloOutcome, reason string) { if outcome.SuiteDead { data["suite_dead"] = true } + if runner.rescuePath != "" { + data["rescue_path"] = runner.rescuePath + } if candidate := outcome.Frozen; candidate != nil { data["submission_reason"] = candidate.Reason data["submission_evidence"] = candidate.Evidence diff --git a/internal/seniordev/app/workspace_recorder.go b/internal/seniordev/app/workspace_recorder.go index f5feef10e..211030c96 100644 --- a/internal/seniordev/app/workspace_recorder.go +++ b/internal/seniordev/app/workspace_recorder.go @@ -44,6 +44,9 @@ type workspaceRecorder interface { // 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 + // DifferentPaths lists the current files a restore would overwrite or + // remove, so they can be copied outside the workspace first. + DifferentPaths(handle string) ([]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 diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go index 52867ba8b..529d57193 100644 --- a/internal/seniordev/app/workspace_recorder_git.go +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -33,7 +33,8 @@ func newGitRecorder(workspace string, note func(string)) *gitRecorder { 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. +// git runs a git command in the workspace and keeps NUL-delimited path lists +// byte-for-byte while trimming ordinary human-readable output. func (recorder *gitRecorder) git(args ...string) (string, error) { argv := util.GitArgv(args...) cmd := exec.Command(argv[0], argv[1:]...) @@ -47,6 +48,13 @@ func (recorder *gitRecorder) git(args ...string) (string, error) { strings.Join(args, " "), err, strings.TrimSpace(string(out)), ) } + // NUL-delimited path lists may begin with whitespace that belongs to the + // first filename. Trimming those bytes would make a rescue miss that file. + for _, arg := range args { + if arg == "-z" { + return string(out), nil + } + } return strings.TrimSpace(string(out)), nil } @@ -118,6 +126,32 @@ func (recorder *gitRecorder) Snapshot() (string, error) { if out, err := add.CombinedOutput(); err != nil { return "", fmt.Errorf("git add -A: %v: %s", err, strings.TrimSpace(string(out))) } + // The candidate must obey the ignore rules from the START of the run, + // even after the model rewrote .gitignore. This is a private temporary index; + // the real index never stages these paths. + listed := exec.Command("git", "ls-files", "--cached", "-z") + listed.Dir, listed.Env = recorder.workspace, env + staged, err := listed.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git ls-files in temporary index: %v: %s", err, strings.TrimSpace(string(staged))) + } + ignoredAtStart, err := util.InitialIgnoredPaths() + if err != nil { + return "", err + } + var excluded []string + for _, path := range strings.Split(string(staged), "\x00") { + if path != "" && (util.PathIgnoredAtStart(path, ignoredAtStart) || util.GeneratedRunPath(path)) { + excluded = append(excluded, path) + } + } + if len(excluded) > 0 { + reset := exec.Command("git", append([]string{"reset", "-q", "HEAD", "--"}, excluded...)...) + reset.Dir, reset.Env = recorder.workspace, env + if out, err := reset.CombinedOutput(); err != nil { + return "", fmt.Errorf("git reset temporary index: %v: %s", err, strings.TrimSpace(string(out))) + } + } write := exec.Command("git", "write-tree") write.Dir, write.Env = recorder.workspace, env out, err := write.CombinedOutput() @@ -145,17 +179,14 @@ func (recorder *gitRecorder) Publish(name, handle string) error { return err } -// Restore makes the working tree byte-identical to a recorded commit's tree, -// and proves it did by re-hashing. +// Restore makes the captured working tree match a recorded commit's tree, +// while preserving files ignored at the start, and proves it by re-hashing. // // `checkout --force <commit> -- .` 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. +// commit's files and deletes nothing. Resetting the index to the checkpoint +// makes files added later untracked, so they can be removed one by one. A +// blanket clean would also remove a person's file that was ignored at the +// start if the run later removed its .gitignore rule. func (recorder *gitRecorder) Restore(handle, wantTree string) error { if _, err := recorder.git("checkout", "--force", handle, "--", "."); err != nil { return err @@ -163,9 +194,22 @@ func (recorder *gitRecorder) Restore(handle, wantTree string) error { if _, err := recorder.git("reset", "-q", handle, "--", "."); err != nil { return err } - if _, err := recorder.git("clean", "-fd"); err != nil { + newFiles, err := recorder.git("ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return err + } + ignoredAtStart, err := util.InitialIgnoredPaths() + if err != nil { return err } + for _, path := range strings.Split(newFiles, "\x00") { + if path == "" || util.PathIgnoredAtStart(path, ignoredAtStart) { + continue + } + if err := os.Remove(filepath.Join(recorder.workspace, filepath.FromSlash(path))); err != nil && !os.IsNotExist(err) { + return err + } + } actual, err := recorder.Snapshot() if err != nil { return err @@ -178,6 +222,34 @@ func (recorder *gitRecorder) Restore(handle, wantTree string) error { return nil } +// DifferentPaths includes edits to tracked files and new non-ignored files. +// Both can be removed by Restore, including a file already eagerly committed +// after the checkpoint, whose change is measured against the checkpoint. +func (recorder *gitRecorder) DifferentPaths(handle string) ([]string, error) { + changed, err := recorder.git("diff", "--name-only", "-z", handle, "--") + if err != nil { + return nil, err + } + newFiles, err := recorder.git("ls-files", "--others", "--exclude-standard", "-z") + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, listing := range []string{changed, newFiles} { + for _, path := range strings.Split(listing, "\x00") { + if path != "" { + seen[path] = true + } + } + } + paths := make([]string, 0, len(seen)) + for path := range seen { + paths = append(paths, path) + } + sort.Strings(paths) + return paths, nil +} + func (recorder *gitRecorder) BaseTree(base string) (string, bool) { tree, err := recorder.git("rev-parse", base+"^{tree}") if err != nil || tree == "" { diff --git a/internal/seniordev/app/workspace_recorder_snapshot.go b/internal/seniordev/app/workspace_recorder_snapshot.go index 414aed614..fea227b21 100644 --- a/internal/seniordev/app/workspace_recorder_snapshot.go +++ b/internal/seniordev/app/workspace_recorder_snapshot.go @@ -197,6 +197,37 @@ func (recorder *snapshotRecorder) Restore(handle, wantTree string) error { return nil } +// DifferentPaths compares the same file manifests Restore uses, so a changed +// file or a new file is rescued before the snapshot road replaces either. +func (recorder *snapshotRecorder) DifferentPaths(handle string) ([]string, error) { + recorder.mu.Lock() + store := recorder.store + recorder.mu.Unlock() + if store == "" { + return nil, fmt.Errorf("no snapshot store: nothing was recorded") + } + wanted, err := walkTree(filepath.Join(store, handle), false) + if err != nil { + return nil, err + } + current, err := recorder.walk() + if err != nil { + return nil, err + } + before := make(map[string]treeEntry, len(wanted)) + for _, entry := range wanted { + before[entry.path] = entry + } + var paths []string + for _, entry := range current { + old, found := before[entry.path] + if !found || old.hash != entry.hash || old.mode != entry.mode { + paths = append(paths, entry.path) + } + } + return paths, 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) { diff --git a/internal/seniordev/util/eagercommit.go b/internal/seniordev/util/eagercommit.go index 64fb1b86e..2f3e16ed6 100644 --- a/internal/seniordev/util/eagercommit.go +++ b/internal/seniordev/util/eagercommit.go @@ -5,6 +5,7 @@ package util import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -36,6 +37,12 @@ func EagerCommit(ctx context.Context, options EagerCommitOptions) { return } defer func() { _ = recover() }() + // The branch comes from codeaf's folder preparation, not the current HEAD: + // a shell command or editor can move HEAD between two file tools. + expected := strings.TrimSpace(os.Getenv("SENIOR_DEV_EXPECTED_BRANCH")) + if !strings.HasPrefix(expected, "task/") { + return + } inRepo, _ := RunProcess(ctx, []string{"git", "rev-parse", "--is-inside-work-tree"}, RunOptions{ ProcessOptions: ProcessOptions{Cwd: options.Cwd}, NoThrow: true, }) @@ -49,7 +56,16 @@ func EagerCommit(ctx context.Context, options EagerCommitOptions) { if rootResult.Code == 0 { root = strings.TrimSpace(string(rootResult.Stdout)) } + head, _ := RunProcess(ctx, []string{"git", "symbolic-ref", "--quiet", "--short", "HEAD"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, + }) + if head.Code != 0 || strings.TrimSpace(string(head.Stdout)) != expected { + return + } relative := repositoryRelative(root, options.Cwd, options.FilePath) + if GeneratedRunPath(relative) || IgnoredAtStart(relative) { + return + } add, _ := RunProcess(ctx, []string{"git", "add", "--", relative}, RunOptions{ ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, }) @@ -62,12 +78,75 @@ func EagerCommit(ctx context.Context, options EagerCommitOptions) { if diff.Code == 0 { return } + // A checkout may have moved while git was staging this path. Read HEAD + // again immediately before the commit, leaving the edit uncommitted there. + head, _ = RunProcess(ctx, []string{"git", "symbolic-ref", "--quiet", "--short", "HEAD"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, + }) + if head.Code != 0 || strings.TrimSpace(string(head.Stdout)) != expected { + return + } message := "wip(" + options.Label + "): " + relative _, _ = RunProcess(ctx, GitArgv( "commit", "-m", message, "--no-verify", "--only", "--", relative, ), RunOptions{ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true}) } +// GeneratedRunPaths is the one narrow list of test droppings this run is +// known to create. A general guess would hide a person's actual deliverable. +const GeneratedRunPaths = "__pycache__/,.pytest_cache/,*.pyc" + +func GeneratedRunPath(path string) bool { + for _, pattern := range strings.Split(GeneratedRunPaths, ",") { + if strings.HasPrefix(pattern, "*.") { + if strings.HasSuffix(path, strings.TrimPrefix(pattern, "*")) { + return true + } + continue + } + for _, part := range strings.Split(filepath.ToSlash(path), "/") { + if part == strings.TrimSuffix(pattern, "/") { + return true + } + } + } + return false +} + +// IgnoredAtStart reads the parent's frozen ignore list. It is a small file in +// the run record, since ignored directories can contain thousands of files. +func IgnoredAtStart(path string) bool { + paths, err := InitialIgnoredPaths() + if err != nil { + // An unreadable safety list must never permit an eager commit. + return true + } + return PathIgnoredAtStart(path, paths) +} + +// InitialIgnoredPaths reads the one list codeaf captured before the run. +func InitialIgnoredPaths() ([]string, error) { + list := os.Getenv("SENIOR_DEV_IGNORED_AT_START") + if list == "" { + return nil, nil + } + body, err := os.ReadFile(list) + if err != nil { + return nil, fmt.Errorf("read start-time ignore list: %w", err) + } + return strings.Split(string(body), "\x00"), nil +} + +// PathIgnoredAtStart matches a file or a child of an ignored directory. +func PathIgnoredAtStart(path string, paths []string) bool { + for _, ignored := range paths { + if ignored != "" && (path == ignored || strings.HasPrefix(path, strings.TrimSuffix(ignored, "/")+"/")) { + return true + } + } + return false +} + // repositoryRelative names a written file inside the repository whose top // level git reported as root. // diff --git a/internal/seniordev/util/gitutils_test.go b/internal/seniordev/util/gitutils_test.go index 43688ac5f..fe2fa8209 100644 --- a/internal/seniordev/util/gitutils_test.go +++ b/internal/seniordev/util/gitutils_test.go @@ -64,6 +64,8 @@ func TestEagerCommit(t *testing.T) { } gitTestRun(t, dir, "add", "file.txt") gitTestRun(t, dir, "commit", "-qm", "initial") + gitTestRun(t, dir, "switch", "-q", "-c", "task/run") + t.Setenv("SENIOR_DEV_EXPECTED_BRANCH", strings.TrimSpace(gitTestRun(t, dir, "branch", "--show-current"))) if err := os.WriteFile(file, []byte("two\n"), 0o644); err != nil { t.Fatal(err) } @@ -84,6 +86,91 @@ func TestEagerCommit(t *testing.T) { } } +// A write made after HEAD leaves the run's branch remains uncommitted, on a +// person's branch or on a detached HEAD, and never advances either ref. +func TestEagerCommitSkipsAHeadMovedOffTheRunBranch(t *testing.T) { + for _, moved := range []string{"main", "detached"} { + t.Run(moved, func(t *testing.T) { + dir := initGitRepo(t) + file := filepath.Join(dir, "file.txt") + if err := os.WriteFile(file, []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTestRun(t, dir, "add", "file.txt") + gitTestRun(t, dir, "commit", "-qm", "base") + gitTestRun(t, dir, "branch", "-m", "main") + base := strings.TrimSpace(gitTestRun(t, dir, "rev-parse", "HEAD")) + gitTestRun(t, dir, "branch", "task/run") + t.Setenv("SENIOR_DEV_EXPECTED_BRANCH", "task/run") + if moved == "detached" { + gitTestRun(t, dir, "checkout", "-q", "--detach") + } + if err := os.WriteFile(file, []byte("person's next edit\n"), 0o644); err != nil { + t.Fatal(err) + } + previous := skipEagerCommit.Load() + skipEagerCommit.Store(false) + defer skipEagerCommit.Store(previous) + EagerCommit(context.Background(), EagerCommitOptions{Cwd: dir, FilePath: file, Label: "write"}) + if got := strings.TrimSpace(gitTestRun(t, dir, "rev-parse", "HEAD")); got != base { + t.Fatalf("HEAD moved from %s to %s", base, got) + } + if got := strings.TrimSpace(gitTestRun(t, dir, "rev-parse", "task/run")); got != base { + t.Fatalf("task branch moved from %s to %s", base, got) + } + if status := gitTestRun(t, dir, "status", "--porcelain"); !strings.Contains(status, "file.txt") { + t.Fatalf("the write was not left in the working tree: %s", status) + } + }) + } +} + +// An eager file write cannot admit an initially ignored secret after the run +// removes its ignore rule, nor a Python cache created by the run's test suite. +func TestEagerCommitSkipsInitialIgnoresAndGeneratedRunPaths(t *testing.T) { + dir := initGitRepo(t) + gitTestRun(t, dir, "switch", "-q", "-c", "task/run") + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTestRun(t, dir, "add", ".gitignore") + gitTestRun(t, dir, "commit", "-qm", "ignore") + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("SECRET=private\n"), 0o600); err != nil { + t.Fatal(err) + } + ignored := filepath.Join(t.TempDir(), "ignored-at-start") + if err := os.WriteFile(ignored, []byte(".env\x00"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SENIOR_DEV_EXPECTED_BRANCH", "task/run") + t.Setenv("SENIOR_DEV_IGNORED_AT_START", ignored) + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("# changed\n"), 0o644); err != nil { + t.Fatal(err) + } + cache := filepath.Join(dir, "__pycache__", "module.pyc") + if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cache, []byte("bytecode"), 0o644); err != nil { + t.Fatal(err) + } + before := strings.TrimSpace(gitTestRun(t, dir, "rev-parse", "HEAD")) + previous := skipEagerCommit.Load() + skipEagerCommit.Store(false) + defer skipEagerCommit.Store(previous) + for _, file := range []string{filepath.Join(dir, ".env"), cache} { + EagerCommit(context.Background(), EagerCommitOptions{Cwd: dir, FilePath: file, Label: "write"}) + } + if after := strings.TrimSpace(gitTestRun(t, dir, "rev-parse", "HEAD")); after != before { + t.Fatalf("eager commit moved the task branch from %s to %s", before, after) + } + for _, file := range []string{".env", "__pycache__/module.pyc"} { + if _, err := os.Stat(filepath.Join(dir, file)); err != nil { + t.Fatalf("%s was removed: %v", file, err) + } + } +} + // A path spelled through a symlink still commits: the per-file commit is // measured against git's resolved top level, and a workspace reached through a // link (every temporary folder on macOS) used to walk out of the repository. @@ -95,6 +182,8 @@ func TestEagerCommitThroughASymlinkedWorkspace(t *testing.T) { } gitTestRun(t, dir, "add", "file.txt") gitTestRun(t, dir, "commit", "-qm", "initial") + gitTestRun(t, dir, "switch", "-q", "-c", "task/run") + t.Setenv("SENIOR_DEV_EXPECTED_BRANCH", strings.TrimSpace(gitTestRun(t, dir, "branch", "--show-current"))) link := filepath.Join(t.TempDir(), "workspace-link") if err := os.Symlink(dir, link); err != nil { t.Fatal(err) diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go index 03a08a164..b8246e6d5 100644 --- a/internal/session/delegate_landing_test.go +++ b/internal/session/delegate_landing_test.go @@ -118,8 +118,8 @@ func TestADelegatedRunFromADetachedCheckoutNamesTheCommitToGoBackTo(t *testing.T } // A HEAD THE PROGRAM'S SHELL MOVED IS LEFT WHERE IT IS. senior-dev's shell can -// run `git checkout`, and it did, four times in one run. codeaf then commits -// nothing and switches nothing: committing where HEAD is would put codeaf's +// run `git checkout`, and it did, four times in one run. codeaf then makes no +// finishing commit and switches nothing: committing where HEAD is would put codeaf's // commit on a branch that may be the person's own, and switching would carry // whatever is in the folder somewhere nobody chose. It says where HEAD is. func TestAProgramThatMovedHeadOffItsBranchIsLeftWhereItIs(t *testing.T) { @@ -141,7 +141,7 @@ func TestAProgramThatMovedHeadOffItsBranchIsLeftWhereItIs(t *testing.T) { t.Fatalf("codeaf committed what the program left while HEAD was elsewhere:\n%s", status) } want := "fake left " + canonicalPath(repo) + " on the branch work instead of its own branch " + branch + - ", so codeaf changed nothing there: nothing was committed and nothing was switched; " + branch + " holds 1 file" + ", so codeaf made no finishing commit and did not switch branches; the checkout has 1 file uncommitted; " + branch + " holds 1 file; your branch work was not given a commit by codeaf" if !strings.Contains(strings.Join(notes, "\n"), want) { t.Fatalf("the page does not say where HEAD was left: %q, want %q", notes, want) } diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index b319b0d3d..9f0c1feb2 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -12,7 +12,8 @@ package session // conversation's own when it names none (a typed `/senior-dev` names none), // or the one a shell run was started in or named with `--dir` — THAT FOLDER // ITSELF, never a copy of it. Inside a git repository it is the -// repository's root. It is never the home folder or a folder holding it +// repository's root unless git ignores the asked-for folder, which runs +// as a plain folder. It is never the home folder or a folder holding it // ([programHomeRefusal]). A folder that is not there yet is made, empty, // when the folder it would be made in is there. // 2. A GIT REPOSITORY — history, a commit, and a root below the home folder. @@ -30,7 +31,8 @@ package session // around it. // 4. WHEN IT ENDS — done, not finished, stopped, or crashed, an end the // process holding the run saw — in a repository, what the program left -// uncommitted is committed onto its branch in one commit (the task's +// uncommitted, except paths ignored at start and known test droppings, +// is committed onto its branch in one commit (the task's // title, the result under it) and the branch is LEFT CHECKED OUT, so the // person sees the work in their folder. A run that changed nothing is // undone: the person's branch is checked out again and the empty branch @@ -78,6 +80,7 @@ import ( "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/filelock" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/seniordev/util" ) // programFolderDir is where the hold on each folder a program works in, and @@ -161,6 +164,10 @@ type ProgramFolder struct { // are that run's, so the person's branch is still the one named and a run // that adds nothing never deletes what the earlier run left. Continues bool `json:"continues,omitempty"` + // IgnoredAtStart keeps paths git ignored before the run changed its rules. + IgnoredAtStart []string `json:"ignoredAtStart,omitempty"` + // IgnoredOuter is the enclosing repository when Dir itself is ignored by it. + IgnoredOuter string `json:"ignoredOuter,omitempty"` key string place Place @@ -170,6 +177,15 @@ type ProgramFolder struct { // Plain says the program works in its folder without git. func (f *ProgramFolder) Plain() bool { return f == nil || f.Branch == "" } +// IgnoredFile is the run's start-time ignore list, kept outside the repository +// so the child can protect eager commits after it changes .gitignore. +func (f *ProgramFolder) IgnoredFile() string { + if f == nil || f.Keep == "" { + return "" + } + return filepath.Join(f.Keep, "ignored-at-start") +} + // PrepareProgramFolder readies the folder a program was asked to work in, per // the contract at the top of this file, and holds it for the run: the folder // resolved and made when it must be, the hold taken, a run that went away in @@ -227,9 +243,29 @@ func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { folder.NotesWereThere = err == nil } if !repo { + if outer != "" && !holdsHomeFolder(outer) { + folder.IgnoredOuter = outer + folder.Outer = "" + } folder.write() return folder, nil } + ignored, err := git(dir, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z") + if err != nil { + folder.release() + return nil, fmt.Errorf("read paths ignored at the start in %s: %s", dir, firstLine(ignored)) + } + folder.IgnoredAtStart = strings.Split(strings.TrimSuffix(ignored, "\x00"), "\x00") + if path := folder.IgnoredFile(); path != "" { + if err := os.MkdirAll(folder.Keep, 0o700); err != nil { + folder.release() + return nil, err + } + if err := os.WriteFile(path, []byte(ignored), 0o600); err != nil { + folder.release() + return nil, err + } + } carried, err := folder.carryOn() if !carried && err == nil { err = folder.cutBranch() @@ -406,6 +442,13 @@ func programFolderOf(asked string) (dir string, repo bool, outer string, refusal case canonicalPath(asked) == root: return asked, true, "", "" } + // A FOLDER GIT IGNORES IS ITS OWN PLAIN WORKSPACE. Widening it to the + // repository would cut an empty branch and call the run's real files no work. + if relative, err := filepath.Rel(root, canonicalPath(asked)); err == nil { + if _, ignored := git(root, "check-ignore", "-q", "--no-index", "--", filepath.ToSlash(relative)); ignored == nil { + return asked, false, root, "" + } + } return root, true, "", "" } @@ -511,8 +554,8 @@ type ProgramFolderEnd struct { HomeAt string // Gone says the run's process went away before it could end the run // itself, so codeaf settled its folder without writing to git at all - // ([ProgramFolder.settleGone]), and Uncommitted is how many files it found - // there that are not committed. + // ([ProgramFolder.settleGone]). Uncommitted is how many files are not + // committed in a checkout left on or moved off the task branch. Gone bool Uncommitted int // Refused is git's own line when what the program left could not be @@ -616,6 +659,8 @@ func (f *ProgramFolder) settle(result string) ProgramFolderEnd { end.Changed = changedBetween(f.Dir, f.Start, tip) end.Kept = tip != f.Start } + end.Uncommitted = uncommittedCount(f.Dir, f.Notes) + end.HomeMoved, end.HomeAt = f.homeMoved() return end } end.HomeMoved, end.HomeAt = f.homeMoved() @@ -673,10 +718,10 @@ func (f *ProgramFolder) settleGone() ProgramFolderEnd { end.Changed = changedBetween(f.Dir, f.Start, tip) end.Kept = tip != f.Start } - if !end.Moved { - end.Uncommitted = uncommittedCount(f.Dir, f.Notes) - end.HomeMoved, end.HomeAt = f.homeMoved() - } + // A vanished worker can leave loose files on a checkout it moved off the + // task branch too. The moved ending needs the same count as a seen end. + end.Uncommitted = uncommittedCount(f.Dir, f.Notes) + end.HomeMoved, end.HomeAt = f.homeMoved() return end } @@ -719,20 +764,11 @@ func (f *ProgramFolder) homeMoved() (bool, string) { // folder onto its branch, in one commit whose subject is the run's title and // whose body is result, and answers git's line when it would not go. // -// IT IS THE PROGRAM'S FOLDER, SO IT IS ALL OF IT. The checkout was clean when -// the branch was cut ([programCheckoutInTheWay]), and nothing else of codeaf's -// writes there while the run holds it ([programHoldGuard]), so everything in -// it now that is not committed is the run's. It is only ever asked of a run -// whose end this process saw: a run whose process went away is settled -// without a commit ([ProgramFolder.settleGone]). -// -// THE NOTES ARE TAKEN BACK OUT OF THE INDEX, NOT LEFT OUT OF THE ADD. A -// pathspec that excludes `.senior-dev` makes `git add` exit 1 whenever that -// folder is there and ignored — and senior-dev ignores it in every repository -// it works in — so a notes folder that was there before the run, or would not -// move, failed every finishing commit. The whole folder is staged and the -// notes' own path reset to what HEAD holds, which git does whatever its -// ignore rules say, the way [sealGroundWork] does it. +// THE CHECKOUT WAS CLEAN AT THE START, but its ignore rules can change during +// the run. Paths ignored at the start and known test droppings are never +// staged by this finishing commit. The notes are excluded for the same reason: +// a program's private record must not enter the person's branch. A run whose +// process went away is settled without a commit ([ProgramFolder.settleGone]). // // A CHECKOUT IN THE MIDDLE OF A MERGE IS NOT COMMITTED. The program's shell can // start one, and a commit now would conclude it, conflict markers and all, @@ -741,11 +777,38 @@ func (f *ProgramFolder) commitLeftovers(result string) string { if half := halfDone(f.Dir); half != "" { return f.Dir + " is in the middle of a " + half } - if out, err := git(f.Dir, "add", "-A", "--", "."); err != nil { - return "git add: " + firstLine(out) + // Stage named paths only. A blanket add would put an initially ignored + // secret into the index when the run rewrote .gitignore, even if a later + // reset kept it out of the commit. + var toAdd []string + for _, args := range [][]string{ + {"diff", "HEAD", "--name-only", "--no-renames", "-z", "--"}, + {"ls-files", "--others", "--exclude-standard", "-z"}, + } { + out, err := git(f.Dir, args...) + if err != nil { + return "git list changes: " + firstLine(out) + } + for _, path := range strings.Split(strings.TrimSuffix(out, "\x00"), "\x00") { + if path != "" && !f.excludedFromCommit(path) { + toAdd = append(toAdd, path) + } + } + } + if len(toAdd) > 0 { + if out, err := git(f.Dir, append([]string{"add", "-A", "--"}, toAdd...)...); err != nil { + return "git add: " + firstLine(out) + } + } + staged, err := git(f.Dir, "diff", "--cached", "--name-only", "-z") + if err != nil { + return "git diff: " + firstLine(staged) } - if f.Notes != "" { - if out, err := git(f.Dir, "reset", "-q", "--", f.Notes); err != nil { + for _, path := range strings.Split(strings.TrimSuffix(staged, "\x00"), "\x00") { + if path == "" || !f.excludedFromCommit(path) { + continue + } + if out, err := git(f.Dir, "reset", "-q", "--", path); err != nil { return "git reset: " + firstLine(out) } } @@ -769,6 +832,18 @@ func (f *ProgramFolder) commitLeftovers(result string) string { return "" } +func (f *ProgramFolder) excludedFromCommit(path string) bool { + if f.Notes != "" && (path == f.Notes || strings.HasPrefix(path, strings.TrimSuffix(f.Notes, "/")+"/")) { + return true + } + for _, ignored := range f.IgnoredAtStart { + if ignored != "" && (path == ignored || strings.HasPrefix(path, strings.TrimSuffix(ignored, "/")+"/")) { + return true + } + } + return util.GeneratedRunPath(path) +} + // goBack checks out the person's own branch again (or the commit their // checkout was on) and deletes the program's empty branch, answering git's // line when either would not go. @@ -874,6 +949,10 @@ func (e ProgramFolderEnd) Sentence() string { f := e.Folder var said string switch { + case e.Gone && f.IgnoredOuter != "": + said = "its work so far is in " + f.Dir + ", as it left it; git ignores this folder inside " + f.IgnoredOuter + ", so codeaf cut no branch and nothing was committed" + case f.IgnoredOuter != "": + said = "its work is in " + f.Dir + "; git ignores this folder inside " + f.IgnoredOuter + ", so codeaf cut no branch and nothing was committed" case e.Gone && f.Branch == "" && f.Outer != "": said = "its work so far is in " + f.Dir + ", as it left it; the git repository around it is at " + f.Outer + ", which holds your home folder, so codeaf cut no branch there and committed nothing" @@ -890,10 +969,20 @@ func (e ProgramFolderEnd) Sentence() string { where = "no branch, at " + e.At } said = f.Program + " left " + f.Dir + " on " + where + " instead of its own branch " + f.Branch + - ", so codeaf changed nothing there: nothing was committed and nothing was switched" + ", so codeaf made no finishing commit and did not switch branches" + if e.Uncommitted > 0 { + said += "; the checkout has " + fileCount(e.Uncommitted) + " uncommitted" + } else { + said += "; no uncommitted files were left in that checkout" + } if e.Kept { said += "; " + f.Branch + " holds " + fileCount(len(e.Changed)) } + if e.HomeMoved { + said += "; " + e.homeMovedWords() + } else if f.Home != "" { + said += "; your branch " + f.Home + " was not given a commit by codeaf" + } case e.Gone: said = e.goneWords() case e.Dropped: diff --git a/internal/session/programfolder_safety_test.go b/internal/session/programfolder_safety_test.go new file mode 100644 index 000000000..3c589715a --- /dev/null +++ b/internal/session/programfolder_safety_test.go @@ -0,0 +1,145 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func safetyRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + mustGit(t, repo, "init", "-q", "-b", "main") + mustGit(t, repo, "config", "user.name", "Person") + mustGit(t, repo, "config", "user.email", "person@example.test") + writeFile(t, filepath.Join(repo, "shared.txt"), "base\n") + mustGit(t, repo, "add", "shared.txt") + mustGit(t, repo, "commit", "-q", "-m", "base") + return repo +} + +// An ignored secret at the start remains outside the run's commit after the +// program changes the ignore rule, while the changed rule is ordinary work. +func TestProgramFolderNeverCommitsPathsIgnoredAtStartOrRunCaches(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + repo := safetyRepo(t) + writeFile(t, filepath.Join(repo, ".gitignore"), ".env\n") + mustGit(t, repo, "add", ".gitignore") + mustGit(t, repo, "commit", "-q", "-m", "ignore") + writeFile(t, filepath.Join(repo, ".env"), "SECRET=private\n") + folder := prepareIn(t, testPrograms("fake")[0], repo, "Change ignore rules") + ignoredRecord, err := os.ReadFile(folder.IgnoredFile()) + if err != nil || !strings.Contains(string(ignoredRecord), ".env\x00") { + t.Fatalf("the child cannot read its start-time ignore record: %q, %v", ignoredRecord, err) + } + writeFile(t, filepath.Join(repo, ".gitignore"), "# changed by run\n") + writeFile(t, filepath.Join(repo, "__pycache__", "module.pyc"), "bytecode") + writeFile(t, filepath.Join(repo, ".pytest_cache", "state"), "cache") + writeFile(t, filepath.Join(repo, "made.txt"), "work\n") + folder.Finish("done") + paths := gitOut(t, repo, "ls-tree", "-r", "--name-only", "HEAD") + for _, want := range []string{".gitignore", "made.txt"} { + if !strings.Contains(paths, want) { + t.Fatalf("%s missing from commit: %s", want, paths) + } + } + for _, excluded := range []string{".env", "__pycache__/module.pyc", ".pytest_cache/state"} { + if strings.Contains(paths, excluded) { + t.Fatalf("%s entered commit: %s", excluded, paths) + } + if _, err := os.Stat(filepath.Join(repo, excluded)); err != nil { + t.Fatalf("%s was removed: %v", excluded, err) + } + } +} + +// A folder ignored by its enclosing repository uses the plain folder road and +// leaves the repository's refs alone even when the run writes files. +func TestProgramFolderInsideIgnoredDirectoryStaysPlain(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + repo := safetyRepo(t) + writeFile(t, filepath.Join(repo, ".gitignore"), "build-out/\n") + mustGit(t, repo, "add", ".gitignore") + mustGit(t, repo, "commit", "-q", "-m", "ignore output") + before := strings.TrimSpace(gitOut(t, repo, "rev-parse", "main")) + inside := filepath.Join(repo, "build-out") + if err := os.Mkdir(inside, 0o755); err != nil { + t.Fatal(err) + } + folder := prepareIn(t, testPrograms("fake")[0], inside, "Build here") + writeFile(t, filepath.Join(inside, "result.txt"), "made\n") + end := folder.Finish("done") + if !folder.Plain() || folder.Dir != inside || strings.TrimSpace(gitOut(t, repo, "rev-parse", "main")) != before || currentBranch(repo) != "main" { + t.Fatalf("ignored folder was treated as repo: %+v, %s", folder, end.Sentence()) + } + if strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")) != "" { + t.Fatal("a task branch was cut in the enclosing repo") + } + if body, err := os.ReadFile(filepath.Join(inside, "result.txt")); err != nil || string(body) != "made\n" { + t.Fatalf("plain work missing: %q, %v", body, err) + } + if strings.Contains(end.Sentence(), "it changed nothing") || !strings.Contains(end.Sentence(), "nothing was committed") { + t.Fatalf("ending misstates plain work: %s", end.Sentence()) + } +} + +// A moved checkout gets no finishing commit and the ending names the commit +// already on the task branch as well as the uncommitted work left on main. +func TestProgramFolderMovedHeadEndingAccountsForCommittedAndLooseWork(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + repo := safetyRepo(t) + base := strings.TrimSpace(gitOut(t, repo, "rev-parse", "main")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Own branch only") + writeFile(t, filepath.Join(repo, "task.txt"), "committed on task\n") + mustGit(t, repo, "add", "task.txt") + mustGit(t, repo, "commit", "-q", "-m", "task work") + mustGit(t, repo, "switch", "-q", "main") + writeFile(t, filepath.Join(repo, "loose.txt"), "uncommitted\n") + end := folder.Finish("done") + if currentBranch(repo) != "main" || strings.TrimSpace(gitOut(t, repo, "rev-parse", "main")) != base { + t.Fatal("the person's branch gained a commit or HEAD moved") + } + if status := gitOut(t, repo, "status", "--porcelain"); !strings.Contains(status, "loose.txt") { + t.Fatalf("run's loose work disappeared: %s", status) + } + said := end.Sentence() + for _, want := range []string{"the branch main", "uncommitted", "holds 1 file", "your branch main was not given a commit by codeaf"} { + if !strings.Contains(said, want) { + t.Fatalf("ending missing %q: %s", want, said) + } + } + if strings.Contains(said, "nothing was committed") { + t.Fatalf("ending denied a real task commit: %s", said) + } +} + +func TestProgramFolderMovedHeadWithCleanCheckoutDoesNotClaimLooseWork(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + repo := safetyRepo(t) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Look only") + mustGit(t, repo, "switch", "-q", "main") + said := folder.Finish("done").Sentence() + if !strings.Contains(said, "no uncommitted files were left in that checkout") { + t.Fatalf("clean moved checkout misreported as uncommitted work: %s", said) + } +} + +// A process that vanished after moving HEAD still leaves a truthful account +// of loose files on the person's checkout, without committing them there. +func TestProgramFolderMovedHeadAfterVanishedRunNamesLooseWork(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + repo := safetyRepo(t) + base := strings.TrimSpace(gitOut(t, repo, "rev-parse", "main")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Stopped after switch") + t.Cleanup(folder.release) + mustGit(t, repo, "switch", "-q", "main") + writeFile(t, filepath.Join(repo, "loose.txt"), "still here\n") + said := folder.settleGone().Sentence() + if !strings.Contains(said, "the checkout has 1 file uncommitted") || !strings.Contains(said, "your branch main was not given a commit by codeaf") { + t.Fatalf("vanished run's moved checkout was misstated: %s", said) + } + if currentBranch(repo) != "main" || strings.TrimSpace(gitOut(t, repo, "rev-parse", "main")) != base { + t.Fatal("the person's branch gained a commit or HEAD moved") + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 40c1d6153..7ae253632 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -148,6 +148,10 @@ type RunSpec struct { // git ([ProgramFolder.Plain]), so it is started with its own flags for that // (delegate.Delegate.PlainFolder). False for every other run. PlainFolder bool + // ProgramBranch and ProgramIgnoredFile fence the child's eager commits to + // its own branch and the ignore rules recorded before it started. + ProgramBranch string + ProgramIgnoredFile string // Crew is the conversation's crew as a delegated run's program is handed it // ([conversationCrew]), so the program works on the models the person // chose. Zero for every other run. @@ -797,7 +801,14 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { Conversation: a.runConversation(), Delegate: run.delegate, PlainFolder: run.folder != nil && run.folder.Plain(), - Crew: a.delegateCrew(run), + ProgramBranch: func() string { + if run.folder == nil { + return "" + } + return run.folder.Branch + }(), + ProgramIgnoredFile: run.folder.IgnoredFile(), + Crew: a.delegateCrew(run), } } From ca28d6b49d5d4680ac22c4bf036af4497ffef02f Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:07:01 -0400 Subject: [PATCH 157/195] seniordev: the verifier runs unittest when a Python project has no pytest senior-dev's final verification always chose python3 -m pytest for a Python project, so a correct fix in a folder whose tests are the standard library's unittest, on a machine without pytest, ended "did not finish" with exit 2 (a real run in the review did exactly that). Discovery now keeps pytest when it is importable or the project declares it (pyproject, setup.cfg, requirements, tox, pytest.ini), and otherwise runs python3 -m unittest discovery. Review of #1488, lane 5 finding F5.1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../app/python_verification_fallback_test.go | 32 ++++++++ .../session/fullverification/discovery.go | 40 +++++++++- .../fullverification/discovery_test.go | 3 + .../fullverification/python_fallback_test.go | 75 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 internal/seniordev/app/python_verification_fallback_test.go create mode 100644 internal/seniordev/session/fullverification/python_fallback_test.go diff --git a/internal/seniordev/app/python_verification_fallback_test.go b/internal/seniordev/app/python_verification_fallback_test.go new file mode 100644 index 000000000..26758cdfd --- /dev/null +++ b/internal/seniordev/app/python_verification_fallback_test.go @@ -0,0 +1,32 @@ +//go:build !windows + +package app + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCorrectUnittestFixPassesWithoutPytest(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is not installed") + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "python3"), []byte("#!/bin/sh\nexec '"+python+"' -S \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + runner := verificationWorkspace(t, map[string]string{ + "calc.py": "def add(a, b):\n return a + b\n", + "test_calc.py": "import unittest\nfrom calc import add\nclass TestCalc(unittest.TestCase):\n def test_add(self):\n self.assertEqual(add(1, 2), 3)\n", + }) + result := runner.runProjectVerification(context.Background()) + if result.Failed != nil || result.NewFailures != 0 || !strings.Contains(result.Prompt, "python3 -m unittest discover") { + t.Fatalf("correct unittest fix failed verification: %#v", result) + } +} diff --git a/internal/seniordev/session/fullverification/discovery.go b/internal/seniordev/session/fullverification/discovery.go index c1441e78d..7843aeb61 100644 --- a/internal/seniordev/session/fullverification/discovery.go +++ b/internal/seniordev/session/fullverification/discovery.go @@ -7,6 +7,7 @@ package fullverification import ( "encoding/json" "os" + "os/exec" "path/filepath" "regexp" "sort" @@ -545,12 +546,49 @@ func ecosystemDefaults(workspace string) []Entrypoint { add(KindTest, "dotnet test", "dotnet project") case isPythonProject(workspace): if hasPythonTests(workspace) { - add(KindTest, "python3 -m pytest", "Python test files") + if declaresPytest(workspace) || pythonHasPytest(workspace) { + add(KindTest, "python3 -m pytest", "Python test files") + } else { + add(KindTest, "python3 -m unittest discover", "Python test files") + } } } return entries } +// declaresPytest keeps an explicit project choice even if this machine lacks +// the package; that failure is a missing dependency rather than a test style +// the verifier should silently replace. +func declaresPytest(workspace string) bool { + if fileExists(filepath.Join(workspace, "pytest.ini")) || fileExists(filepath.Join(workspace, "conftest.py")) { + return true + } + files := []string{"pyproject.toml", "setup.cfg", "tox.ini", "requirements.txt"} + for _, pattern := range []string{"requirements-*.txt", "requirements_*.txt", "requirements/*.txt"} { + matches, _ := filepath.Glob(filepath.Join(workspace, pattern)) + for _, match := range matches { + if relative, err := filepath.Rel(workspace, match); err == nil { + files = append(files, relative) + } + } + } + for _, name := range files { + contents, err := os.ReadFile(filepath.Join(workspace, name)) + if err == nil && strings.Contains(strings.ToLower(string(contents)), "pytest") { + return true + } + } + return false +} + +// pythonHasPytest checks the interpreter the discovered command will run in +// the project's own directory, so an installed or local pytest is usable. +func pythonHasPytest(workspace string) bool { + command := exec.Command("python3", "-c", "import pytest") + command.Dir = workspace + return command.Run() == nil +} + func appendShellCandidates(out []commandCandidate, raw, source string) []commandCandidate { return appendShellCandidatesFrom(out, raw, "", source) } diff --git a/internal/seniordev/session/fullverification/discovery_test.go b/internal/seniordev/session/fullverification/discovery_test.go index 832b5b6f6..d839aca3f 100644 --- a/internal/seniordev/session/fullverification/discovery_test.go +++ b/internal/seniordev/session/fullverification/discovery_test.go @@ -293,6 +293,9 @@ func TestDiscoverPlainPythonWithoutPackagingMetadata(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { workspace := t.TempDir() + // The fallback below is about identifying a Python project; make + // pytest importable so this older test also has a fixed test runner. + writeDiscoveryFile(t, workspace, "pytest.py", "# importable fixture module\n") writeDiscoveryFile(t, workspace, test.marker, "# marker\n") if test.marker != "tests/test_example.py" { writeDiscoveryFile(t, workspace, "tests/test_example.py", "def test_green():\n assert True\n") diff --git a/internal/seniordev/session/fullverification/python_fallback_test.go b/internal/seniordev/session/fullverification/python_fallback_test.go new file mode 100644 index 000000000..47b8af39f --- /dev/null +++ b/internal/seniordev/session/fullverification/python_fallback_test.go @@ -0,0 +1,75 @@ +//go:build !windows + +package fullverification + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPythonUnittestProjectWithoutPytestPassesDiscovery(t *testing.T) { + pythonWithoutSitePackages(t) + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "calc.py", "def add(a, b):\n return a + b\n") + writeDiscoveryFile(t, workspace, "test_calc.py", "import unittest\nfrom calc import add\nclass TestCalc(unittest.TestCase):\n def test_add(self):\n self.assertEqual(add(1, 2), 3)\n") + plan := Discover(workspace) + if len(plan.Entrypoints) != 1 || plan.Entrypoints[0].Command != "python3 -m unittest discover" { + t.Fatalf("entrypoints = %#v, want unittest discovery", plan.Entrypoints) + } + cmd := exec.Command("sh", "-c", plan.Entrypoints[0].Command) + cmd.Dir = workspace + if output, err := cmd.CombinedOutput(); err != nil || !strings.Contains(string(output), "OK") { + t.Fatalf("discovered unittest command: %v\n%s", err, output) + } +} + +func pythonWithoutSitePackages(t *testing.T) { + t.Helper() + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is not installed") + } + bin := t.TempDir() + // -S gives this fixture a real Python interpreter without site packages. + writeDiscoveryFile(t, bin, "python3", "#!/bin/sh\nexec '"+python+"' -S \"$@\"\n") + if err := os.Chmod(filepath.Join(bin, "python3"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestDeclaredPytestKeepsPytestWhenUnavailable(t *testing.T) { + pythonWithoutSitePackages(t) + for _, declared := range []struct{ path, body string }{ + {"pytest.ini", "[pytest]\n"}, + {"pyproject.toml", "[project]\ndependencies = ['pytest']\n"}, + {"setup.cfg", "[options.extras_require]\ntest = pytest\n"}, + {"tox.ini", "[testenv]\ndeps = pytest\n"}, + {"requirements.txt", "pytest\n"}, + {"requirements/dev.txt", "pytest\n"}, + } { + t.Run(declared.path, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, declared.path, declared.body) + writeDiscoveryFile(t, workspace, "test_calc.py", "def test_add():\n assert 1 + 2 == 3\n") + plan := Discover(workspace) + if len(plan.Entrypoints) != 1 || plan.Entrypoints[0].Command != "python3 -m pytest" { + t.Fatalf("declared pytest entrypoints = %#v", plan.Entrypoints) + } + }) + } +} + +func TestImportablePytestKeepsPytest(t *testing.T) { + pythonWithoutSitePackages(t) + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "pytest.py", "# importable fixture module\n") + writeDiscoveryFile(t, workspace, "test_calc.py", "def test_add():\n assert 1 + 2 == 3\n") + plan := Discover(workspace) + if len(plan.Entrypoints) != 1 || plan.Entrypoints[0].Command != "python3 -m pytest" { + t.Fatalf("importable pytest entrypoints = %#v", plan.Entrypoints) + } +} From 19c5c1a32a05455027469fea1caa1c5a6035c4ca Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:07:01 -0400 Subject: [PATCH 158/195] delegate, seniordev: the model's commands get the chat's scrubbed shell, and a wrong protocol lands nothing The commands senior-dev's model runs inherited the host's TMUX and TMUX_PANE, so one of them could reach the tmux server codeaf itself runs in (the #576 crash the chat's own bash is protected from by JobShellEnv). They also kept every provider key but the default one, and the loopback model API's token. The child's environment now drops the key variables the profile's services name and the conventional API key names, and the bash tool applies exec.JobShellEnv and removes the model API's address and token, which only the engine process needs. The comments and the manual now claim exactly that, and no more. A child whose hello names a protocol version codeaf does not speak is ended with one sentence naming both versions, and none of its step records reach the task trajectory any more. Review of #1488, lane 3 finding F3.1 and lane 2 findings F2.1, F2.8. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- cmd/codeaf/carried.go | 4 +- internal/delegate/child_shell_env_test.go | 89 +++++++++++++++++++ internal/delegate/host.go | 29 ++++-- internal/delegate/launch_test.go | 6 +- internal/run/delegate_protocol_reject_test.go | 37 ++++++++ internal/run/delegateworker.go | 14 +-- internal/seniordev/tool/shell_scratch.go | 7 +- 7 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 internal/delegate/child_shell_env_test.go create mode 100644 internal/run/delegate_protocol_reject_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index 90c68eda3..c56ef2987 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -343,8 +343,8 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { 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. + // NO PROVIDER KEY IS INHERITED BY THE PROGRAM (delegate.ChildEnv): the engine + // gets the loopback token it needs, and model commands lose that token. Env: append(delegate.ChildEnv(api.API()), "SENIOR_DEV_EXPECTED_BRANCH="+folder.Branch, "SENIOR_DEV_IGNORED_AT_START="+folder.IgnoredFile()), Dir: here, StderrPath: filepath.Join(record, carriedStderrName), diff --git a/internal/delegate/child_shell_env_test.go b/internal/delegate/child_shell_env_test.go new file mode 100644 index 000000000..c5914be9e --- /dev/null +++ b/internal/delegate/child_shell_env_test.go @@ -0,0 +1,89 @@ +//go:build !windows + +package delegate_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +func TestChildShellDropsHostTmuxAndModelKeys(t *testing.T) { + if os.Getenv("FC_CHILD_BASH") == "1" { + workspace := os.Getenv("FC_WORKSPACE") + input, _ := json.Marshal(map[string]any{"command": "env"}) + result, err := tool.New(workspace).Execute(context.Background(), steploop.ToolCall{ + ID: "environment", Name: "bash", Input: input, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("FC_RESULT"), []byte(result.Output), 0o600); err != nil { + t.Fatal(err) + } + os.Stdout.WriteString("{\"type\":\"hello\",\"protocol\":2,\"delegate\":\"fake\"}\n") + os.Stdout.WriteString("{\"type\":\"terminal\",\"status\":\"pass\"}\n") + return + } + profile := t.TempDir() + if err := config.WriteSources(profile, []config.PersistedSource{{ID: "custom", Written: "custom", KeyEnv: "MY_SERVICE_SECRET"}}); err != nil { + t.Fatal(err) + } + t.Setenv(config.ProfileDirEnv, profile) + t.Setenv("MY_SERVICE_SECRET", "custom-secret") + t.Setenv("TMUX", "/tmp/host-tmux/default,1,0") + t.Setenv("TMUX_PANE", "%7") + t.Setenv("OPENROUTER_API_KEY", "router-secret") + t.Setenv("ANTHROPIC_API_KEY", "anthropic-secret") + t.Setenv("AFORGE_API_KEY", "legacy-secret") // legacy-name + t.Setenv("GOPATH", filepath.Join(t.TempDir(), "go")) + t.Setenv("FC_CHILD_BASH", "1") + workspace := t.TempDir() + resultPath := filepath.Join(t.TempDir(), "environment") + t.Setenv("FC_WORKSPACE", workspace) + t.Setenv("FC_RESULT", resultPath) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + _, err = delegate.Run(context.Background(), delegate.Launch{ + Name: "fake", Bin: self, Args: []string{"-test.run=^TestChildShellDropsHostTmuxAndModelKeys$"}, + Env: delegate.ChildEnv(delegate.ModelAPI{BaseURL: "http://127.0.0.1:9/v1", Token: "loopback-secret"}), + Dir: workspace, + }, nil) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(resultPath) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, entry := range strings.Split(string(raw), "\n") { + name, value, ok := strings.Cut(entry, "=") + if ok { + got[name] = value + } + } + for _, name := range []string{"TMUX", "TMUX_PANE", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "AFORGE_API_KEY", "MY_SERVICE_SECRET", delegate.EnvModelToken} { // legacy-name + if value, ok := got[name]; ok { + t.Errorf("%s reached model bash: %q", name, value) + } + } + for _, name := range []string{"PATH", "HOME", "GOPATH"} { + if got[name] == "" { + t.Errorf("ordinary variable %s missing", name) + } + } + if got["TMUX_TMPDIR"] == "" { + t.Error("model bash did not get a private tmux socket directory") + } +} diff --git a/internal/delegate/host.go b/internal/delegate/host.go index 15091e1e4..fb0fdb0fc 100644 --- a/internal/delegate/host.go +++ b/internal/delegate/host.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/modelsource" ) @@ -79,21 +80,33 @@ func ModelAPIFromEnv() (ModelAPI, bool) { // 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. +// NO PROVIDER KEY IS INHERITED BY A PROGRAM. The child itself receives only the +// loopback model token; the model's shell strips that token as well. A +// redirection left here would let a program reach a model outside the API, +// the one road codeaf can meter and show a person. func ChildEnv(api ModelAPI) []string { - strip := []string{EnvModelAPI, EnvModelToken, envBaseURL, "OPENAI_API_KEY", modelsource.DefaultSource("").KeyEnv} + strip := []string{EnvModelAPI, EnvModelToken, envBaseURL, "CODEAF_API_KEY", "OPENAI_API_KEY", modelsource.DefaultSource("").KeyEnv} // legacy-name for _, source := range modelsource.Vendored() { if source.KeyEnv != "" { strip = append(strip, source.KeyEnv) } } + for _, source := range config.PersistedSources(config.ProfileDir()) { + if source.KeyEnv != "" { + strip = append(strip, source.KeyEnv) + } + } environ := env.EnvironWithout(strip...) + // A provider may use a conventional key name before codeaf lists it, and + // the older compatibility spelling may be present without its new name. + kept := environ[:0] + for _, entry := range environ { + name, _, _ := strings.Cut(entry, "=") + if !strings.HasSuffix(name, "_API_KEY") { + kept = append(kept, entry) + } + } + environ = kept if api.BaseURL != "" { environ = append(environ, EnvModelAPI+"="+api.BaseURL, EnvModelToken+"="+api.Token) } diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go index b06e026a8..e2284fd40 100644 --- a/internal/delegate/launch_test.go +++ b/internal/delegate/launch_test.go @@ -120,9 +120,9 @@ func TestRunHandsTheBriefOverVerbatim(t *testing.T) { } } -// 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. +// NO PROVIDER KEY IS INHERITED BY A PROGRAM. The child's environment is this +// process's with provider keys and model redirections 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") diff --git a/internal/run/delegate_protocol_reject_test.go b/internal/run/delegate_protocol_reject_test.go new file mode 100644 index 000000000..2447dc031 --- /dev/null +++ b/internal/run/delegate_protocol_reject_test.go @@ -0,0 +1,37 @@ +//go:build !windows + +package run_test + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/run" +) + +func TestWrongProtocolLeavesNoChildStepsInTrajectory(t *testing.T) { + store := runOpenStore(t) + script := filepath.Join(t.TempDir(), "future.sh") + wrong := delegate.ProtocolVersion + 1 + body := "#!/bin/sh\n" + + "echo '{\"type\":\"hello\",\"protocol\":" + strconv.Itoa(wrong) + ",\"delegate\":\"fake\"}'\n" + + "echo '{\"type\":\"step\",\"command\":\"bash: incompatible action\"}'\n" + + "echo '{\"type\":\"terminal\",\"status\":\"pass\"}'\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + worker := run.NewDelegateWorker(store, t.TempDir(), delegate.Delegate{Name: "fake", Default: "run"}, run.DelegateSetup{Exe: script}, 0, 0) + _, err := worker.Run(runContext(t), *store.Task(store.RootID())) + if err == nil || !strings.Contains(err.Error(), "version "+strconv.Itoa(wrong)) || !strings.Contains(err.Error(), "version "+strconv.Itoa(delegate.ProtocolVersion)) { + t.Fatalf("mismatch error = %v", err) + } + for _, line := range rawTrajectory(t, filepath.Dir(store.Path()), store.RootID()) { + if strings.Contains(line, "incompatible action") { + t.Fatalf("wrong-protocol step entered trajectory: %s", line) + } + } +} diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 5f4fd96c0..b91dc7179 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -23,8 +23,9 @@ package run // // 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 +// its address and token and nothing else (delegate.ChildEnv): no provider key +// is inherited by the program, and its model-written shell loses the loopback token. +// 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. @@ -257,7 +258,7 @@ func (s *delegateSink) Hello(h delegate.Hello) { // 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.mismatch = fmt.Sprintf("%s speaks record protocol version %d, but codeaf reads version %d; restart codeaf to run %s", s.name, h.Protocol, delegate.ProtocolVersion, s.name) if s.stop != nil { s.stop() @@ -269,6 +270,9 @@ func (s *delegateSink) Stage(record delegate.StageRecord) { } func (s *delegateSink) Step(record delegate.StepRecord) { + if s.mismatch != "" { + return + } s.steps++ if err := appendTrajectory(s.storeDir, s.taskID, Step{ Kind: trajectoryStepKind, @@ -478,8 +482,8 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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. + // NO PROVIDER KEY IS INHERITED BY THE PROGRAM (delegate.ChildEnv): the API's + // address and token are what its engine needs; model commands lose both. Env: append(delegate.ChildEnv(api.API()), "SENIOR_DEV_EXPECTED_BRANCH="+w.setup.Branch, "SENIOR_DEV_IGNORED_AT_START="+w.setup.IgnoredFile), Dir: w.workspace, StderrPath: filepath.Join(taskDir, delegateStderrName), diff --git a/internal/seniordev/tool/shell_scratch.go b/internal/seniordev/tool/shell_scratch.go index 024a84541..083ef2203 100644 --- a/internal/seniordev/tool/shell_scratch.go +++ b/internal/seniordev/tool/shell_scratch.go @@ -14,6 +14,9 @@ import ( "syscall" "time" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/env" + jobexec "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" ) @@ -210,7 +213,9 @@ func shellScratchPIDAlive(pid int) bool { } func shellEnvironment(sessionID string) []string { - environment := append([]string(nil), os.Environ()...) + // The engine needs this run's loopback token, but a model command does not. + // The chat's shared shell policy also isolates this command's tmux socket. + environment := jobexec.JobShellEnv(env.EnvironWithout(delegate.EnvModelToken, delegate.EnvModelAPI)) // Appended after os.Environ() so exec's last-entry-wins dedup overrides // any proxy the parent carries; independent of the shared-cache early // return below, which must not open the network gate. From 41684fbbe0e15a4b3db8469ee28bcdc443a54de7 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:07:01 -0400 Subject: [PATCH 159/195] seniordev: nothing a run starts outlives it, a models.dev outage refuses nothing, and question leaves the hosted belt Each bash command senior-dev ran had its own process group, so a server it started with a plain & or with setsid kept running after the run ended or was stopped, holding the folder that had just been released. The bash tool now tracks every group it creates and closes them with the runtime; on Linux the engine is a child subreaper and the delegate host sweeps the run's remaining descendants, after an engine crash too. macOS kills the tracked groups, and the manual states the one limit left there (a process that detaches itself). A fresh machine that could not reach models.dev had every run refused with "model catalog: context deadline exceeded" though codeaf's model API was fine. An unreachable catalog now loads empty and the engine takes conservative model limits; the request itself remains and the manual names it, with webfetch on by default and search opt-in. The question tool was on the hosted belt, and every call to it was rejected: a run reads no messages and asks nothing. It is absent now. Review of #1488, lane 2 finding F2.4 and lane 3 findings F3.2, F3.3, F3.4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 16 ++- internal/delegate/child_processes_test.go | 122 ++++++++++++++++++ internal/delegate/launch.go | 19 ++- internal/manual/chat/senior-dev.md | 38 +++++- internal/manual/senior_dev_reach_test.go | 21 +++ .../processgroup/run_descendants_linux.go | 79 ++++++++++++ .../processgroup/run_descendants_other.go | 9 ++ internal/seniordev/app/engine_client.go | 10 ++ internal/seniordev/app/hosted_belt_test.go | 16 +++ .../seniordev/app/offline_catalog_test.go | 54 ++++++++ internal/seniordev/app/run.go | 17 ++- internal/seniordev/app/runtime.go | 16 +-- internal/seniordev/app/runtime_test.go | 2 +- internal/seniordev/baked/agents/coder.md | 4 +- internal/seniordev/tool/bash.go | 43 ++++++ .../seniordev/tool/bash_background_test.go | 62 +++++++++ internal/seniordev/tool/registry.go | 12 +- 17 files changed, 515 insertions(+), 25 deletions(-) create mode 100644 internal/delegate/child_processes_test.go create mode 100644 internal/manual/senior_dev_reach_test.go create mode 100644 internal/processgroup/run_descendants_linux.go create mode 100644 internal/processgroup/run_descendants_other.go create mode 100644 internal/seniordev/app/hosted_belt_test.go create mode 100644 internal/seniordev/app/offline_catalog_test.go create mode 100644 internal/seniordev/tool/bash_background_test.go diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index a37fc552c..ee126b6ee 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -5,7 +5,7 @@ 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 <brief>` 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." + - "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 provider-key environment variable is inherited by its engine, which receives a short-lived loopback token; model-written commands inherit neither. 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 <model>` 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." @@ -39,5 +39,15 @@ invalidates: --- `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. +is its specification in Go. senior-dev reads models.dev for its catalog when available; +an offline machine with no cache uses conservative model limits and still runs. + +## Fix round + +- C1: Provider-key environment variables, configured custom-service key variables and the loopback token are stripped from senior-dev's model-written shell; its commands share the chat bash's tmux isolation. +- C2: Background commands, including Linux processes detached with setsid, are killed when a run ends, stops or its engine crashes. +- C3: A child speaking another record protocol version is refused before any of its steps enter the trajectory. +- C4: An unavailable models.dev catalog no longer refuses a run; conservative model limits keep the loopback model API usable. +- C5: A hosted senior-dev run no longer offers `question` when nobody can answer it. +- C6: The manual now names senior-dev's default web fetch, opt-in Exa and Parallel search, models.dev request, and macOS detached-process limit. +- C7: Python unittest projects without pytest now run unittest discovery; projects that have or declare pytest keep it. diff --git a/internal/delegate/child_processes_test.go b/internal/delegate/child_processes_test.go new file mode 100644 index 000000000..d474ac2d0 --- /dev/null +++ b/internal/delegate/child_processes_test.go @@ -0,0 +1,122 @@ +//go:build linux + +package delegate_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +func TestRunEndsItsBashBackgroundProcesses(t *testing.T) { + if mode := os.Getenv("FC_PROCESS_CHILD"); mode != "" { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) + defer stop() + if err := os.WriteFile(os.Getenv("FC_PROCESS_PIDFILE")+".engine", []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { + t.Fatal(err) + } + registry := tool.New(os.Getenv("FC_PROCESS_WORKSPACE")) + command := os.Getenv("FC_PROCESS_COMMAND") + " sleep 300 >/dev/null 2>&1 & echo $! > " + os.Getenv("FC_PROCESS_PIDFILE") + input, _ := json.Marshal(map[string]any{"command": command}) + if _, err := registry.Execute(ctx, steploop.ToolCall{ID: "background", Name: "bash", Input: input}); err != nil { + t.Fatal(err) + } + if mode == "stop" { + <-ctx.Done() + } else if mode == "crash" { + time.Sleep(5 * time.Minute) + } + os.Stdout.WriteString("{\"type\":\"hello\",\"protocol\":2,\"delegate\":\"fake\"}\n") + os.Stdout.WriteString("{\"type\":\"terminal\",\"status\":\"pass\"}\n") + return + } + for _, command := range []string{"", "setsid"} { + for _, mode := range []string{"done", "stop", "crash"} { + name := strings.TrimSpace(command + " " + mode) + t.Run(name, func(t *testing.T) { + workspace := t.TempDir() + pidFile := filepath.Join(t.TempDir(), "pid") + t.Setenv("FC_PROCESS_CHILD", mode) + t.Setenv("FC_PROCESS_WORKSPACE", workspace) + t.Setenv("FC_PROCESS_COMMAND", command) + t.Setenv("FC_PROCESS_PIDFILE", pidFile) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ended := make(chan error, 1) + go func() { + _, err := delegate.Run(ctx, delegate.Launch{ + Name: "fake", Bin: self, Args: []string{"-test.run=^TestRunEndsItsBashBackgroundProcesses$"}, + Env: delegate.ChildEnv(delegate.ModelAPI{}), Dir: workspace, Grace: time.Second, + }, nil) + ended <- err + }() + var pid int + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + data, readErr := os.ReadFile(pidFile) + if readErr == nil { + pid, _ = strconv.Atoi(strings.TrimSpace(string(data))) + break + } + time.Sleep(10 * time.Millisecond) + } + if pid <= 0 { + cancel() + <-ended + t.Fatal("background process never started") + } + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + if mode == "stop" { + cancel() + } else if mode == "crash" { + // The child records its own PID separately below in the helper. + data, readErr := os.ReadFile(pidFile + ".engine") + if readErr != nil { + t.Fatal(readErr) + } + enginePID, _ := strconv.Atoi(strings.TrimSpace(string(data))) + _ = syscall.Kill(enginePID, syscall.SIGKILL) + } + select { + case err := <-ended: + if mode == "done" && err != nil || mode == "stop" && !errors.Is(err, context.Canceled) { + t.Fatalf("run ended: %v", err) + } + case <-time.After(4 * time.Second): + t.Fatal("run did not end") + } + for until := time.Now().Add(time.Second); time.Now().Before(until) && processStillRunning(pid); { + time.Sleep(10 * time.Millisecond) + } + if processStillRunning(pid) { + t.Fatalf("background process %d outlived %s", pid, mode) + } + }) + } + } +} + +func processStillRunning(pid int) bool { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return false + } + fields := strings.Fields(string(data)) + return len(fields) > 2 && fields[2] != "Z" +} diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go index 559b17334..0fbea8004 100644 --- a/internal/delegate/launch.go +++ b/internal/delegate/launch.go @@ -9,6 +9,8 @@ package delegate import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "io" @@ -101,7 +103,18 @@ var ErrNoTerminal = errors.New("the program exited without a terminal record") // 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 + // A run marker survives a plain background job and a setsid escape, so a + // Linux host can find both after the engine exits or crashes. + markerBytes := make([]byte, 16) + if _, err := rand.Read(markerBytes); err != nil { + return Result{ExitCode: -1}, fmt.Errorf("mark %s's descendants: %w", launch.Name, err) + } + marker := hex.EncodeToString(markerBytes) + baseEnv := launch.Env + if baseEnv == nil { + baseEnv = os.Environ() + } + cmd.Env = append(append([]string(nil), baseEnv...), processgroup.RunMarkerEnv+"="+marker) cmd.Dir = launch.Dir cmd.Stdin = nil processgroup.Configure(cmd) @@ -163,6 +176,10 @@ func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { } } result.Elapsed = time.Since(started) + // The engine's own group does not contain bash commands, which each start + // their own group, or a command that called setsid. Clean those descendants + // before draining stdout or releasing the folder, including on a crash. + processgroup.CleanupRun(marker) if waitErr == nil { result.ExitCode = 0 } else { diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index c12435533..4df492469 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -178,9 +178,8 @@ 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 ask you anything.** Nobody is at its keyboard, so the `question` tool is +absent from the model's tools. 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 @@ -239,8 +238,12 @@ and codeaf enforces both from outside whatever it does. On a service that report 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 +**It reaches a model only through codeaf.** Its engine receives a short-lived token for +codeaf's loopback model API, but its model-written shell commands inherit neither that +token nor provider-key environment variables codeaf recognizes. Those commands can +still read files their process can read, including a profile stored on disk. 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`) @@ -257,6 +260,26 @@ not a git repository). 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 senior-dev use the internet — webfetch, websearch, models.dev, network off + +Yes. Its `webfetch` tool can fetch URLs by default. Web search through Exa and Parallel +is opt-in: set `SENIOR_DEV_ENABLE_EXA=1` or `SENIOR_DEV_ENABLE_PARALLEL=1` before +starting codeaf. `SENIOR_DEV_NET=off` withholds `webfetch` and `websearch` for that run. +Senior-dev also requests model sizes and capabilities from models.dev when its cached +catalog is absent or stale. If that site cannot be reached, the run uses conservative +model limits and still calls models through codeaf's loopback API. + +## What happens to background commands after senior-dev ends — stop and detached processes + +codeaf ends background processes that senior-dev's shell started when the run ends or +you stop it. On macOS, a process that detaches itself may outlive the run. + +## How does senior-dev run Python tests — pytest, unittest, missing pytest + +For a Python project with test files, senior-dev uses `python3 -m pytest` when pytest is +installed or the project declares it. Otherwise it runs `python3 -m unittest discover`, +so a project using Python's standard library tests does not fail for lack of pytest. + ## 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 @@ -513,9 +536,10 @@ with kimi-k2.6", or several: "with kimi-k2.6 and deepseek-v4-pro" — and senior 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 +name, rather than swapped for another. When a catalog was loaded, a model it cannot size cannot be used: the run ends before its first call with `senior-dev cannot work with <model>: …`, and nothing -is spent. The models are fixed when the run starts; changing the crew later does not move +is spent. If models.dev is unavailable and there is no cache, conservative limits let +the run start. 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 diff --git a/internal/manual/senior_dev_reach_test.go b/internal/manual/senior_dev_reach_test.go new file mode 100644 index 000000000..c27836e58 --- /dev/null +++ b/internal/manual/senior_dev_reach_test.go @@ -0,0 +1,21 @@ +package manual + +import ( + "strings" + "testing" +) + +func TestSeniorDevInternetQuestionReachesItsOwnAnswer(t *testing.T) { + for _, section := range Chat().Search("can senior-dev use the internet?", DefaultResults) { + if section.Page != "senior-dev" || !strings.Contains(strings.ToLower(section.Title), "internet") { + continue + } + for _, fact := range []string{"webfetch", "SENIOR_DEV_ENABLE_EXA", "SENIOR_DEV_ENABLE_PARALLEL", "models.dev"} { + if !strings.Contains(section.Body, fact) { + t.Errorf("internet answer missing %q", fact) + } + } + return + } + t.Fatal("the internet question did not reach senior-dev's internet answer") +} diff --git a/internal/processgroup/run_descendants_linux.go b/internal/processgroup/run_descendants_linux.go new file mode 100644 index 000000000..8f86fd5a0 --- /dev/null +++ b/internal/processgroup/run_descendants_linux.go @@ -0,0 +1,79 @@ +//go:build linux + +package processgroup + +import ( + "bytes" + "os" + "path/filepath" + "strconv" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const RunMarkerEnv = "CODEAF_DELEGATE_RUN" + +// EnableSubreaper keeps an orphaned descendant with codeaf if its engine +// exits first, so this run can kill and reap it before the folder is released. +func EnableSubreaper() { _ = unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) } + +// CleanupRun kills processes that inherited the launch's private marker. +// Unlike a process-group signal, this also reaches a setsid grandchild after +// the engine has exited or crashed. +func CleanupRun(marker string) { + // A setsid wrapper can fork just as the engine exits. A short settle + // window lets its final exec inherit the marker before declaring it gone. + empty := 0 + seen := make(map[int]struct{}) + for pass := 0; pass < 20; pass++ { + pids := runProcesses(marker) + if len(pids) == 0 { + empty++ + } else { + empty = 0 + } + for _, pid := range pids { + seen[pid] = struct{}{} + _ = syscall.Kill(pid, syscall.SIGKILL) + } + for pid := range seen { + var status syscall.WaitStatus + waited, err := syscall.Wait4(pid, &status, syscall.WNOHANG, nil) + if waited == pid || err == syscall.ECHILD { + delete(seen, pid) + } + } + if empty >= 5 && len(seen) == 0 { + return + } + time.Sleep(10 * time.Millisecond) + } +} + +func runProcesses(marker string) []int { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil + } + want := []byte(RunMarkerEnv + "=" + marker) + pids := make([]int, 0) + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil || pid == os.Getpid() { + continue + } + environ, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "environ")) + if err != nil { + continue + } + for _, variable := range bytes.Split(environ, []byte{0}) { + if bytes.Equal(variable, want) { + pids = append(pids, pid) + break + } + } + } + return pids +} diff --git a/internal/processgroup/run_descendants_other.go b/internal/processgroup/run_descendants_other.go new file mode 100644 index 000000000..3963e763f --- /dev/null +++ b/internal/processgroup/run_descendants_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package processgroup + +const RunMarkerEnv = "CODEAF_DELEGATE_RUN" + +func EnableSubreaper() {} + +func CleanupRun(string) {} diff --git a/internal/seniordev/app/engine_client.go b/internal/seniordev/app/engine_client.go index 0146b8494..3832f3910 100644 --- a/internal/seniordev/app/engine_client.go +++ b/internal/seniordev/app/engine_client.go @@ -158,6 +158,16 @@ func (models seniorDevModels) projection( } func (models seniorDevModels) catalogModel(providerID, modelID string) (calc.Model, error) { + if models.backend.catalog != nil && len(models.backend.catalog) == 0 { + // Unknown metadata must leave enough room for the baked prompt while + // limiting each request conservatively. codeaf's model API still prices + // actual usage; these zero prices never enter its ledger. + return calc.Model{ + Cost: &calc.ModelCost{Cache: &calc.CacheCost{}}, + Limit: calc.ModelLimit{Context: 16_384, Output: 2_048}, + Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true}, + }, nil + } if models.backend.catalog != nil { metadata, err := models.backend.catalog.Resolve(providerID, modelID) if err == nil { diff --git a/internal/seniordev/app/hosted_belt_test.go b/internal/seniordev/app/hosted_belt_test.go new file mode 100644 index 000000000..c52d2ae36 --- /dev/null +++ b/internal/seniordev/app/hosted_belt_test.go @@ -0,0 +1,16 @@ +//go:build !windows + +package app + +import ( + "slices" + "testing" +) + +func TestHostedRunDoesNotOfferUnanswerableQuestion(t *testing.T) { + runtime := newRuntime(t.TempDir(), nil) + t.Cleanup(runtime.Close) + if slices.Contains(runtime.registry.IDs(), "question") { + t.Fatal("hosted run offered question without anyone to answer it") + } +} diff --git a/internal/seniordev/app/offline_catalog_test.go b/internal/seniordev/app/offline_catalog_test.go new file mode 100644 index 000000000..4dce0ec0d --- /dev/null +++ b/internal/seniordev/app/offline_catalog_test.go @@ -0,0 +1,54 @@ +//go:build !windows + +package app + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +func TestUnreachableModelsDevDoesNotRefuseCatalog(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("SENIOR_DEV_MODELS_URL", "http://127.0.0.1:1") + t.Setenv("SENIOR_DEV_MODELS_PATH", "") + t.Setenv("SENIOR_DEV_DISABLE_MODELS_FETCH", "") + catalog, err := loadCatalog(context.Background(), io.Discard) + if err != nil || catalog == nil { + t.Fatalf("offline catalog = %#v, %v", catalog, err) + } +} + +func TestUnreachableModelsDevStillCallsLoopbackModelAPI(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("SENIOR_DEV_MODELS_URL", "http://127.0.0.1:1") + t.Setenv("SENIOR_DEV_MODELS_PATH", "") + t.Setenv("SENIOR_DEV_DISABLE_MODELS_FETCH", "") + catalog, err := loadCatalog(context.Background(), io.Discard) + if err != nil { + t.Fatal(err) + } + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer run-token" { + t.Errorf("model API authorization = %q", request.Header.Get("Authorization")) + } + calls++ + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(writer, chatReply("done", 10)) + })) + defer server.Close() + backend := newModelAPIBackend(delegate.ModelAPI{BaseURL: server.URL, Token: "run-token"}, "") + backend.catalog = catalog + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", ProviderID: "openrouter", ModelID: "vendor/offline-model", + Prompt: "answer", Workspace: t.TempDir(), AgentMarkdown: testAgentPrompt, + }) + if err != nil || result.Text != "done" || calls != 1 { + t.Fatalf("loopback call: result=%+v calls=%d error=%v", result, calls, err) + } +} diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go index eeafe6d29..9436ce6f7 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -14,6 +14,8 @@ import ( "github.com/Agent-Field/codeaf/internal/buildinfo" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/processgroup" "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" @@ -93,6 +95,12 @@ func Run(ctx context.Context, host delegate.Host, options Options, notes io.Writ // 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 marker := env.Get(processgroup.RunMarkerEnv); marker != "" { + // The engine owns its orphaned shell descendants while it is alive; + // the host repeats cleanup if this process crashes before this defer. + processgroup.EnableSubreaper() + defer processgroup.CleanupRun(marker) + } if notes == nil { notes = io.Discard } @@ -150,6 +158,9 @@ func runWith(ctx context.Context, host delegate.Host, options Options, notes io. client.catalog = catalog model = client known := func(ref string) bool { + if len(catalog) == 0 { + return true + } providerID, modelID := normalizeModelRef(splitModelID(ref)) if _, err := catalog.Resolve(providerID, modelID); err == nil { return true @@ -199,7 +210,11 @@ func loadCatalog(ctx context.Context, notes io.Writer) (modelsdev.Catalog, error } catalog, err := catalogClient.Get(ctx) if err != nil { - return nil, err + // The host still serves and meters every model call when the third-party + // catalog is offline. An empty catalog selects the conservative engine + // limits below; a catalog outage cannot refuse the whole run. + _, _ = fmt.Fprintf(notes, "[senior-dev] models.dev is unavailable; using conservative model limits: %v\n", err) + return modelsdev.Catalog{}, nil } catalogClient.StartRefresh(ctx, func(refreshErr error) { _, _ = fmt.Fprintf(notes, "[senior-dev] failed to fetch models.dev: %v\n", refreshErr) diff --git a/internal/seniordev/app/runtime.go b/internal/seniordev/app/runtime.go index 7ccf0b651..37bebb367 100644 --- a/internal/seniordev/app/runtime.go +++ b/internal/seniordev/app/runtime.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "net/http" - "os" "strings" "sync" "sync/atomic" @@ -207,13 +206,9 @@ func newConfiguredRuntime(workspace string, client backend, cfg *seniorDevConfig 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" - } + // A hosted run has no channel for answering questions, regardless of a + // configured client identity, so its belt never advertises that verb. + options.ClientIdentity = "hosted" runtime.question = question.NewService(runtime.bus, nil) options.Question = runtime.question runtime.registry = tool.NewWithOptions(workspace, options) @@ -416,6 +411,9 @@ func (runtime *runtimeAdapter) Close() { if runtime == nil { return } + if runtime.registry != nil { + runtime.registry.CloseShellProcesses() + } if runtime.unsubscribeQuestionAutoReject != nil { runtime.unsubscribeQuestionAutoReject() } @@ -506,7 +504,7 @@ func (resolver poolResolver) values(tier baked.Tier) []string { // 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 +// IT INHERITS NO PROVIDER 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. diff --git a/internal/seniordev/app/runtime_test.go b/internal/seniordev/app/runtime_test.go index 2ecdc31df..c42495b13 100644 --- a/internal/seniordev/app/runtime_test.go +++ b/internal/seniordev/app/runtime_test.go @@ -62,7 +62,7 @@ func TestModelFilteringPreservesDisabledTools(t *testing.T) { got := requestToolNames(runtime.definitionsFor( "openrouter", "deepseek/deepseek-v4-pro", "coder", map[string]bool{"write": true}, )) - want := []string{"question", "bash", "read", "glob", "grep", "edit", "webfetch"} + want := []string{"bash", "read", "glob", "grep", "edit", "webfetch"} if !reflect.DeepEqual(got, want) { t.Fatalf("tools = %v, want %v", got, want) } diff --git a/internal/seniordev/baked/agents/coder.md b/internal/seniordev/baked/agents/coder.md index 83ee5f4e1..0192336ee 100644 --- a/internal/seniordev/baked/agents/coder.md +++ b/internal/seniordev/baked/agents/coder.md @@ -24,8 +24,8 @@ 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 run is unattended. There is no `question` tool because nobody is there to +answer it; make a reasonable choice and continue. The working tree you leave behind is the answer. diff --git a/internal/seniordev/tool/bash.go b/internal/seniordev/tool/bash.go index e0ef7538c..4ab8b571f 100644 --- a/internal/seniordev/tool/bash.go +++ b/internal/seniordev/tool/bash.go @@ -124,6 +124,10 @@ func (r *Registry) executeBash(ctx context.Context, call steploop.ToolCall) (ste command.Dir = cwd command.Env = shellEnvironment(call.SessionID) command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // A plain `sleep 300 &` leaves stdout open after bash exits. Without a + // pipe-close bound, exec.Wait waits for that background job and turns a + // successful shell command into a timeout before the run can clean it up. + command.WaitDelay = 100 * time.Millisecond var output bashOutput command.Stdout = &output command.Stderr = &output @@ -136,6 +140,8 @@ func (r *Registry) executeBash(ctx context.Context, call steploop.ToolCall) (ste if err := command.Start(); err != nil { return steploop.ToolResult{}, fmt.Errorf("start shell command: %w", err) } + r.registerShellGroup(command.Process.Pid) + defer r.pruneShellGroup(command.Process.Pid) done := make(chan error, 1) go func() { @@ -156,6 +162,11 @@ func (r *Registry) executeBash(ctx context.Context, call steploop.ToolCall) (ste <-done return steploop.ToolResult{}, ctx.Err() } + if errors.Is(runErr, exec.ErrWaitDelay) { + // The shell itself succeeded; only a background child held its output + // pipe open past the bounded drain after the shell exited. + runErr = nil + } var exitCode *int if runErr == nil && !expired { @@ -260,6 +271,38 @@ func killProcessGroup(pid int) { _ = syscall.Kill(-pid, syscall.SIGKILL) } +func (r *Registry) registerShellGroup(pid int) { + processes := r.shellProcesses + processes.mu.Lock() + defer processes.mu.Unlock() + if processes.closed { + killProcessGroup(pid) + return + } + processes.groups[pid] = struct{}{} +} + +func (r *Registry) pruneShellGroup(pid int) { + processes := r.shellProcesses + processes.mu.Lock() + defer processes.mu.Unlock() + if err := syscall.Kill(-pid, 0); err == syscall.ESRCH { + delete(processes.groups, pid) + } +} + +// CloseShellProcesses ends background jobs left in the process groups that +// the run's bash tool started before the run releases its workspace. +func (r *Registry) CloseShellProcesses() { + processes := r.shellProcesses + processes.mu.Lock() + defer processes.mu.Unlock() + processes.closed = true + for pid := range processes.groups { + killProcessGroup(pid) + } +} + func appendOutputLine(output *bashOutput, line string) { output.mu.Lock() defer output.mu.Unlock() diff --git a/internal/seniordev/tool/bash_background_test.go b/internal/seniordev/tool/bash_background_test.go new file mode 100644 index 000000000..183bd5c49 --- /dev/null +++ b/internal/seniordev/tool/bash_background_test.go @@ -0,0 +1,62 @@ +//go:build linux + +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func TestPlainBackgroundBashReturnsBeforeItsChildAndIsClosedWithRun(t *testing.T) { + registry := New(t.TempDir()) + defer registry.CloseShellProcesses() + pidFile := filepath.Join(t.TempDir(), "pid") + input, _ := json.Marshal(map[string]any{ + "command": "sleep 300 & echo $! > " + pidFile, + "timeout_ms": 500, + }) + start := time.Now() + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "background", Name: "bash", Input: input, + }) + if err != nil || strings.Contains(result.Output, "timed out") || time.Since(start) > time.Second { + t.Fatalf("background bash waited for its child: output=%q error=%v", result.Output, err) + } + raw, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + if err := syscall.Kill(pid, 0); err != nil { + t.Fatalf("background process ended before the run: %v", err) + } + registry.CloseShellProcesses() + for deadline := time.Now().Add(time.Second); time.Now().Before(deadline) && shellProcessRunning(pid); { + time.Sleep(10 * time.Millisecond) + } + if shellProcessRunning(pid) { + t.Fatalf("background process %d survived the run", pid) + } +} + +func shellProcessRunning(pid int) bool { + raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return false + } + fields := strings.Fields(string(raw)) + return len(fields) > 2 && fields[2] != "Z" +} diff --git a/internal/seniordev/tool/registry.go b/internal/seniordev/tool/registry.go index 36fdfd9cd..6e81d3ba7 100644 --- a/internal/seniordev/tool/registry.go +++ b/internal/seniordev/tool/registry.go @@ -145,6 +145,7 @@ type Registry struct { allowExternal bool confineWrites bool hardConfineShell bool + shellProcesses *shellProcessRegistry question *question.Service questionEnabled bool questionRejects *questionRejectionState @@ -159,6 +160,14 @@ type questionRejectionState struct { counts map[string]int } +// All shallow workspace views of a run's registry share its shell groups, so +// closing the run reaches commands started through any of those views. +type shellProcessRegistry struct { + mu sync.Mutex + groups map[int]struct{} + closed bool +} + type instructionRegistry struct { mu sync.Mutex services map[string]*instruction.Service @@ -280,7 +289,8 @@ func NewWithOptions(workDir string, options RegistryOptions) *Registry { confineWrites: options.ConfineWrites, hardConfineShell: options.HardConfineShellPaths, question: questionService, - questionEnabled: clientIdentity == "app" || clientIdentity == "cli" || clientIdentity == "desktop" || env.Enabled("SENIOR_DEV_ENABLE_QUESTION_TOOL"), + shellProcesses: &shellProcessRegistry{groups: make(map[int]struct{})}, + questionEnabled: clientIdentity != "hosted" && (clientIdentity == "app" || clientIdentity == "cli" || clientIdentity == "desktop" || env.Enabled("SENIOR_DEV_ENABLE_QUESTION_TOOL")), questionRejects: &questionRejectionState{counts: map[string]int{}}, submitFreeze: options.SubmitFreeze, formatters: newFormatterServices(), From b96d3311f677beda50de7b3e865c668832ea6216 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:08:55 -0400 Subject: [PATCH 160/195] session: propose_task offers via only where a program can run propose_task's schema always carried the via field, even in a build that carries no program (Windows, whose builtin registry is empty), so the model had a verb with nothing behind it. The field and the prompt's words about it now follow the one registry the programs come from: absent when it is empty, unchanged when it is not. Review of #1488, lane 4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/session/delegate_door_test.go | 42 ++++++++++++++++++++++++++ internal/session/task.go | 15 +++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go index 612c59407..27797c3ff 100644 --- a/internal/session/delegate_door_test.go +++ b/internal/session/delegate_door_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" "github.com/Agent-Field/codeaf/internal/plandb" ) @@ -387,6 +388,47 @@ func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { } } +// The tool and the prompt read the same launch registry. An empty build has no +// program the model can name; a build carrying one offers via in its real tool. +func TestProposeTaskOffersViaOnlyWhenTheLaunchCarriesAProgram(t *testing.T) { + for _, tc := range []struct { + name string + programs []delegate.Delegate + inTask bool + }{ + {name: "none"}, + {name: "one", programs: testPrograms("senior-dev")}, + {name: "built-in registry", programs: builtin.All()}, + {name: "task node", programs: testPrograms("senior-dev"), inTask: true}, + } { + t.Run(tc.name, func(t *testing.T) { + config := Config{Workspace: t.TempDir(), Delegates: tc.programs, InTask: tc.inTask} + if tc.inTask { + config.tasker = graphForShape(t) + config.taskID = 1 + config.taskDepth = 1 + } + agent := &Agent{config: config} + tools := agent.taskTools() + if len(tools) != 1 || tools[0].Name != "propose_task" { + t.Fatalf("task tools = %+v", tools) + } + _, via := schemaProperties(t, tools[0].Schema)["via"] + wantVia := len(tc.programs) > 0 && !tc.inTask + if via != wantVia { + t.Fatalf("via present = %v, programs = %d: %s", via, len(tc.programs), tools[0].Schema) + } + page := promptWithBeltFacts(config) + if got := strings.Contains(page, "`propose_task`'s `via`"); got != wantVia { + t.Fatalf("prompt names via = %v, programs = %d", got, len(tc.programs)) + } + if !wantVia && strings.Contains(page, "senior-dev") { + t.Fatal("a launch with no program told the model about senior-dev") + } + }) + } +} + // CODEAF PREFERS A PROGRAM FOR THE WORK IT IS FOR, AND USES ONE WHEN ASKED. // The paragraph said a large task "can" go to a program, and the model took a // permission for no reason to: the owner's call of 2026-09-24 is that it diff --git a/internal/session/task.go b/internal/session/task.go index ebebb4b99..bfe49be7f 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -188,6 +188,8 @@ var taskDescription = "Hand self-contained work to a task outside this conversat // moment the field is being filled, in as few bytes as say it. The page stays // the rule's home: on the lean belt this schema is fetched on demand, and the // page is all that is read before the model decides to propose at all. +const taskViaSchemaJSON = `"via":{"type":"string","description":"A program your instructions list, to do the whole task alone in ground (or this conversation's folder): set it for work one is for, and when the person names one"},` + var taskSchemaJSON = `{"type":"object","properties":{` + `"title":{"type":"string","description":"One line naming the work as a person would say it"},` + `"summary":{"type":"string","description":"Two or three lines the person reads to decide whether to redirect it"},` + @@ -201,11 +203,16 @@ var taskSchemaJSON = `{"type":"object","properties":{` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + - `"via":{"type":"string","description":"A program your instructions list, to do the whole task alone in ground (or this conversation's folder): set it for work one is for, and when the person names one"},` + + taskViaSchemaJSON + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + `"no_progress":{"type":"integer","description":"Optional. Tool calls in a row that may add nothing before it is stopped as stuck (default ` + strconv.Itoa(taskNoProgress) + `). Raise it for work that must read a great deal first"}` + `},"required":["title","summary","brief","deliverable","acceptance"],"additionalProperties":false}` +// A build with no program has no usable via argument. Keep the full schema's +// bytes unchanged where the launch registry carries one, and omit this field +// on the same Config.mayDelegate predicate that controls the prompt. +var taskSchemaWithoutViaJSON = strings.Replace(taskSchemaJSON, taskViaSchemaJSON, "", 1) + // taskArguments is the wire form. type taskArguments struct { Title string `json:"title"` @@ -543,7 +550,11 @@ func (a *Agent) taskTools() []bare.Tool { if !a.mayProposeTask() { return nil } - return []bare.Tool{bare.StagedTool("propose_task", taskDescription, json.RawMessage(taskSchemaJSON), a.stageTask)} + schema := taskSchemaWithoutViaJSON + if a.config.mayDelegate() { + schema = taskSchemaJSON + } + return []bare.Tool{bare.StagedTool("propose_task", taskDescription, json.RawMessage(schema), a.stageTask)} } // mayProposeTask says whether propose_task belongs on this agent's belt: always From a55f8ab771afdf30ccbaeafda500a09507fc7c37 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:08:56 -0400 Subject: [PATCH 161/195] tui3: /senior-dev on a dirty checkout refuses without promising a copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /senior-dev reused /task's door notes, so on a repository with uncommitted changes it first printed "your unsaved edits go with it · your own copy is untouched" and then refused. senior-dev works in the folder itself and makes no copy. The program door now shows only its refusal; /task's notes are unchanged. Review of #1488, lane 4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/tui3/delegate_test.go | 56 ++++++++++++++++++++++++++++++++++ internal/tui3/taskcommand.go | 25 +++++++-------- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/internal/tui3/delegate_test.go b/internal/tui3/delegate_test.go index 448b99836..739a01c0e 100644 --- a/internal/tui3/delegate_test.go +++ b/internal/tui3/delegate_test.go @@ -2,6 +2,10 @@ package tui3 import ( "context" + "errors" + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -151,3 +155,55 @@ func TestAHostedSurfaceInstallsTheFarMachinesDelegateRows(t *testing.T) { t.Fatalf("StartDelegate was asked %v", fake.started) } } + +// A PROGRAM WORKS IN THE PERSON'S FOLDER. Its dirty-checkout refusal must not +// be preceded by /task's promise that unsaved edits travel into a copy; /task +// still makes that copy and keeps its existing note. +func TestDirtyProgramCommandRefusesWithoutPromisingACopyAndTaskKeepsItsNote(t *testing.T) { + repo := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + git("init", "-q") + git("config", "user.name", "Test") + git("config", "user.email", "test@example.test") + file := filepath.Join(repo, "README.md") + if err := os.WriteFile(file, []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + git("add", "README.md") + git("commit", "-qm", "first") + if err := os.WriteFile(file, []byte("unfinished\n"), 0o600); err != nil { + t.Fatal(err) + } + + program, fake := newDelegateApp(t, session.DelegateRow{Name: "senior-dev", Description: "a program"}) + program.workspace = repo + fake.fail = errors.New(repo + " has changes that are not committed (README.md); commit or stash them, then ask again") + cmd := program.slash("/senior-dev finish the feature") + if cmd == nil { + t.Fatal("/senior-dev did not open its command door") + } + if msg := settleDoor(t, program, cmd); msg != nil { + _, _ = program.Update(msg) + } + programNotes := strings.Join(noteTexts(program), "\n") + if !strings.Contains(programNotes, "has changes that are not committed") { + t.Fatalf("the refusal did not reach the person: %q", programNotes) + } + if strings.Contains(programNotes, "copy") || strings.Contains(programNotes, "unsaved edits go with it") { + t.Fatalf("the program promised a copy before refusing: %q", programNotes) + } + + ordinary := newTestApp(&taskCommandFake{Agent: &fakeAgent{model: "m"}}) + ordinary.workspace = repo + _, _ = ordinary.Update(taskMsg(ordinary.slash("/task finish the feature"))) + wantNotes := []string{session.UnsavedEditsNote(repo), "single task 7 started · named work"} + if got := noteTexts(ordinary); len(got) != len(wantNotes) || got[0] != wantNotes[0] || got[1] != wantNotes[1] { + t.Fatalf("/task's notes = %q, want %q", got, wantNotes) + } +} diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 3c0ec962a..58361d1b3 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -134,16 +134,10 @@ func (a *app) startTaskDoor(door taskCommandAgent, brief string, solo bool) tea. }) } -// startTaskDoorVia is [app.startTaskDoor] with the door itself handed in: the -// notes said before the spend and the start message are the same whichever -// door opens — the conversation's own worker or a delegate (delegate.go) — and -// two copies of the preflight would be two places for one line to drift. -// taskDoorNotes says the two lines every task door says before the spend — who -// else is in these files, and what unsaved edits are about to travel — and -// answers which conversation is speaking, read HERE rather than when the answer -// lands ([app.adoptTypedBrief] is where that matters). It is one function -// because the conversation's own door and a delegate's (delegate.go) say the -// same two lines, and two copies would be two places for one line to drift. +// taskDoorNotes says who else is in these files before either task door opens, +// and answers which conversation is speaking before the asynchronous answer +// lands. A program works in the folder itself, so only the ordinary task door +// adds the separate note about edits travelling into its copy. func (a *app) taskDoorNotes(brief string) string { // WHICH CONVERSATION IS SAYING THIS, read HERE rather than when the answer // lands: the door is opened on a goroutine and the window may have moved on @@ -166,7 +160,14 @@ func (a *app) taskDoorNotes(brief string) string { a.note(line) } } - // AND WHAT THIS PERSON'S OWN CHECKOUT IS ABOUT TO SEND. The task works in a + return conv +} + +// taskCopyDoorNotes adds the ordinary task's copy note after the shared +// preflight. A program's door does not call this: it has no copy to describe. +func (a *app) taskCopyDoorNotes(brief string) string { + conv := a.taskDoorNotes(brief) + // WHAT THIS PERSON'S OWN CHECKOUT IS ABOUT TO SEND. The task works in a // copy of the folder AS IT STANDS (internal/session's groundladder.go), so // half-finished edits go with the work — which is what almost everybody // wants and is worth one line for the person who was in the middle of @@ -184,7 +185,7 @@ func (a *app) taskDoorNotes(brief string) string { func (a *app) startTaskDoorVia(brief string, start func(context.Context) (uint64, string, string, error)) tea.Cmd { ctx := a.ctx - conv := a.taskDoorNotes(brief) + conv := a.taskCopyDoorNotes(brief) return func() tea.Msg { id, title, note, err := start(ctx) return taskStartedMsg{ From 339dcffaf9b3e7874d3faad43a8dea49f63d86b5 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:08:56 -0400 Subject: [PATCH 162/195] manual: quote the stale guest footer as the page draws it, and say where via is offered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tasks.md said a stale page opened from another window reads "reading"; the footer the code draws is "current status unavailable — showing the last known state". A test now reads the page against the TUI's one constant. The program pages also say that via exists only in a build that carries a program, and that a dirty checkout is refused with no copy made. Review of #1488, lane 4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 3 +++ internal/manual/chat/delegates.md | 7 ++++--- internal/manual/chat/senior-dev.md | 3 +++ internal/manual/chat/tasks.md | 3 ++- internal/tui3/taskowner_manual_test.go | 20 ++++++++++++++++++++ 5 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 internal/tui3/taskowner_manual_test.go diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index ee126b6ee..d9a2b098e 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -30,6 +30,9 @@ invalidates: - "`codeaf senior-dev [flags] <brief>` 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 typed `/senior-dev` on a dirty checkout printed `/task`'s note that unsaved edits would travel into a copy before refusing the run. It now shows the dirty-checkout refusal without a copy note; `/task` still says what its copy includes." + - "`propose_task` offered `via` even when the launch carried no program. Its schema now omits `via` whenever the program registry is unavailable to that door, matching the prompt's existing absence of program guidance." + - "The chat manual said a stale task page opened from another window showed a footer reading `reading`. It now quotes the footer the page draws: `current status unavailable — showing the last known state`." - "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." - "A restore after submission or a failed suite could erase later edits and new files; it now copies each differing file outside the project before restoring and names the rescue folder in the ending." - "An eager write followed HEAD onto the person's branch, and the moved-HEAD ending denied existing task commits; eager commits now require the run's branch and the ending names committed and uncommitted work truthfully." diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index f2ba7bdc3..4de719487 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -40,8 +40,8 @@ Type its name as a command, then the brief: /<name> 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. +That starts a task with the program as its worker. 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 @@ -219,7 +219,8 @@ most 70 seconds, so the call it was cut in is in those figures too. 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. +absent there rather than failing every time. With no program available, the model is not +told about one and `propose_task` does not offer `via`. 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/senior-dev.md b/internal/manual/chat/senior-dev.md index 4df492469..969641279 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -443,6 +443,9 @@ again`. senior-dev's own `.senior-dev/` does not count. A checkout in the middle merge, a rebase, a cherry-pick or a revert is refused the same way: `<folder> is in the middle of a merge; finish it or abort it, then ask again`. +Typing `/senior-dev <brief>` in that checkout shows the refusal without `/task`'s note +about unsaved edits travelling into a copy: senior-dev makes no copy. + 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. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 98f123d28..99c173d1c 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5421,7 +5421,8 @@ A task page opened from another conversation keeps a separate draft, identified conversation and task. Its words do not replace your conversation draft or a local task with the same number. The page is a reading view: sending, steering, and stopping belong to the conversation that owns the work. If its status connection closes, the footer says -`reading` and the page keeps the last known state with an explanation. +`current status unavailable — showing the last known state` and the page keeps its last +known state. A reading view continues checking its owner after the task finishes. If that conversation opens something else, the page explains that it is showing its last diff --git a/internal/tui3/taskowner_manual_test.go b/internal/tui3/taskowner_manual_test.go new file mode 100644 index 000000000..1cbb1b194 --- /dev/null +++ b/internal/tui3/taskowner_manual_test.go @@ -0,0 +1,20 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/manual" +) + +// The manual quotes the stale guest footer from the surface's one string, so a +// changed footer cannot silently leave another window's task page misdescribed. +func TestTheManualQuotesTheStaleGuestFooter(t *testing.T) { + page, ok := manual.Chat().Page("tasks") + if !ok { + t.Fatal("the chat manual has no tasks page") + } + if !strings.Contains(page, "footer says\n`"+roomGuestStaleWord+"`") { + t.Fatalf("the manual does not quote the stale footer %q", roomGuestStaleWord) + } +} From d8f8d3d31737012c2b40ad2835a573b833f64a6e Mon Sep 17 00:00:00 2001 From: agentfield-bot <agentfield-bot@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:24:55 -0400 Subject: [PATCH 163/195] README: DeepSWE benchmark in place of "coming soon" The Benchmarks section now carries the ten-harness DeepSWE comparison: the chart, a table of solved, cost per task, cost per solved issue and time, the limits of a one-seed run, and the V4.1 Flash and Kimi K3 runs. The per-harness numbers move into docs/benchmarks/deepswe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7ShhY74oyWjYGB3SougdE --- README.md | 49 ++++++++++++++++++++++----- assets/readme/benchmark-deepswe.webp | Bin 0 -> 308434 bytes docs/benchmarks/deepswe/README.md | 38 +++++++++++++++++++++ docs/benchmarks/deepswe/arms.csv | 11 ++++++ 4 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 assets/readme/benchmark-deepswe.webp create mode 100644 docs/benchmarks/deepswe/README.md create mode 100644 docs/benchmarks/deepswe/arms.csv diff --git a/README.md b/README.md index dee128d01..9d962dec3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ 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. +On DeepSWE its developer subharness solved the most issues of ten harnesses on +the same open model, 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). @@ -133,19 +136,49 @@ 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. +<img src="assets/readme/benchmark-deepswe.webp" alt="First on DeepSWE: senior-dev, CodeAF's developer subharness, solved the most tasks (54.9%) at the lowest cost per solved task (1x). Every other harness solved less and paid more per solve: mini-swe-agent 1.9x, codex 2.1x, pi 2.4x, claude-code 3.4x, omp, kilo and opencode about 4.5x, muse-code 11.3x, deepseek-harness 26.6x." width="100%"> + +`/senior-dev`, CodeAF's developer subharness, against nine other coding harnesses +on the full DeepSWE set: 113 real GitHub issues, one +attempt each, the same model (DeepSeek V4 Flash through OpenRouter), graded by the +official verifiers. + +| 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. + +Read it with its limits. One seed per harness, so the gap to mini-swe-agent is +not statistically resolved. senior-dev sent the provider's default sampling; the +other nine sent temperature 1.0 and top-p 0.95. Five tasks in four other harnesses +produced no verifier result and count as unsolved. Cost is billed OpenRouter +spend divided by 113. + +Since then, on the same 113 tasks: 88 solved (77.9%, 95% CI 69.1% to 85.1%) with +DeepSeek V4.1 Flash, and 78 (69.0%) with Kimi K3. Those runs are senior-dev +alone, not a comparison. + +Per-harness numbers: [docs/benchmarks/deepswe](docs/benchmarks/deepswe/). +Earlier single-repository comparisons: [BENCHMARKS.md](BENCHMARKS.md). ## The right model for each call diff --git a/assets/readme/benchmark-deepswe.webp b/assets/readme/benchmark-deepswe.webp new file mode 100644 index 0000000000000000000000000000000000000000..047e106739444e899e591a1561e4bb09105042c2 GIT binary patch literal 308434 zcmV(vK<d9zNk&G>v;+WGMM6+kP&gpIv;+W9j}V;!Dqsl(1U@ksibNtIp&}vDIS4=o z32A89n_Yhu|MNpXhJWXpcl_U9=fC~(){UR8<wyVj8$bX3T3Rdrlb8P*AOE#OM<+ED zB>TPB<zBuq#p*h<`yNm4Xh8N5lne6}%|XPy3jg@>sr=ROP5(jqoBox#e8Ka7<-D`` ztK;`nzR=FUrmx$5wEXJ-`|P*(xAyOFKi5z6y>L9m{>Fd1{^{>$=XvS@{NwuPt}m{S zpa<)ht&ik~#dr2k`CqtR)Bd0z|NXZ4?fpmpxA;%UFWH}*pHcr;{IBEZ_n-S7vi`5` z+5La)-}q1bAL#z@f4%>s!xzW@#s8D@3-`z5uj^l>KO6pA{I~hP?_al{fWPMd%lUc! zBljEIU*CVx|Fi!$_LKZK>~GjF<=@gjzyE;$CH|ZHkM0lGmb83xc)$C<{Qot+0zJR` z2l?OeKjlB`|BU$0{*U^<n19`W{e0j2cm1#V-<e<KKly$Ee?9)i{(t-D`JekA;6MNW z@%&HspYyZSv-h9*|L=dZ{JZ-C{+0db{%81)^WU~V|NsB~L;dvsz5ikRaqsW<fBIke z|JeVq|MC9Y|Ns8~(68s;+P~p{h5teSr~L>2|Nh^7|Np<)|JC`g`?df7{crt$mA~4L z|KIWiVW^c%-r+1cDqu(GlRAF*5fgw2#!Aoksr+;D)Sz{Dw92Sg_hfL+Hw28bZQpbo z6A6(O#?YYN!S2Ww49tDO=j)CI#Yw`q>*)G<Str*lZ@!ynNj5wJ*moN=1xpO9EX6!+ zJkurqxGXk}C&s`$aty+MN4}4SWyd!tWSuOvP~i{uJAc`x>0NB}2x|9m>bEKJ!XTN6 z<B56SxRh@%|2fcm;yrz-qdp7W#?9p{0J|+nl&oB?OiYoxof;IF=hhoh>KLt{QidWX z)y6Peu>2-=9gB5^2Nt{Qq`y&73?S<dWJ&glW|WU#uZuo#i``je<9G%#L=*;0I;}yh z!hdck>8eiNy`e11-DJjwQnwYh@7wHAZVYxabszR(oKEU{k~hd*X%pq&TD9^g(Cqf% z_Gfh1xBe^q{#U<70_K>%QI^g;OgJU>>!@W)FbVuN>VB(@Y8I?%ofqziHVRn32a$gy zd8c6N<<qNL%~Imh<j@Y0NG@ORms}?QvqxYYqL<%-Q9RoZf{FBM66&OQUfXh(*HZLN zJ1S>rEeWQ8uh7AR5-kHt(HnH3c@u&I+^JSFiTCgd>THH_YJJ10_6vl0K8>gXyf#Qk z+WdAxms`^lTPZ@}+`vO|>eRvcGq&2<;(Au{<}!kb7NHuhasNPfcDXf?b<H5Rr0afZ zAnQ0n;@`49D+F5-t8hFs5@^{4#m^EMP`lhM$?X1F?<7V`Snz+j{){Q5<eB{IUV?+z z)yX>`)M7%MJe(OjrG=3cj$=xQEUpJKzxQw$dn5jBhonoOX8XYQw9lRpbfSHUq*(J& zktB3Lag+odHykx&6agdG--?0AIoi=|Xs?>y9ggP&qlUG}32A4NOq`fKtB{Fxf4#mB zKi3oe!L!QT#&`=55nK~a;%zBcsx%<Et_%^TQi#V|6RQOnPvhIP#^oQO5)hpWf_XVJ zMf&lJvtc3vi=i^ne^P-s4_;osNX07r+?+FXlD$Mg%UF~vkdWYXVpBwb9Mm=`?n{rA zwQV<Z>FZ5gn*ZTo<n7~_ECyVAQ7`s4E!Jc3CN|Hg#Sy>FX{FEgWzm%j)M<j_zIX;r zq4li9YXa~Ul)^G$3QMXMq4jaIqLo||cI3pyP40oV$t~*253Ny&?!%R7zY0dygNVCn z4KgI_QlxTBE+rdlz*HL4=w~m7rNMRDau_R&WIc~P+s%OP{%DU9O(>0AZ@hxj9$2%b znxKT%QJ%dUW)7y;ZCEA|VX92s-z&wRfFc2O4^>%^9}C?rsk^C|hPa${x)vV5$JC+H z(p=~7)XN2DO*LPr(kKi(q05Tt?#Sf!$z$zpyJ1}0JuJ>DxV>w4lWzp21~O)!u&>>N z#FhuaW}{)@UrPpKfF1?&e<EKWS9}m3VT3be_*9fr`6q9bMNTHEs2_edAQO@zJCzLJ zQE)v`d}2>Ln!aV+Gro$t&!65`U+q;7^Ns%F+>zii_ENWt%GRSA#x7RDjqiu>{&KVE z`XMJnILcUecYMsrgkK`Cb?bUlQk3u!6_J{5wHFvVBKiv%qeuB<4k&Yv^;zC@SyBD7 zZsQG$=9Q&jF2q6&lo5@F;Nu9=(|Gp&&~w4q9;#XkaF_3EZe&!jjBVKu8Ntd%)VuV> zXsj&cVLQE}4lDf#0v-FwFPtC^q*)yDh(Eh#6odnIkA<_bQIhFHUsTHnYZzt86b<VK z=?<;pw&+xJh8F)f&QV)K2jTX;P$Kz!@aeZK_hzr7;=AK-(Cfh*KS?;Ir`lb%Ww|!@ zWWq;!xz7_Uqei6yx<xbmwhD2B0m>TXW0fs$Ad?@M{Q64k!zbN5xy^u#y_*zAsOS@B zhR8j(0R6Kl;O0$(=eIVK1led76bLAJmLNsi;Wm}P(TeUzUF?N^%Xn9_Ox$ZctEjkZ zWe*@-5V`dJ>ZxKn2U+f0k4Ke37%^Lr1U8rR>-i5|VCnQ0qPTE7q!7A%T1LrGyg<(N z#Go*r-7u0?uYXq_<C`0=@6Xxa@@Xx`q`iE^Z^!^82}NnQ{he|;xNhq@dkI@~<NLY_ z%EjeSRvuaW<X+X5hU#b9ERW@|Ict4~k|YeNfHD|V@uRd`PPw=t)B!_1cy95<mv2Ou zn`=!^I&JKYP*t02BiA%wxxIjVdw{c+?4<>tLYrFicJF9r&}p_27psxhqcglZ_u8h7 zW?r(s+GR4<##+PNTc-raU4SlDG;O?S6x>u1D`j;Pph2<x(DYU8Vk4%75Co<vBovTu zxddI4@+X8jb-vZudbdWDa==Q+<NH3|@T#jgeC&B}9xfgb){N><1mA?v@K#iwa+s?N zC`-83MXoD_n+FR7(;k6<<&8pjocx7=o5t?WMXd#LXV{&1CNR(ApEP2PU(gbjnWCD) zvII_{zkXQ>I{n3}I@Md+ej^yBl*b1~*|+Y8L%IoKt*I-pR|%i~WCN~b3xqJEWs$A` z;kJWI`al0kMahXdy^d<iHur!3R*bj8<?Tb5`LE`kn5L&fvDgN~3Bd~jAM$rmq)!mj z&=c}wQ6AcSTOb^h02V59+Mf8SJXgk(Ii|yAq`8``Jx6|%sm5NvECVxEb~atiP&DAZ z*sU~`H^o(_&O1BaeBAXd>iDhbOxvV;u>3be>n?eW8^(#;)&C|CrWxDnsfv_q;2_zM zpy>h)?7HkR%3iWzQLkIE4MJr}+L}$}qd+i=z(!8Dm-|q2ud!=;)TWwkSuX%6;xsv7 zu>2LIZJ?3YlsJV@UqBjV?DW@Y*c0^v8T*iOuPXMyzqrHew(VwDrrh_P$vaf<s4@3Y z9F&g^JyH26StL_eB8av71$lL3@cxEFVh14&@;fQKifsRecxofgYer1VwkTP@F@nSg z*Y$n+N1BynQu}>T7^ffbDb(>MYcCX*GOYnkA9Z$cv^-TLaDV<b6;Ne`w{Hixa;3E$ zpC7j@;6&4xJJm54#P&TXqcw)%7#N23IpK4|!c|E$RcH4bFA4TbF1Ut)_6N)UIDFH@ z=&Jx>#HC#LkLHyk-aHE1Zr}>Nk%GZk)BJ9eqy}U8k5{Cq@Tv{7@I(8fB_XYrFW#kZ z{+}sfH)<+C2}H0)^6JZ0O-ooj5E}gU6x`gWjKySxnT%cK57J3^ckQvbJJQP6GQ6x0 zHA{8DDuM(k7Zg!IxU9=T;cAFy8{_jlL%%Jg&AU+!l`!OpsF*`E&_&Ip5ktzHPC@He zn{Z^${YgmefMWen_-bnz?62#*VU5R>tC_CF8ASnU;YG~$-4N{P@DJqSs2dQIt0Ua^ z*aJ=`RGAl5<aVi^1^%FV-M<!uSIp&gM;8oAh%P$;c{Iw50V=`V$5QFga?O#r^->!! zj%{%e!!<?Ma--n#1e<SsBNC2yoHNGSMOwRK%|zZLh$*;j9DB~5k{BM61hb3-hXlEp zlx7nv84h?`B$YL<LkrG_+sTfwCncXNdYR4RkGf4A0GIU>Tyg%B$o^xQ1pOyTiCD!y zJ@8L#G3<aoOhJ&~_}i<>Kxh>wS@Hg)`nrHDNd6Wpqr>~pCeL#2T?SY)5DT266pI6K z9DEeT6@#mHT{RMLe?<41<v7BPcR=DiDI+_!jc7)`)2T%(>lat4&pVUkS#h2Na8h3q z6;MF-$4p5C9=~UWIfeL%UHbu?&*~>5sVB2^n0(6`u)!JxOnQ6kk|e@zNmMJuwk$iA zYleN-u+Ft&?FS0L*b>*Tmx|i&j`m4^TiK|IVVId3Kmgcgp9C09O~HW}GRThtp4qws zwOrU$G20&?b2-LXZp>j=QOBD|oSTmSf-86Dj4J<@<NoOe%gy$uGrSQ{*Z#pop5Fey zajfc&tLp$f9ZyQ#s!v2{JYT;)*CgA*UtimLk>!z!P7f1e+4l`KZM}G0>8vW>!zDt? z3Vby0BW9v*|Il&Kbc(usrIU19iMb!Pzh`sOM1)U0Q===H)5<_ZFRqVx=^tItpT+4~ zrk*DTkxOOrN@VWHx^AlU;!Zz%43XX8lwKVgbR7YpF+ys&-z?!%T?ukr{3u0sd3g&& zr9At3B>9k8Zrf`7dArf1w$N0MNBbvGtI*`w(Cz$=9VUqW3tvzPCR;`580&h{byw|Z z*+#5zz6>uiF$Z^6h~V}f60RIZ`_{JnyrK2f6;b;?v~o*jAZ=n5LOcg}uWT0o?Xzh= zj?mdO%Q7|%M<b!J)uT)D7sdBOz~w2VCHB%xcS*g}D|UL9QJw9?>z+7_qA!qRj6dj% zG6W^H#{`^1{GIC4L9uMX<G6DCD5#<C3F_sk^0gL|PB-pBeP#K?fQw@*q$CF9!KSFl zs3RMVn6{l(5u{632|BpB^Ouq7YRg*J(QOAI3o%k>Z9Nt_B9WoP`aAk&Qbb%{X=ZK` zJKI`->l`9I@b?PJp*--kI=U9o@UrwM&j^sx`A&=&rZXFf&>K>mkD6@XvSB}ZAbhD> zTjx)TVI2Lc{tu=gqS4PCTPw`5vj!N#%pYt}T2KlYkrQGJ^1M?q@h8;;Iq=(CiA>wS zIl6(?k)DH|u#$`|MkjA+7vn3o%S7+@yDQ>K+h?tVnaknB^2fikv$l_~L1{UM8G*>w zJnyNi*j|+q^>1GD$Xsud_gtDuu`mNd)V2<;8o(TLYz@J`PtD`P2fcRy-o{)d09^T? zIDq{J+QRmx+Q$?xRgI;9s`!)`Gz_|BN46eeKjd#o30v#5cy+yJX9o)@-X|2P{xui~ z5Y@@*x%zB~_bw%wKQ#8_13ET<lFD^jD`6wUFa!{R;r{_)bqSp~kI*D{P^!2#B^%1; zYynRLWT)1&6t6;Hc6~ZOCKKGawk2EW1%ZDAf9lxeZ|ocv9ow5P^)kK%ky+RL=zFt} zMdjcoZ%brGVBlqT{}Us{kWI4Ra6Ki`os-ltHxJ;C{pFm)OGW1~@m3$EiohQJh&?j{ zDAlABW6pvT$OghNBF)F+9vBGSbdQ9>I5)X!Rj}S)Myd*tiLtnV{NK0n65L)L*0`2j zCMVGpO0kwKR`0Cu1ad=x^9eX}S8~TQZBRk+8j{IW`LF=KP(t`Y%Je4ZR41UzNWU#P zW#~GRh*4#>w>5%da$cOIr6o_H8rw%R(*4Wihjfo*ndaF~kL8F~`HgD_L~b}(>4~GW z^)By&ieZf*GKvX`LfsP{tNwo<gphN0B+LHj!27^&SQ5VCFK#O~ug6{VYMz3@A~EL8 z;03lK^c+7$h%yP4z&_Qy9IjVcMlR&ia!c$$3COy=>co(zIhCooHqy}<K~HG3w(|G0 zbO<}#FVF-Qnzj^Y7cpzok2tz3+XhHkx@1D03H5uRZx~3Y;z42q=NH!XoP)8~bS~O} zQ|3|7T-v?#H@KyD?fSSu(h>2o)|$RdyHhgWcMsfN3YL!l6@R1vtmb{}qWd7U-6O5e zo{cXYogRAGUbV*5tZ)E-UjEegZX%p(jGI}#!A?m5(5|AK91BS+p2>*M8C8w{L}uNI zZTzTaK>9urWmxiFb7K6~a|2v~+9j6@&k=%@-8Xsxgmh7Y#*F?`st?z3WBP(PEvP;} zBP<U{V}rXBB6a^2FJVauCChMR4Gzr#11P6e1zv%6u?$VtUjc4%1orQ6X9AjgPCqVw ztsjz&ZQuR@H~08C{RJ{XQBhp((%yI}&(hIf%_-&|S$P^lLkeV~mfq-&AD*ZlgnA(j zEC4(*hb<dJlZ3N*)Vqn$zP%(yOku1kR|9s_EHm^fg-2kdv^D2cqPxA?SFJsYaN3bB z$2a|cet;&+#d7W%`5xFsj>E%%5^vjmRUZ$e=Vm*r<{{)~vL4%c;w%jGCp28|+WFkh zrTX--bG)q$#^)TZpa+r=e()W`>X{6F0ap)>At^gRCv<ooU3`91E}Zi>E7#7C*hYbv zBf_kgjndmB8%6@rNupnUC;YX`pE!kDUcwM4BpL~NJ>(tAhoE$%0Z<`DFh9q{2&|D` z9u$1^=Lev59U0vCMA-ZjD0#wEYAGy}>)DHlEy&^OUNnLm6z!E6pv)R9-;8AnZLNE1 zCY>~&N0k3KN}KBW&Hwkf`*x0hGp}@AIQvaLa0N7tPhuK6A)3R8hNqTuKF|VD+*R}7 zZ~sJrxwhtj;h4Nv13WCC2Sb?BwTf(30WgiiZ`r&4mLl|ul`VMBZ$8_VP7<1}eZKwR zl~SVO@9ik$vw=Ib=Xxw5DQDT^0mgLFK4S0yrza+w5m+j~|NaaX*qUZEV0stzsphj& zp>x+^OtAB#@KrEQ0ZP+RITZ0<$z?_~h|2-Rf$T=YM1fE*KxISf_T8V|+I(V|pWJ_N z4N}N=I@E&Lprv!1{ZY3XLr{<g;l~6bOAZ#M_j4c_t)RtzW_nLhO;RKdF<Hoo7jc9S z>IM;-P5bMG;E!iymOt%rG-6?H1~t4-aq=4h!|>Xd(42{^J`44`w1+Nlsr4P+&4?K_ z1?z31oKwcc{>MQ-{7|tFphp>1z@!96nS^W2x8hc)n>y5wYlFRZR39>m-L7yy5QFZs zyzpsLLTgwH55$cMCK1WpM{pJrbLRAjnkoJI<~>UAC;K*WNVir?--2fZ0)*z>TM~|T zgs!T_vSG^SJC^(aD3{H9bmc}7LFs3NrNkZ-Z;UTO_OZ+!qrz|I=!S3@`<g>4uX}!g z2g<;FyPndNP{P*)h=6B^lHprPvVbf!&icq&f^6>89%ek#?H~bd(LxVPW)|@=fhKYW z5;<oTA14fA?W-3E9YG;wcKkz-9YM(?6xhEujbr_SMa@3H+isY#>%m>PaE`Z`Xav0G zkBCFQGsN{huuf{>LXL*P`QLzt2SZX4L9!CX2N|`<R%#)|ZrU)7=>xy<6Ff-gpb}+| zLa|6C){KH>_%Htp4h($OsaVFd!!JJlbH<lU8mx}JpRJ8lhaOmZn`>V$(H8~_FKSZ; z*PFkXtCQfwIcamZ!zyW4S&1~c@7=ooX1qWb6l|tmB{PL_Y@Ifixe{g2wv4VoaUIMA zw@B%-PmHp4D$u-*j8jY)ak?QP^#LH5QqTTIiS_Im<D8#*SC2<&>4LJ_2;L6<BD>7T z4hY|)rJ`kk_pdihl1lC<^mvA%Dg0zdx=b(G*pF^Rvj~D2fT!@u5XYW>ivJD)B1zqx z&lER(;&lO_n#^9R^TTh?G(gL1+c-4+r|9K*8|d&TWr)Y;w07w{t+fgKA0+_E{RA|3 zg?6BuA@pXW)k$VR1uS3!ap(^+pP24@iac1lj#pdTk%=r1W0-`{OcLwlKDI!Q3kG9+ z`YRPF7&hYm#8q_&#AR0O<{!k*@FlLAwX;4Gh`&$~b2=ez+_EL0tm6_-8hp;#?)Hij z6_f;17Rw4Drmq3dNVo+pS9p5|?z)nFjAkz~&ENPew)-jrx4R~u?$IdURz=VU^yyb< z_|kB*7YC_8W*=zd6AP_py>}ku-l{GmbXfXzP{OlGTkI*4tLfwF5aPwNLF`)3ZTZnZ zzO&@_z?^e0NCed?E!i*uKZG?D;K_W&3G+n2$Q}>er6jK5m}e|3*S<GTEX=(E%(Sjr ze;_Lx{>g$cQR@tq<Jlw-^8g6vLR|(NlnEgW5Bprf*I(lfQP3imb<WR>Sns74+3Mk- zSNp2Vqyid)6!%+*nf#p$nq97JT?Y+Cx-B-`c`%5{+wM%MFxOYu6^3*YdQNrjo?>o+ z@^H5wh*q-H)B4*+hcRwaPrM2+g6XBw)Szv~kdmpIx<TAnh0qpjG%~L+^jY6;zH*>` zU{oq!_wRvYtIccKccd;YHzyy^cD#ZimDgv!C95Y_XH%gBd;Ht+5060K0WAnwLFalq z<&S3H>w<2OjVm*d-m*g<YLg3GUj8C0?nOH;fW`VNHk2U-EgAo%fRBQQNGUG2E-O~u z`Q~C!vNt3dls#qqNV>Ojo08AtA#W8ju7A;IK~vm<G5r{X-q*tD<pHB@@!LtIgZT|D z<D!OIxGlky-FlkCvlX#MC94@ey^3W}IgYm=mW1UrDUsSDsn{lBg(u*Uq;<q`F@h`j z?oi4tJ$x|y2$d3Z;)WDf*<M{G8Il0yXHWmJ8EIguLl)R6!Gw3D;?cbZ<?*KtG4_aJ z5JUSzp`^k99R2LbLOi=sy<PWUeX1XdXNhO8gTd}*S^t{ay%4w#IQ2DiuDj=+Z^fAX zneoYMn$W~A<+5qz1``+(vbNoJ=(K{^7}T8bo3GJwz+e~v10{PDD!~ptiXMw5sJl>L zvQvwsH{v^_%a5&UY)IN)t8g3X*)e#TO4#336P9N59rvt^x^X3#l2>VL;rdGqO*m>5 zr5ido5_JMJxH!?=DqfP{X6!!!+jmPz6E*w^9=T!u(vYR8*9?V$Nc<jx=4YXriz$;Z zvW}&UZ&s%n$=q>2{0gCLBcoc`lK%)CMcg>3B*Va#zU0Df*aUEdVJY`2z650=)9DqL zzO)a`uZPD8OpZyT@1RNscZ9D*p|H!YnNEDB4f&rNGk85zJ`s^+J0e-sRqguy$LFr# zMpwMq^7Cm=k1p;z>696XTD|O0N__rmt!@^3h;$Z-$bCLt%3*i#$=E?6+!xLTn9_Y> zytviLLh7_C1RGGneXrpDzgsP7$iJCCQT$on#pULf-UZ#4I*+)7JW)#3YZ_TH(D=d$ z^rh)?gZpHqcf7l?E*IPji!58exc|uMN~$nWCA#pABW`ui6JHVU9x^A((d|O}Llt$r zG=e9@H{6oFBB*ZXa^=})_JKv?J34cFN#byuXX^6!@s6(7d#u?KaKR`Z!hOt{s!32y z^sySPyAEb;^}iB))(jKBy$#~dCH$Q2!5xmi@I5wzx;nrgBmb-@kTr$3?+Puw>Z8&> zcaj=-xO^dW?TN>OFV`ec6$xFiCGxU0haPc*W=`UjK+j|%Y%y@X0<r>*fKOQJj5c*2 zUZFUr=dhWTGry{p!*Q~$3-r{ZigGfM@uT|DE=vRN5p3V_5N|vmAEgBNPb^rG(|}%X z-J2OxiG;|y3u^lG{Zu4|Bv#8|8D3&C-f9c(tMrHS$|y9va{zv=HlEY(Q(t>E06v;! zbqS|0X6AgD7~+(dTTD}_b}V@u7ette?klCoWO7ud(}_fB_uN{0sf<U6mRlgyRa-5J z10<fK<35JveeE{<^CDshP+N${7b#BhVSUb^$Y$}v%a$X(IQH3Fa4=+i=xK|Q%cS}^ zDSrjfgf|m($I<i=P^H}yI%y2=u9!Zcmi1DyghZf(ET6|#G#MolNxlQUI&=|ozlsDG z89Jvbg3(yTG4q@;#`osnbiAGC=bgud0s77k!)~jw(ogQbWn(9E-`j$>?`To*P<+)| zT-x&I^)JN2(DDe%AI-L7MQV)y8`c;*_|>iL_DI|0fM;}zaDYCc*8R>03-+pjric^R z>xymVvfO5l!qJNS*}a7%^1%>`m4_0^?fJ=T*e@vjV0C~svRYWo`U6Ob7|%t72D(&V z3~b|-2oy_mxY={bX{o~RBhQbJzv>+RZg-Fe)YU?GW-pc`FT>0OTg|p{cLqnc%TcJO z|2D+>(q=VeaP~AR5^n`{IR%wcC(lOw**OCb0fYhz(GL~tG0oF^@5a=pdZ3`l_L8CK zX5o4{WrOdyZ6fGH{ny_Tp)vJ4wN=8E0zTmz(!kFSMqi5`RzINI*56|}QJ32q)3^Ct zKXNV<1Jv?WkEcrU%1*Y{A6C#wt!Og9b@TMcE*Ew^C(-wF)V`$f<nle8R(G;Smww0I z!D!{#0)K)yN8)YNvW>V1J_A(KU|Va44-Drx$a5)0g7aeu^0BedU|LXQP^`jU>s~W& zr&jEbj|<phK+ie2`@Ra9oU(!&d*k_>S3bBFzz~tD_qDElEXO@8l-$7?mmrifhgmli zPkr+QfF<~v?x%Ey$Y0}T5W(00p7)(48|kE<`LLYOa;;W<I*P{14T4wQS6yJRh~Emd zn6Vw){VK-JOwm;P90SX;@e@6v2NI9f%2}DZoy=3X&VXjDvOoHk!9|z{2s8-%rA~<L zjmc@yj7r^0d3gKy3o1OUuv~u|HY2U|wPg1<b4*NLHMdX8b3aXqa-26}_M=vI*vA%Y zFIf5+=H?(@_HwrfmuHvEc41*@5uUQbatkcD&Y3&<dSXSi91KRWm=M1n>BNVTzgey> z5drIe?M=}DuIAEkZ&$-_wPDqYX~xlkV~s2+lS3}wohWMGa+%FQXvlVXxisUJFj!@l z2~J|f^eNn>HNX2>iLVp@?f_oM=reGXXMn?Gza<3zdSY5Xc4=?tvEwLswM02aEsIQR zhYek4kQwsaFYvFoJYL<y8rFsVN$oDAp5HSg{<3m4<7NzYu*ru&=w?<{WfB#C2a>Mz zmN0v$_JunA!f~1_222&;9*+-9dQUyiCy1LD3GU;?>$(~>dZW!3;y>jgX%+|OeYnPd zC~iY?PlquBbKF=elb02I#`2h5Wj{$5#4CI%zQRl#`*kRPwmnkN+#$j<Vn0>8h6z>6 z^xfQgQ-UEq8`LdIUr5H&JG-$Kh=UFozO2kNxu*N6c6coYWgDRzGtTs`A0~uCCs$PB z_8wnE@8fi$u?`lE&wcLf-ufz`()c_UlGk!jdRX$8>#~wq0d8I7{D%|okPxn)wqMQc z1<8;1haqR=mXZjz+f`EZf7dW;j?(eq%Ekjq=4y*7><2LjlU0s<c0ZOyFo~pl6@D`H z6_eUYuO<Yhx`lyZQ6}CJ8_8YY#+g9@i<h!;OBZ|o$Rd_ngt+&C^o#nV@Zw;?uuVpO z7AoOs+mWV=ppsdzqi^<utq2#<)Yt4Wig2l9l{s(E&-9E9`l^dDD?&m0xKd6L^S@rG z!vgxRJu9B!pic^F`Lyjur^@EQ@3H36YGTBvLKyWMM|AkVyy5B(DmaZj?oNSWvQynh z9|-GcROyw~07nUb7aXJMwE=2On<&IW$V=k?VIOmEx2rN0O0ybDF^;&U)#UVVgBAj; za&w&7kb$;Gy9$)mzKLNmD2Kfk*7$`Z7?<}c<9sKqtJf2(gyd-$&xhy$EwpFjmCxg1 zPtjM$tBM)Lf7gQ8la5T0lG|R+vbU!W3X0p{<CN2T-AtyNY+ES_j$O}b4!QoSs^@_A z!1>0nAy*iem&fA2G`VKXO+&s?KP1NZLg#w_*(8r4^uh(xpXr4|QlQ|^<3_rKCTEG& z2!YK?p%{R%Kc;*)Idv>%4RAe4ob(a8Q}cld#&V3uWx-S`mAfd{l&#F$NKIf*eimN- zxQ_4=Nlv#wJ6E(Xyf*%LEKP?B5%C)Kol;p<Dap+<rxKl`d(ma}$cvucK1s>qS44x> zx6gR-itP4x0TS@RxAT0#)pkNuYf%zz<92oniXh_7UCZT<%b}t+{mqimO9+P<0wrU} zjI<u%S$e7nP)35RQWtMp*Ga%zoNZbU_m!dSGhj&+AGn&9hxyH!2QQ=$*#x7@5;suC zE{U@r?de6WI#ByM=6fAR)aQpci1gp95m9Tky}&Fqwbrk~9HOy9x#_9ayP0aQJdRtQ z=!}>`P6Vef%^D<CX147($7vAbwA%#I&L-R4#UBG`A-NHN!`&gIA=a|dr(M`mUH$um zNopz0bWG$%*qPUhdc}QwvNs()M)u&LLxf;cSMAs#D6;TKAtCfgnV&Tn2;Y2{zB~e# zi?KCq#s)!N5NFSwNl|lJB7uMLhk-pBRTSWyjVOPEy)BJTY7u}7zE0{v4MCpH=OSD6 zAi0s?tu51?K^H><yG`K3)1JJg!LkTE#Rd+zL%K)7p*+bA#6@}xYOhUaI<>w5V|wvK z5PqEUUL~^$nc4pDXCt`H&T+R_N-@@Nw$IeKy{*30q+K4M6sE*c!^9en?Hu(8$6_1< z*!=yQc2Od(Kb9(v0Xwl#APXE#-1FnuWUyl{&`9HWCQP(moDi;D2V}d$B<w9wBrso9 z!h^Hf-JsWL>vY~Lkx_ad5186j9^7|52v9~K#Q$`mSy=+32(`EVT?i9+iG{sCP&uJH zYLbCv_ewfYFNX){!tCn=h$1*lW9g%XsJVGxt~DP5;oynj9CtjVf{#VI#*0(SHozMj zq8o#%nh8F`rCB+JcMCcgBbok0n$B{w9Q*w+!S<BfNPl2(kH<#uE&4HEJN)QOMcUjP zsF@uqa7O5fQ++kla@k?tisw&Ke9cR9acEyU_N2I&3qN;gDLQmsVd{Nt5}<=!u^;^Q zmf%COVG31#&?NY@oa%uBX7i0MT74BJKn0;evTNF{4L*ets*o_p)Zgg@*%EXU{dtm7 zHBd<X5U(q1ZDpJ=_4o0&Q`pgpJJ34NmjSqLO+_{P!_n*wSC5Yvg9`xhqBIxFRnjX+ zd*cDgg-i>nueWroQNnWpANCwJYP8BFMa0y+NClx~2LK2n-L2+Oa;j}}W%pp)-|!w| zq8Cdko;zEH2X$raBzsAZkN*I7-SfVCqvt=lxg?$#`G<Y#f*w+2eK6zWdlSl$|K!oX zm^=YElZK6n<&fUBTg<9VCsEb*D>xWex*rIJ&pr2EbRR)E#l}c71uD+H$4;xv9)Q6s z{x}MU!<PMfZtMo8Bd0`|h`}Zpps?4lak$&EOn<dt6!T1~YMarE3sV9H**oXDk#;>> zebenvG?DCR@R*tf-p_;OjNNox!D8^xx70)OUs?E|t+TZ^;mu?^W7#T0Fq%c(Vliu8 zU6Z`?sA($Oxd!-a!z&&VB7kBu9p-4U#L22De|L{YLTQSs9@by}6I9~N%sJu7f*l(p z+U!|ZlkQ2Y`DWeEKr_1-VM{mDDxHID{>g_3q7DGO3C0u5icE@^d*`8Lz~n7DpDzj2 zLea5ZJU8F~C-CGGKYaR}k!BO>2H0-k-xGH9u+2lUshL60?3Qz&S<OMtAswaq4P*~! z-r05fM6+%=!L?r?6X!eh7fx;hVa4AaXpi+QQ6G>mnuI<&)_EWEx6#Z<MIi6o7e$6{ zGq<WY<N=4Lk+DvOMm5ovr$#BCH)tAN7I!?QskGK3w;e98-&}L=EtsYrEd?-7ACJS{ z$sY1|oG4C#&CF1;SJuVJtV|1dTTcxLrwxklugPwVALIW7w_Bbc)Y`Vz9iB~XEe0(? zRN*ObZs)k;5v2JfR%1HO^;rQA%zAUIyEu!?zJqns%Nx-RRS@cObMh(81B|@gr_`wb zDd4J$;05wHKr!*kU+flMW|KY|7cKYv?4p%i)r7%nq2(BFh3%n^ZVwLvM|?ZZR%u#a z`HQ*<iP+4rrg8$lmh@jt7yXM(dGM6ML@_<KxJed$6cxVamv>KA@(US}4aVmT>ud32 z0wzrX0neXP=X0Z^{`3}qd(1XkKfo21!E3DoaV@wtuFdiaAtbF|sh_@4YXvI`g|dSb zCLW`|fEI*gl$x86V6Xd0M@jClax)jRbx%&t%p?Aqi7Ue#S#83LZUZBGNWHHzEC?CN zWv*)R4Xtm}w(VBq;}~s8pIVO<TG_!<lFvq20l(Y4uYzH*Jo}ofZ0QYj*cNuTJwxG^ zZ(@HNe>7_%FkcxLrH>D1jkikdH)Pf&V2+tt%|#G|WX#!luSHaNt_`vW8*By5Yvqu0 zfr&THB8j*GYUUahGV%qGrBl1B$+$7noAPt3;0o8NKe(i|R8hu|tJpI9<va;lV>Hed z1s?AE46n9_d&9n8_mm0rcx-cUQ}fU;oZK{_zr=+@k`dJqMbW1+k+-taQTqd?XF>C6 zK%7>e9^s)_n%-)aoacT@DT%juEnK%Z<FP4n`6a8rmSM@or}=3-o`2fDbv^1t^}@{A zos)z?_p@PsE6{6H_??vK@U(QpA0l?)>~4^f-f>*bkbQB5UCjf@440F>=ThL1s-Gvb zSt6j4hQ`J|(9;k>$ru39(Ww3L$fMyJ!vLl9v{>@z-GGjoc${gD{}~N{rGmuEZD_NM zbxw*RuCawNADbMKoz_8e_(EW179gQ;HYS^%6d>`Ru5ldxv%CAL1y&)|cV&`35v|KQ z{&o#xT&a!B>LW2s$9ZC-)-d2^=)O7Anrm9f_rdZK<aqa9*q2S_#%e4H>w#q@%#PW$ zjMI1X0_I8+(Q!9Dfn)TbvvZw>T%~!$(&#ql;&CbC#f5MIg;f?@L)L(?6iUjaO(7<l zG4E;K^zod=3Q)kvUbJdT=~gq?^`_!0lD<CLJ9vYWO*JJc3imfB@soAtS?qG3_zc%n zE}uZX!7)kQ@gn3@MxPC_S*_9#SX%hVsUDc4!7BD|scu|?6EeIdhZT8hiUHz#ubm1$ zM)Wfy^h&Z)J?5hSXus$tiu^2{h}?|0H9*zye^o@yZ9>0bv7!&~rh6O0lNmzv4qD_t zY9|MwVWJeNur3BIeSP;iKlIgHU1Ro_7oguo@>5T3-x+gR>#Bf?B!5x2E@eSS2u?G! z77FBR6aOESyq7o0@wZp6{%2h49`S#f<97J!^JBJ*1UaFGf{vZY6BGEDeuTW6io4TZ z=G!90k?HdkJjK-7v2x$rZrV%7Aq(l`QW?w?mPfe}KE|#tvT=mfT?La7STp=j{5d`g z;o;b3C#?_BA?|;}i^^slnW8`BqG)acYypGEGe~zCa25?|+S|6_wmz)IO#vV2yD^ML z{-@49NA*ogxn6>-aoUj?tZam}VXa+aM2j1*KI~siM6Z=D<5Gs(RnJLNYfkBVAY9<C z_D4lA_$0n5HClI>A-$%$^T$qWDDkO02=T{;V;)>N^|R4OLHl?BP9)MWR7YF|3XZ_4 z8LM*BwRciBu_!u`36M?R=>rT;19N8(TxK9MA+G2DiTOW2<HTte7c8tAZ&W*3p!o~- ze<VzJV_(FZ1zI!8!pK$%zOBOhT<c&A$2hjPzpegQUU%Fn+(78Anbn=n&b(Te*BCQ{ z2z{v~J`-pKX~4cIvI%{lrSBCHVdB*;hHA|Sl@nZgUK~>zwG7I2Vj$;fv&=i*J7b9= zOsJ|yRi=Q7g!~(|E|alqNM<XE=dk8Y3}5z8dyB1`nN)FUATvomd%aI7eck^eXhKO8 zkZT-j%`sJO0ZdZxLgNs3^Uu#$y-yEtpf;#r*}LMNy3-T3iDx%ZmWA)@za`}nbO`lo z&2XprF2&ycKr#ZJa<%fs)n68*8+ko)6Y1X-0)On|bHXMkD@;waKf`TE(Mbz7*^OnF z{?Om+fIDv*))$?c%CFGTGq|_n_;jt0MN-~>0W1m`>OPZO?*mL05KJLPvha+LVKPM8 z_+k||ypc>m(wmwoP`gx``;Fbhtj<g-j8xg3=40gf$fvsv^m2!7BD8$$dWMBv*1Hnq zdS-c?87|y7GXzJV1oVP}co-wu=p6RLMR;}~yIb5y<KaQ$lem5>)l))BEth@+wz)U# z>kC3vz4rK1r?e!fp54as<z13ldif+eW6?1bmojybg@Uw^j=vpy0c;a=gKtyN;ybz= z_EXVCW8j2nxjs<f7kQkODb{=9SXBh*XCST?+(N!yKR0H}B)8nwOXpG^DG!$e=c#p% z;{LXi`OTdYInw$;XlTf`X%q81-VPoJ*B&_;_nphV&UB&ZbECT3mi`R95xt*J%0-`L zTJ@IygX%bA;qmzw3mpO@Mmx!T*>$S9VpPBr>JLoyoA@JIr*$5X)Q2W9wYS5Vwp6oG zQW&_MQjnQ&VkQg&2ub4aTWkR8n7O18hE0lh=%x&f7(4=Z+px<8;6Gt`-s$#@)Cou? zNkWmg3SI>J^)YD~QPf4iDBhRl5fdHX1e4*O#E#pZdTrkRAsrUJc`>4*%=czT_yeJZ z?=QrxT!$%3@}^!U*AFFtNoQ#?qV++)0)lw+wo9zeZ6)Frl6kDc%vo>GY?Rxh-d8eL zo%9Uq2U$Yju6y8pn<N<@+x4~dJ5?<<lCvIAXqo>3_v>Kr%InEyutLtsfT8n)6g@h- z7(d_Y9)F)gMlSt-<H~ZzuC?e`hr2JRsLXAsC~s&#R6fx`gRUkH#tlN;?MtSGS5$U1 zdQ@G=N|D;+mPj-{?Nz|P@{<h-Kf9|p_@6~!y~WcUP)8s0PVR3Gl&px&4SbZP<{cE% z`W3)?`*YUyn7!#!H_Va~zUq!EtIdxGj&(*Q3)m;VE_)xyAwE_;?qYqHg1~UUtj2`1 zVrm0W7@if`>g|RT!MSNP(Ak1x)Bmj#A$1O1{JS`m(lD8BsH`xjCW({JI&ZCI_UkQq zH=s-xdpo&wm^Oe(>_1N#@MPO|nAzTv(^*VZ8$lM)<<xAN`S+>UxAQvAT?3GZFSw>Z zdr3ex43b^&l2zY|V0B`nT7|kcX3V_PEl#N{YlapkA$#z`D%8E~Q@54t1DT4;F<^Dc z)K$$`>F8Y;{9GVQ4&ZKRJ`)$vP&Uu+18Rvwv`1|UG4$a(2#<TNVME(L(GVsveIhIi zAs5?$Tn$CDAosV?`zBbgg&ig#rf^XzGO(Y>z;*%MD_3`|(|t~)^{?^BY<gl&L|v_Y zrO^`M?&e;kJn>$JNbx;(67PtWHTw*-{g|PVd9Pt*Bz50l+6#NnVjMR%(LxZ>j61|} zyp2--?}FS_{p4RdnS`)rkaeYFGCZlw^IDBYIJUSlq1~?LW3+GBNze!55mjZU3w$Kh zyd~TXh-+ZgY?<qrgr9s)L%g=Z@4DpqAmj^>cP`;J-dWwe;UpT${_7ixeu=yC)O-`R zT~AQi>=rG83y|Q$77Uym&S32mGE3inm+8paNzgEm8jJ51X&2P&of0D}z}7%tpJ07h zYQ@|GKSukPlcf~_;Fk1Mg<iD7_$T3vwUDDt_pcfBO5Uub#zmQa+-QY~Ihf_PK_2VS z*Erqk@TI7=h)>ep$MHV<3FMYWimU-FhF^gjf=`M=uE5|3PlPHES4FgI;=+{^w&Ic1 z!<5J+lmiFB7yrhIy9pRfPbDNM{m48<xojhR!DTvHD4R{pvd`oR{2P(-7#kD0eH%?5 z@#>033ETPP=E`7I90YqOO)70qslH0HY1&+-s#SIOuzIWlH}Xqp)LP>!R)Xa>2>y9n z458wjMit29xV*v(YTVZo$1l!Woict+d8gD*cMG|qY0Z_d3ZCnL_2!jqg0L4|vRg6K z_}ock+7BAsQus1{yjdUlLE!6Lw^F}WFd<l4nC|EkR8V6&=l)`I>b%|UV&6~<9Qb>s zLa8Q^LTpVg1s1+TzQaSlBML-o_^3Y+zNF)Cgb9QqHnLW-B)7yHN0P@>%QWXmHr+9f zj73?i58cZBJT%~Vlrx-Q?q`P|e$Q<WL0Ees$wm(;z>#|-VFXk}qQ{p<I%44<3>oqk z8L(uLzQtFFksdvf`f$_OK;kMV*XoP_Cy~3Gy}{BbAu!D&;K7{wo<HUD4`@dIi4%{k zN&XWBV->rUkr;j;!a5(l6ew8ZD1+N*s03b>iM62MMV1Nq?Z^f&cCkEj9fps&J|tgK z9tU3?FzEKUP5Rub{k(g}^p%>Kc%!_V#&+LJy?`BB|6+>#>T0e2W!<wU={c_`xy{^E zquFa1QlosUkNarI0PTn7jgwpgziDH=$4keEjW4xrv{P>H;jMeZd6fFJ;l(|57G`b= zv3|g}z|v8bOMT2%LK-7}T80#U+VHOhpfqTSs{~v*Y;-#4%yr`NWp5kZxC)dmT$Uit zK`kvU!hd~%*ZuRc+ev-S#$pt`|6l+ggF%^CUture(gDlq>oI&9xk7GqwD>h^?|09Q z)dEfQVd_{arY_?c)n&>d4M|ysQN-HC-KY&CKEu+z0f>oU)#v>$Gl+hnno*C%l!Yhb zdoY4rimDc|Gb{oM&MW6>_AlA73ltYjwQO3;b+P=Y{enTXa)POpI96!rHW1`olNp!N zP3AW=lHra8Z*@YL+g06n@G<$viB&k9nhJhHlkJwwH*WhvItI@caTtIBuql@QB0n5$ zuIBuSs7ggKhrDn^Yne(I089|%7{Z6tp_aOm7{7k;1=%-Z1qool^S_`Oo`A#g8-W;p zODq-sv)7$+>)1;2KS~J(sV|6RKX~&{Qu%i%XnZDbZs_HIDqfF>_C;(E;dK%QIke(n z)?)pWu1mSJ5DjzZ68_)g1fw6M5C2uj+`{?Q!H@q~Cc;nc!a8_KC^13KPbve+nf&Ox zZ$rfg!q}8PYamO7z@Lq(<&oNmjUsn`OeXgZPp<kFLVi6Ja`!n{#UA0Xl)^*3eApC) zF+sflIt#PPx{>pr!%u3__)>4z0w=W?i~L?Gf$6dFVvD~ku!cGXdS%tUTrVVnlsGGd z;55{Hy8ur3G%>i5ycK~7I4~NpmA#$q>DTY9VLTlnwH)k^8^4gQDvVZQW74H|&27l> ztC>;ypNuM4msR-A?@K*GM2At!YN3NU^2~_2nHNW6W=GfrUtdEj8*~JU;wfKCS!mfp zr%xWmm1PN`#?l^2g%imOiF?Hf8}@e6Z@(55oFPAShnmVlqd-6IFig3-`X|9o;gSC% z#-w#Lx=SX=EV8>7e^rXZ<C}<wwOQ|#THzMCdN55&4Bue!8EzBcg{1UIME5g;D@d1> zk<dJl_tF^1p1n<K+lVbZx3dZi`%2o3J`<1pbnlPnnVeyYQL}b)dL;V#)(!_=8(^J@ z*cCL1n_!-Wgg8~zs1+WtsL0+y6(F{isBOz;!oELV@UDU1+hf8@V|+P2O}|m&)gptj zdOmzb{j`x|I*K{a$=IgZv55~TO>np!W5@>CFumYU^`2l6L~|rWS6!-qE&HCL9q7qf zLiYJR436RLb=Tj$Fn6=zdR3}yU_}0%_Sp#4@>0Y4Z!xm&CrZIUd&<uGsk_N-3Ic!J z_CdLQ;CEM<`P6P0;!(y9aK5x>CK4m6IqSU(e-sak*PzOMwx=_Msyk?YguXT%W88QY z2RV?r3@BI+`(;?6>tcq!qzQw*1(lP4(<_DkNBKGE&xW#rkmT^|(~`X`-;GuZK~(aR zOJ1alVjET>5Rq$A2q}v1vuu?pZ(GA-Wxhpu;uy53&ajTYf<|w-ga|B2MPuSbwc=?d zFB`}ErBvV}X>RWQtN`<Ymu8xe7LV#C+GWTz3_~1s%&wse&;3$9^dK7`G3U&X4*Dml zD2H2l`~A}n6O!JMY_)bf{FZe~f2W=Kl0>BetGz_2_ZC|^C=()<z3A}%2ze5X?gCtI zEQ?RoE!8=$(4I&1;TTM--k5}iJI9-UvTOoG0sJ48U|$xPW{=XO$28p$5pVD*OHtP* znT>_~z=c_yvwvkr-N{-;3~nm8Fxp0Ri2Pct{vq+~@&W-LDNH;4P4;#lx};eO(?sro zzJyzR*|TlJ`Ikc}#io+I_pFIzm8x5d)~kai5_X>`6H$;&qL)LW!e+v|&=ZVkreRbZ zGjT=BZAE{*_MxBeV?|<MK-!Hc*bJZj$NG#OdO;W(*2a@U9>a6EcSyWfxG-zQsh45R z9C;Y;00VF_FVJ&ZcdV*yn0VXR$qZU)D}C(URqK6l5yAK4Rw6}#2dg}NKuMRztD<FA z<D;h$IHrQI8MnXkUy6k02Oy{-U-KxB_VE<R+`FFehaF_Y#5hS?y@V9G#pQ`SZY)+I zQQ0p^uus5Tp=$$hQ`9kcMb;&9nr8-f0R{3!PPtMq{z>QiX4hli#IJB(V6|Im?GBpv zx5Kirzxo|At9J&>_7`UUL@&&Sx^5nU2oA0LOb2yt*Pr)P{EV+MUs5b?;Cq;~e2*1k z$Jr#vn|4?rcS`y;hrK7hjBZ6%Y?DK_y%ar4s?{GQ4ZbOH=Ght6tVMSnEB|<PIc^@I z{QEiXy?7nI7d1k5XVANQi~`NvRgiM8xxWass?~>lorfLDEChZR&hy8v>XB&wiY{}Z zXAW@>UmGwnv>k*Rxyr&cK_BXYjuW*{CT@DUw3<i9@mU4@a<XeDIJYpKZj3|(vL<L~ z5XMMMJ&KKVkw$f|8mob>F#L>U((ihyMs>|i{XO$ZhL<Mvl4r}Wf8Wj+*`eFhy)6ng zvlYVdMbw)YZTMZdH}jbPB-D{=N8nFQf+YupCQjwA;SPj*Co<g04Ny}NI{sR9CUv;g z;9~BJeCfB&JVQmbElSe$t-<>UEew6*xdBW(8zr|%n9EF#jEa=}ep?v!KkUA<sIm=s zQOy3ihj0JlaAhFpj*pG4%`^~^pNNSN|Mas>6n6UG?hDEVnrLWaR|w@-`Rx9ibuuDO zfYPvqtHs#of%}seSSEA<&xx|bYny=+pSmv|O#2p~16atANJUt+vq_<sxMJd~w>;PO zY7xpnpz`5XtS^vMPK=KzXsOhHY+70@V@4}ki<B4adk|)%*jU=q0yD*yaYLbOG44~e z{LFYf;;0@%1i3Z1DTJDE*r^U5%8l$a2#D4d7-_Gh#OX*DXP>JFFs!4NnasY8db1EM z7!KK`N|`*l)i{H4Zf@jw($m(ZZuQ>QS}Ef9*7dievh?j&0nped4RNh=TJ3FbCPP7y ziuWf18S0Yv)IvifK{Cj&SfwU2IL^x3X$$CCC7ZME;^iFhQ~UDck2BYTTqKLXeJ~d{ z8LE3mrY}BT0IUaQ@9M&|pL2^y+dm0x3e&OW>y3gI?~~?_TncA6c89dx6lPwY%i$$Y zXEPuj9O7DV25Ci?n>T@3am=)zvmnns2c!tKO!4vZ3i)$50Ovz}T6i3QC(KopH^`k( z;j+j;j)X)_@^BR++RIQ#iKNFWyynxh(P#ExW0#y428ArEyfo-_i^={NkB5?-CV?Ue zVMBChNLhlr&zMjXYSG_lN<5AdMAiATZiro0s&pky*fk_9BDU9h8Z;)jIVoM6j2)Fq zM|&PuqSuvejZpsx(jS_Oiw^UA1hmKlrEw?ALXSLslNL>bEh!XnXKl$A`H7F7f&NpL zv{n7%nds^NEQ85+;bl9C-a~L}w31L;cdN-WV2)y@k~ylbENPdND&HUldL{vbw}f|# z?pJ{57irstjeSD(n(mt={og~*duWs`YR6UdLEdp;04G4$zwzs|2AxD%Djx<h6za%? zU;;<Q#B1-RcL>zRDbfF;P}Rz4a}PQrnZDN`_K<aebJ{^<jx;J=*CT1O^%XpIeEy`s z$?W`m@1i(43mNY>*M=%?4Q-d2E8la{kRDEGfjUWdzO;*%NOG7~4SIz)3jA0->9Op7 zIj{O+c_Kwh*J8iEs3{wueP2$+WwsRurd>E+yd}MtuccuQ_J3cWOb&J}rqP%b4K^wg zu{+Xx5v0B4CUVzq8Q*cQ+)h~mNYb0Z9cxXBUX2(^r)eS-m}^iLo}zot9^#NBfgnO# zCL~)oO2ukhdOF!%5DaX{n%6y*`gn3yC1Zil2J11AYkvhfj6Win41PA^6!P|&HV!~J zm>>tM>PYYI{5UA|D}{R#bg=P9kb&lhb}<*_8kpDw9r4@TE(E;szIK!GcNi;kpYlL& zPrzB2W#*UZ1f1!+ntwq%y*!vfia-2$SGHGR%JQ@yrk@tOKH!18QRoaQ|Ll~DVV$tN z+vhx<>sCKH@`od~cR{rUEL9gyODjwLI)#G|lY|aAV(7*gR@KYigP<*5PPGl-(C@HP z)!5Qw=Z{mT(FK5a?!)PekKXE;OjF8F$57?_G|i35t<9h|)42-NYGd%g{FJ=T90kV9 zQhDeQnLPq#MnU4;+YbGjAOOfFUfNwi<=ga7Q1#b2JZF|Cs1ONic?%N_YbysPzG>#; zBl=o$26rxq)8fT5%jcL6=IBTVxp|FF;8=5Ns6SVSLrkEN{qk>fwe3&8C);bmpU7Ec z8n32|SD!Z!BWXldIrpx07?#(=>P?<CM7Krz-eVOXWYY*`6Yp_4K0$6DUFLz}{mRG# zeP(wq0rneu5)`>M=6EL-rA{RTQ~NP;=lwcIw>w3g5la5jUm1F0O>LDhZ$9#9km6>X zb?54Kdr6xQo_^jWo%=rUVe`*7%=ABlAS5RiP(9hu#y+^${&hw~xV~z5L@P&<M#41+ z0<o6ifAtqWu`}$9V>Xy0z|%S1w^uHL*!P$$T{o=J-VoMvtYYG#S&1691L4weVV*Pk zVdDb}FxK|}GzyC57dVdr4!$g-*j^2vx@;%Ul<dWT8RgD_rJN%I`X9P7*lKc;)pyit zvs|kDLC-C|rjodqqp*p=W|Xii%qKsYvPDZKo-+xHpGo^Kbp7%~|0PSslEmgF<|f;C z)WbL!r7NZCcx0H*)?{?Wvih&c6tHE8IzM=@?`Wn4#*+ZS3i+WeA^*NN=^+lgBof;# z<a0H#EBh(UMBF_kW&pL_+H{!VmS63`ztdoVBV*AR_N))AG@ZYv1^_8E?l+4~zP#$k zrM-M6%BAcOMx5OIwSS|Xdx0K}zU`R`Dp#fhFFG>@8F}Nqy|b>Lz9BJgO@O711j+#Q zSvG8$@x}q_Cxhg42GL`%K#twjgiZ!(v~TQ7UNk%-c7fQj+TStb^Z`=XgjiXQ(Owfr z*pCctB9%4Ilduc&)O^s?FFIo~&fG-IE<-_!hGTIPH0Wdep)2QS4Dbz47F$@K)qr%m z0oV>0{8`!MNXU_shI)^zEhTa}TxPAk==VL6fh6$cR`t0F*)E=dSrTg2Segx1Vn1I^ zx6wIa_kT<6!Dx+By3XNY=1P)ko^T|;O#qh&T0MM#G=jZAX+j1LY*%LNo1mm<+7^B9 ziut*y(a)9Z=2=ao1Xo559n}b8Ux@!WUrwe*bRoH@oc#3N?0j|cHSn&=)J2=Q)p%LU zE!4l`)X9kBx8bluxfb<!zkut*c90ghkX7hL-@}<It<=GKfP8+QD&o9q;k4Fw*X9gT zh#<j1dZ@{Y*-6OLFW;>c1@z<d93SrVy%>wN9tXxArc&r-84I?41>Lyr$KiY;md^~2 z7R6XlHrwrJ42{>eLO}q|(UoI9`)~0-x>7f_vxc|RQTuIdVO_Q)P3EQ3tg>qLY@{6x zbyqK<UjX<T<yaIVbG7it{Gcq#2xiR;vTL}960Evv;;54)a|=d-%3O)=^fOMfs)p#0 z^KHSbv#FRK=BjQ~oGLa3b%Ay6<#>}3ds6{VgQ;CqJ-o(~iuiNJ=n1x#h*cNU5G#~* z1+*@G^OKEVEs87q{}PLiJrKWRqlMechheNwn1|$;3tb#eIju?oq<LwNT~p1GBV+C- zxnQk(x1P@-wYdY1EjFO-HQb;%z6LTMAa|1UI7-M6@V=2tU}$X$QNI|Jx%|!XC*h}_ z?_~!jo#xi<q7D@x_}!dkX?EG_G|2Wr(H>}FN?w#H!1*U{WIfJRYW7y|z;j<;-{Xt( zVlT_(0elHptsxpYwK1obD5|f2MM{>UqWT)!Me-!s%Dd4KQ7xjG8}ix)^iJO;Tok`t z?|lBI15HB&k)VLKhnWzs6}94!eI4aTBuKBgQnShOQptWL$?!E4LqQDu&Xu=%z<BaT zTAt2`IqnKJKDI(rMlI)Vua$*~AN5PS7B$C8OgZ&FM>8*CQXIHeB%7a3!}PLB6{Ps# zRF<gu(Mmrk{&HuC>W(H?G+xvNdft}NHg*qrPTB3Jzi)c<@N<Y&yQU0vg8VJ69>G60 zG{#!Vty~sA=Wwa0`(Ud0SEo*OprslRyVslQ*-g5*{aO3Ck3wRdFKW+zgH$~171{Y0 z^?A?Bkw4>>QQKLk6<2<j^<%<7fd?^LN)VksQ<oD2tPMbfEy#P1{a#Jtn$pCFmG~CV z7U#0{9*{CbHu6iqP1u&HA9}_qJ=W`K+7bq4Z0UpM##dP<a3fMl=aPH(5Q7J!9galT z(}mVx-O0pxqEUZv-9PX}LXEl%Gj09*zTrrurb!eGe5Z+`s&^K;X@+9(N7l)&i}0m{ z?oB1xjA}8W>=tJ@sA|p0@->2`9R4aG?=luC+*+B`cFx?#dAIqqwZ<GHO<^xZ$Gw$G z#~cac!n0NiWG3K5sG=rgc2Zeic)ZIjDLM1&-3eu_s8|>O6<dQ~&)M?7QnEis`!a9v z-z!R7aQ0XV;on2k<>tSK(=sbP;GT{W`O$WzR+#hAITD5X05nYW@AyWGfv4GZVqVJc z1YDT+5pioRa2DW;vVnYS15;f^J1%D*$(VRAJr0fngbejp+cb^Oi`d@|7z&2%rn64$ zKqGAQ&=A*<(SrE~lIOGW{XQ(J!ACijF1nfUFxcD?g_Dn5?Efgp(PMa?Q;ZBNf&4<U z$fs=L-wx{bJgVSHUtys922fjC7AnF(ApPQlv*7q(z2|Jp(_;Xt3@hf?CK}&!OJ0h) zE&l(6tTOGX$C6ZK5=8t_eNo8NVO0nSc~GMSqg_-Ze&z!jc)uEypB6Ul9GOwzo4ovk zRT!Blf78ifso@i_^GWwLD5eri-bP4qq_6m+cq5LYP&>s(W{NI6fh0qXTzk3^385^B zaMb*L%P!ogbZ}PaKm(8YLXT`-M>ZnB4NfI%?ke=rC(Oo2!x7A|6dX*OvsuyN;xoh= z)@&vh30|oH`T5LPUi{uOa;3MIs6cUR>1W%<#xgC2W)i&?s*ychG0`{saM6|N#2Fg- zK!kP}+DehKXdlTyvWD1y@Kkd%wJVfJzy(I8pkkx6HRf$%Mu;aRU~nUHk$9?~)zpbw z|LoO_5^6CaK$0Jyd0rk`>5beX0~J5DQdW<8u-u2*Lc7ydOcjfUaqV+7vtkz&=s2e~ zJcNPD!jI*!CH3bvuK>;TJ4PYbQJ|45%iWL=60}m-R}yTA%u=erDcvDs<onx~D^M39 zRWZ&B7<N1HBvRR(L__L;d^liT6<7@s6lE#YivAj^^83m$M>qg&2%*CeP*skiwYr;b zZbiw}PM`c81-mhZXq4b7$S&Sr2(mT|+^pMF_I%qkU)5#(_XP1%sWiD63kB2r7{y7b z`dE5bq!U-5wZEEuZ!V2MtO@bFIWDZGE&kwk{<?h>WGdZo-3uLe@hBr%j#O(hFgneY zkFGms8X}|`Qm}J*h>5wUjt;A;dz@$HNEv>SCZ)DV#~PLJ^f#->1Z>NZU>&{t=Ii-t zE@~Qab%N_I#25uc9l9b;R~sJx+2Xi1!MZI8+_wX`3ucnX9TS79u-W<s9;CJ%k@S2M zH)t$Km=(leXwKDg27pkZcYU8?+5c)bP2=YmJNafkE)j*@=S^<j$wXo7+#QIDmc+(} z2=P$eZj4sq{`9h|P{9pEp&(`UnVT5`*xgi0lk1Yh)tvr@Bh)GB&!Cko{xyiVOb@uA zX*>)BqS{>@CkMH<2656ycUuG~?L$ccCe^ck8TMElw?+?KCf<9|KTUpnqN?mhHNB(l z={Q36#BC6RH+k~Du)Zk9X(a<>_Zz>9<cxZuQb)cauYH+fw2moPnha{MIE~ZhM*2Y( zLnMw*AI@D>(p+gV+5sBLHnHAWD_XGOIBd6HIU)3#b(Qq9FdY`QYTsTp;|CTx1HWc7 z%gZ^Y5mZ}rN<UYYK4ex|pI9Hm0qop|FF5w*#-P16=$RGN#LFRJi4=pY{|wN1(A0D{ zb2eoK5do%EMcPV8MEAnXM>S+PE5O2|TZmJ}*^hurthas!{mK-b2h3D$56h{Nfc{x% zfYkewPFz*wd^g3Y;D2D0-FSrTVx6<Yr57q=F`{Bvm6NMEMG#FS@wl#Ne~5u1gI!w= zQHbOK6tGKho1-RTT&N8Bg2q56Q-`)j(xfG!x)1{aGEfk_KV$X@RuLIKe2FwoH2a!1 zv^FSGOZsD)M85N`JL}Oy2FQA&ho%~-PrzhJq8j08`~?=*-dM#F<ljz3m?cmk;FlHK zEhhYnA9(1TCzeG<F7J9C_ywEReBQy`GS|E_x3;<TR}+k0Oh3tY(O1u?1gpk+&}AmA zRDIQFLYJv4FaJB*)YG!DCa)?-k?J7Ai}skONw^N?>^k|0VRCnPR>8gb_vAEqla(E3 zFAppXC0(%$40myBqxMGKRJkayBghMz?)%;BUPXm%z?R|kXb!9A)3b~h`g1(&?|k^Y zH+n5XK5;t!16<Uth1Am_1N*Ta1jqpFWA;qC#2p&N!Vp(F_z)}llCX@O@={m{%#m0r zrRmTG91*#R$f+W(`Gxf~F=oq+7S6>Zw(t1LP|#oQkZwS6l&-<6CB2sIK`C(BclVc& zX{Uug?IhWJ;*bH>Nl0=zjlC2C`Be1a@u=UBIW(^|*vx7;W#O(ssR9JhbofFE;*_Bn ztA{oPR^N%_WWm{=X&}<4Stp(m#_xEYBp(*ob3G(B*1t++Tm-GZF#N_s!OurY*Sw6# z+E4Kzs#KumhpAEtTI(a$AmZl*+oKt!pbSG6(~`(z8RS^Y<S)3wja&?SO>ev^p}N;P zz5>>~%cZ8-I0P^bGs;u5b(i`)-`817tI?UV?H~&b2^zn9T?f$ATy&xcOWxM8S%_~@ z)Wa|<76400(?!-1dU014|Koc>jOMyH;}>Q%Ny1{E?`Pz;LEOcKA#rM3n8mH^X19MJ zre5imE&UyG;?Km2m)@6|=QtHS#$+?u6PH5EhMUe<w;5ULAwJ)eJKFxSvbo<*4_CU} zq!X-|e@Yg;QpCXsA=AR8v6)0&F2d}KMA<+O6!Qw7($FFNvFF>^OeP|$(zGIr{wcTo z9C)Xr49Ye-O-~|4H|vBb23z6DgAhIephg)Y>W92si8vLn`VEN~lN4L}93B2Ot5Y6q zE{WxVHy+gzwD*s5B1EaDIXiJZhEs1sF2%N)DW+kdk7S6ouI#pMd*J(8sM|I_R;6*Y za5~#(a^nt*1d>)xJ*0eZqslvNg(2*Rj#m$=6s14)(k(Q9K=HRk%Q&oAp@CgAP)J?F z{@ofR4eE=X7y@vWb$b`rufKEG_c*pZpeczV_<P}F2t?9H`%)$2Pz_NMvKW&!k0yB{ zai3@o?4)<f&$l`c-b?B=WWJvs)CwqmL#b}uGeD(p3ym`*34A<J?Iv#CIk)%*%`(<} zGHXsH;44b|{H5P0;7kzPU6-zm2`B1xZm}mk$mmL;b@p@fC9&j)$4|IPz4UM%+I6dq zx#eI5bz*_K)C3-z-^w)O=kJH1VMU8|c+LE2imhwQQP`0{f0sBiU)#u~RHhm?n)i~> zQNT2wFXcJo<Kp;WWi446VItESyDP0+nOA^RL+Y5K0i@)G-nG}f9DIS`Xn)zI3=C`M znzm@TH`Z;02K{$=KW!8w73F(RuXWBbO!KN@Bk%`I0W~!PK$D-FVs-%+sE!&pan4aw z8?n_JsPDzcH`5!<rD4q!TPM4zZKIZ{t0n(Ed;w(~f4|lw<3CPl-Ya0oRHB(XsBAM8 zGXu2BE0tME=AVC+!}{;3iq<}Jvmss&Y%A#EC=*acslB}x%LWyH&Sc1B$6|&9xJPjn z_-T@imzAQKei%uWGM-2q6qyG`5axu<K6h@EiyG19q1nZ%tZL+Wt2l{6Fw|$K8>um) zMA)l3Tg#lQC+3Yhz&3_=&${Y-$1oD7p{0j%v%~uJpz3|#P5htgO~DKF-k%%XVV-CG z7Wn92<6TMN&9tpmh+xW_v$+0~ZBpMTEU@zKyyz+6&O%#aBNEQ!4Q@2gIMvwwcJN3T z0VDa3Yv+Ycv`GE<r-?7CHwcuz{<ef+`LB5ac|luE^@Wp%$+*iIT!xV><$g+1BCW8k zR7_mWJR&{9Vo4Rh<DC7krp740nKUp=I%Ldcii?1-%dG|sgyv_xc84_$nc(f|?CVui zB+Y1QQWIJw_WX7)=DDd>oHt)fpfz-2k*(B}m~K+fBaq%v*IN^Qqu=jWjNvQDcVbi( zfu-1yILQbj_NGh-qK(eqqWiJm+W-4`H}`}}K^-Y61VR;il#$k{3o|%Y%2MoZ+d@AY zMeJ9UJu@vww{-Psq!3XHs?S%qeWqD;|2$Ql9s*mgjylpOa=)6_=8hu+1uJc7P8C(R z@RVa&m>u)VkHbUxHWX}UXl7q(fX1KuuI?R3NkjO>8GxqvSHaVfmm#DEEkKlAC7B56 z&`OGIQ$HztF8HKhNRc^XpQneHzX|Pzp;t-&tFJ6+KW0^rl=N`P3!ZJe7=Jg3I6t-g zr!$jI>xU3h!}Q<WpmD^;{`k@nOTj|YwBJ8d#+8lZb)3FETyK|~cF!mG2LS2kG<TLA z#D$Q3cL7g8J#H?KES@H2*L0P}L$uYfcY)u=;xQwdhz7gC4xeKyAS7mrT}Q8F$>D~* z<d??Ntc!WiSADO+J2g`_m`MB+hVry4?K0?s7)j4ld`cg+X+%HM%(Z1chMHD2=-VQr zejHM}KQGiAv#PLo?E)43ab}#EJwxJds<GP>Xo5E}dr!Wm8D?#+OcC!piqZa`=q@P& z2?^<6&)GjEns7G_KZyXqRn@fYXiEQpX4tEVg>Ztejn`OeB4L*297=edJpusdJB!=# z{LyZk>i0_)ytyI7DX<{2$YG`WQ!FGACKc>HqIm~+cvGWoUYyfizyjnd9R%w4e-Qx# zUUu|YFe4Puy#H@2(CjfY|A=}jrKlm_FCnNt8e(A2(tFVTLTPF*c-V4qj~oMq;&-&~ zsK<`JkTelDrkM>Cw8z~~O3IMoZ#dnhLd|rJ<M*$;n;);(?sW&VYaQPbQN%#VzCE6s z@=Sur7R<~$z?lE+x@q8WsH&+Xdy?^t=NRl6tgO(;vAm0TBMFuq=~Y9%4RbyrIDsrh z8&&e0QH_LPIJc;r;&P0^CW+v2)>#1EDRctt&K)uiivOaV$d>|cR!bs6)9+m(W8HBB z-rz>WU2{S7+ExxNm@;&!Qp^NZ>tCFUAtp8E(hb%5=BZY4K{8R!v0RzLP4Kruu-iap zwpBCJ&ss;r5x{1UUx%{uZIXn$dlEY1Un$;RaWIg9wmo?|yTOZU&{SK$0b=%Wjg||k zfunFao8{9Oe~3(t_hVi>Y|iUPyJ6<?|Id}POSpKJ@8Ddd7w6sM9<K%~sNKtRj8rnL zQ$~-RiQO0U_VIf^^H!AC7(nKs-OzQq@sUSK%ZEw&*5EHA2?8Dtl71;8LP|pameW)Z zwBT9Fh?xwJ9Wu{;x+oXcRHfA&Y!a^4q=6+YV;ZFm7Z{x^d*>Df;*Pq<d9wbDghe#G zd$oFw)6l)t+|fENnKtEd$6Gjjcvv}+h89fob-|gg1fX26r!Un7`0~-CM#N^0i#;zU zDbBCI<r!!~DhDjgNx@z83d@%w7_Zwz1C`Ou@3xj?AwS0Z*X8o**5G#2(o0Xf{nP%q z?-5V7CYO|j4fdL=Z(#iB3ttRW+X4@#nI|Vj6@ejh*%^W<j>`tU8l3TewySXijRy*F z&Jy +QUU$AHe)5%J$dNP<Uzefya_D1-F1QdRTUUxxE34&Hr@+fRj{g`SE#Fgm$L zDV~o_GsR6xj)86{ZB04BfZlGF=fz{|zE@-zYp-whv;K1%4DfOT*4?GA=D&`%PlnKD z;;hrNu)s-eGRdA*NqxCP4Nz0`!aT{)p<7w(zZU^WQrl9XVx6WSBZPQJ4-49)UmKzz z;=@%C)*z0<S{vSpp|*nEVN%AanRRRN-lKyb^`qI6L=_xIVhYTIKym$}5D6I0AGbYZ zE^spCbxMTZqN+cy`?Z}Ab!mF^$CFN<9B6YuQ@#!!{T-FsK@K}kr!*?0eA=Rxe-OsJ z%OfaMH4eE)c|;h9sjY#9BzTxiQ0m&#>`-!%G0xJll2LSof)Y!Yt0zpS@&{8F)Z0jF z%^vaeZO?eU)ek3`JcSDI7LJyy#|_xlp&Wi#dTHZ!7#zhBFs&ajJsi^K^lb;*JDTfY zH(a5HhtYJhYt%G+YinBxT+yTCAP2|4_|Zd89_I7*5c3C7{#!yOKpA`86MWvF`wwbC zxDW=Zl`|RS+MO)9@;z{hEsGN$!VnQnGYA^Bq~gCubBllaU(kM2d|}u+jJje%FbaI* zDPtL?>iBWlIv*ApgBkgw*kjAkdU=VE=(lJ!{Rhya>yM^GDK<HQg*f}A8~$o_umlWU z<Z*%hT4M*$G~mco-Q3@oVgf1JPkHDr>%ZtKnkppIf^H$fJZxZP4{FC4E18>^U9SFL zX6!98ve(~}Q9492-!~IQnvt@*wYxvh&JubDS9Bo}B{$jBzLSK3XHK0!^c&28(V314 zG^d_OUsOs!x4|9Lp@SO~x5@Ku9M<Dzg?QDklN(OuGxd`Dh4MY79g{wlE(F&j{pNHl z-|JJAb|h8YtWIDb|7SWRq|&d%61o0fepRIW@5~Fpoa%~kAxi@~rpw)MaG|pbIC(GC zlUzAsG%I?N?Qk%KD?l&sVn4cWr0KvWyO-Ak`}=L^4aK{HgQ4U)rRDfI13Ti`S{_IX zLYV3fIB3d_UxUSxim|;{EgYVw70HqGi$gN^vz~hbc5&dgQp=jaZ80o4K3XOx)&e+{ zPl!yBm{l)M$&}=8hZHq(C;a6dP`D4B!RdXrw9Y0)>u_`o_ZvC_i(*omHQ?N24Wxb) zYa2~vk(B3`v0dlMuPqt+S%1!=eHN*0-VM}YGUh+|S_i>u!ZVK}={<P~euVpx!m-}U zm*~8;?Nr@3Hh+$}XR%TEN<)qN|C-+M>#;7DK+ABuquxBCjlABY1@x~TY(*NMdb1TZ z>($lHiTwscVCHNbog#VZ2Fe56yM=*MAU^6Lk{z<L^=p%k?Qvi%+zI1&7!-IW<REA< zb>#x|S@};pv??XW)LF?h8}akUhQ9v98H<ZXoOPWp5qgvm=ZhI<M`p$TxLJXo<Xw3S zpy;80lGF9%=a&jN{c$`Fk&5GIpWGG-h^9yux@axvak+~*qp9fKv>6e_uOH9|&UL|L zCLm8`WJiJv0^D;hrvA;#zNd8{9c~+S49X4qLX5jlLtrpGw``OSPS34RQ^&0&j>NdN z-FQ2^5oAEaMuF<(>`Z*iO2#+Vs#k0lpLHW`?ah3B^S<4|;fYFhw+hVJPEFJ<5h~S2 zP?SRD=r|VO;D*Tj6G8>LHGx9EeU5>(>e?V!rssk(IXd|1#4^m{oOUZS%T|Yoiv7W1 zM?~4-&p8Q0W=LS!Pa$!WHy><(elM&xc+IIQ`B%|8KZ-8*r0;QC2W7dFB;d<@v+D4- z>K7R`dF^k39($fG0k2~Dp9lCgUyaKul&&x<tm`Dg+ag;Vf5F{HIT~y(MXX}8W%9a0 zy*M;1FvVQgQ;?qYq>1{=xeDmudD0zz6lD7dl#RGQw$@3lzQl~=6C*%x-^yv~GF!C~ z)jduM6|`rw8&bBmx)EQkh^$H|ri2nBi_jMG6GJNYey!u^mX##~l&e%9T_7Yz+V2*} zO~k&}KLxZA9^<D_tQ!2TySI*}s$@T_8!6$?P|+A^m(QHUK;8;_zxk9@*@iHTLY5B? zuuGJQ?#3cvrq@j0_qDjQ$Xq`!1XQF&hhC(Zy)$~N{`pv64m9^WEv+ZqX%g>(j%b33 z_F~y%_pM4#s1qLX0{s3kU{#A(ZLY5#FvZjnPX`;-t7&_>vBE|Z--xc#&cT+2S@4p? z8u)21p>5*tH|dTZ=ygQZ7xpVl#C{eIK0~-ZIlN2#kVRArGv^v#%a>)*uFJWn>eaCl zx5XV7?8#lMoHo%O@{`6C-p~|({`_|6!A#el#x(>>agdm-%BilaGHEq{$|yZgR~%E1 zNQ5@(6v+H}Ylk9AN9O4o0WqH~qZ_JhP`i@zS0)qRNFFwsbQ_ZoZgB8bMhOKpw@*ZF zl@qkTFU{4U+}}HeTz5OBv#WwasVc$yoEuh`2u@&kde-+ws+<e^SzLk<p0WMiJfA8( z$Thdcf()x{ETSR-;}=_Y-Eq3!fqmuBS-hTP4GzMtdbOM|(AZb|i6P;I;lOm?p02H5 zqX_@JAR>`I^X|JjR>I>|Aed*x)RGwxxP<psYqtz}MI_hsQ3&MfpqFBPyU~-pBs{kI zDq`EEMaa4-c+>8$dg1lWc9{NY<OD@KM<?pg)4N-I^(d;Qz|2F?r{MiWxYq$@&4Ro8 znVOeGLhaNar>GTlCgM0>TYm@;kpc3u4$TdAaALTBgGDg`rHcBj?fdbX?a^dS?eQxu zSjd<B2I%RCitW3TH)%<P-&30D5o;i#n?8|3n-U%IopGeUc6_HSdfwK7;`SG>%8A?t ziTYJKm0vz<D%xv8^iLM(x$+G`(=2gugF;5Nbsv4oHPR+0ooQezMPTGe)u7v^<n63g z?ns6#G}#&YlwL<b7~u#p61}rCQG<Y=0B2IQ%G%U))1{qB>7e!IlL+IyT=AF79#%D` zt{qk;B61_@(ptoaB2p48@1$D|ulHs%hI~9v-`}cmdsQ7wXv*}so8PB+>D#81C&%EL z^Zlqh%Dug>b~v(nw6%#`c-YUU?$J|!|2m!Dr7pEg!g+p*uV;>xQX&us4o~q>maq26 z!#m~aKfk*+GgM-o(aOmHu-MI2mcb*|w}bP+1Op{a^JXNo?S`Jgo#`+;x3kWg%Y863 zRY=O#N41h_tx(C*{EorwzftZ$^_VCR$booW{q9rI4P-h5b)78|WsX~7F@&;l@7-8B zel{-!m4`R3Z=!enq5D&5{F~~5AA=#`LT97-Ae%&0_UI<!<w?tqIqP~uM_om0Ps?<x zomT76x$F4DI9qPscdiV=$hKja=)NGL4s;&`JdUmY?xmm0y6_Awuf=qIzPQK(Qp($Y z!l4W8c#LRRn~`g|<j>%GCB5AQA7(ZJr;=yEXHY;yDY7L=jJ#eUEsp;w-N_@c-bpa_ zdlyjQbg(g#Y|BSBjzp}pI0IwiNjRI$Yi>GIdu!L1yJu^dR5Vup=AIG34sTWx9yc=k z?6s#UVIqiIE7_j^Rwq*h+`IJnkO;o!XT3K%oo+Njku=*R>FV`srM7KgyXdZ^yYK!8 zewUuAzc2bl%}qmBI})USAnhFHW?cp~pxBaNpJDl~D3ad9p#i)mCV!uK3SE~WDus?G zdQ$cEz=4E8PpMgs-nIy*FTD99GI$W$7};pHgY1>DRLtpwiZa5=U0ijL0U-dH8p*Ui zHfiDs>_eYuN#Y|sECux;77KRae&{{=G1(Qy5kc<IgL1UL;wu<y(_3g6$O7f)il<dO zRyfr$Yk~&Uq9m~wS2&Lwu$?9Ss=Kdn(ajo+@6cpsW`ydbF#^!ttwo$AJ36T>)j^(0 zm<vFX!hmTaT&uK3<1}7@?cXHDffNY!vH3i}2H26km6Gu9pl9j|MT%ZO4It@RAEgq9 zUHN8XJ!@V`rPvs+?@^SjMP>c~-ai7nCFH24ovc5%;Uq<k%7nyREVD=LA+t{WihSD+ zJB2;4^CiBnpe6XZNG@J_zWZk+Rc8&g^96TE$3$sYE-+jfTmhr2KZ7k>*0#{qSztpf z2(7<jo!Pm%xf5GECX<>Uw2M#uSYZk1vv^bAqytTdutX#|TPYRP8_Fxjl#HUa7gg<! zF>l}`6O$SaA~lr0wBlNfd*OqS$J5A?26hEo*SiH5JUK$viM2-^tk?|}p9`EuB~LE4 zv_;r-0hIix%4n3u{_`})t8z9&0!jqSH&8utO?FzC%z^}F2{@qM$+=Wzz!}uT?3n|k z#P?SR+p6JL--_u3+B`vzD&7}{U<RqBpB87SaPAL<$4~vj5w5~Js0Oj9p=U%4Mk+CV z8Zu)VBS)y&*o*o*8d~3)yS{BMfTrIUY|`P+Gjp?xI-1nkdD;-#4zkW3e9Ff9C(+cT z=#Su`vBt!1WhxTL<oNQ68hMnJysH>Wi!X10Lp#px`=G7p?8u=|D-yncW@|0?<bI?O ztl&S8^Go<EL$A%+l@@r=lyRd+NAAdBZeGS@u=u8juRjRCmpOD8t0AGJ{nPJ`oK4}d zd9GP09b4qd&tD74{j&T*YXQ9FP1<7-6vBG9VM-vY(jUq5fSpZNV9x*X&0^hNTf)Bk z5L*&D<^77aC5h2!W{G9}j2jKd^--pISHYO3K~Z-}-&|2fOt*rIcBmFG<9O4M@?6m| z8&7R3mly)jlssjF<qT*3eHZJj2*Pyix{EPZbxZZH4%LC7)GsW6WQ(9Xn7Mh8A8xDd zkwq9>TGf<0oJy<dyQaVIHpHT9tq8{m<+k2PW|!ag&cwOec`I_!{l6m$rQ-r6c89LX zp^DX|D;H0&R4T1Znusq`Qm|dsnkK$UW3bM5;qv~Ix}c{uqEHbz(UG20YyW%5&IPJg zmuOso^`M4IZGJh6!AzcSru;<X>Z-IBx3Gt{rYLCaCsPa?Qp&l=_AcRX!-tHNg^Tal zoDvQPK2u{-gJHUdn@RO01To8qLnA)1kp#sHl2HD_tYZ<13Na=qF}y;^Un(}qOq@kR zr)0IBg@Zp%o2$ZNgt!pwvISTXMZ}lQ@YR@FLF4u4(P2FU5%#Vim~=zphR*Wq`!*(k z45h7j_czP@!3mYGCpGQ}!3&e)4Xgz1xiKA|moBVbo2o;y1{?g5-D<8zBBycYGvAF5 zYT?5F{2E1B>5Mw+Lt%=#%=9nDh+!9DfVc#KY}nMfjY7r=>A3jEJA=ad>o+Xey}<^r zVoHrFwjLq(MmbpG#C=DmXybBWt_%EZ+GAegYU};6cnn>k(1VxTwFRZeH^QJ3{4R~* ze|}`_$1N<E+;!D&64LpxI>)&GR9hxRW3|ZG9Xm8(7{?l0^@8H<OrKnyX6CWf#a(n9 zw~Av36_psga#YP=CoR8C4Rc^&`$8+j<SO1=_0q*kdIxx3+`)VDh$BGp@51--c{599 zDA_-$G<=_!5_x__taoE4zK+SxwI*J~WYnXo!PTF`if<)o-+yYQ*!doWh;$vhys<a9 z!^7u?tLYLD>^;!sR8I7y+4J{pC)|5;9;~W)7CCzTl~U;1x5?K0nyLu|wt06GQqwu( zPc~OkL(6%C-nL8=&v3rKo4&(nY{DdaXZJHGS5Xp-;Phh=?w)7HSc~>7CCObhbt(%* zYRS^pslyW|QMqAn0emeA!=ultQ;3G336j-k^O)jdW_9lj5EG??V?@DajMzJlf!8dA z8rP=QQ=*w{U+~^GdRqG10^(N-Vzj|~xwffUx-^D5`SINEG0=U$J&L?55QYeeC8Xg? zU)1UlUzfpBwqms&tKtM;(k{~kPq6y~F+ctyWCp0koG1cv^R?)E8uT_SM<7DF8DxIr zY;b>c#uT^t@KErXOePUqe8Qx6yh!Pfg=2AcrmB}pQPCfLc&7YpNbJ?BDOd`3@O8QL zP~sitEV(m3GR_{ix%NF!=WSu`sU+Z}E2@&TlpWIX24hx_i6L)M10-G{pK`sTgvR%G z0+0Sxm+IIK2`BoyO%3b=z3i)kMf%zE;EDN*y;*FjY**eg@~t;cUVoY(2`!1=SRt~B zkwu=!iKNIkAJ+6EuR5-{e?$j2C#fXGH2gZ1pr7ZTR9B8`Kwn+UCDjniT|LOah=oXR z?awI`>J_<<PO;47oIWLrPtK3LVM-=if~IFxp&^*d$_X!b9>Ln>u+{O~YVi>w-bf4V zta4xanC(~nuVKBbgpzp^HFGPn>p7Y(W223Ovyf&pPCHBoid(~gLS(Jt|1KsJS%WRo z)GYD+^G&mU?duCRMJ>3*0!^fHA-$$KvsP>0^G;)<kh_!R!>>jpOs8Dk!z!PJY|%Qk zfRzzyXV0KHcbStkm*78g-X1CyugfXLs7kyJEhqQ11|>k*%LPCz6QG7!g0WfYOKjR7 zo?zL7u}wUXV$=FR?cjG?Lr|$rp-3fnLOFU<`?s=PG`R8yMi5Ff<ihw=<<v31r*&d% zP4LmS&ZdltA|XEX9Xd!tqZ&vLLUttKZT=TCuOUY&J${9?1y1q-!n*9ogn0q!%Vr|f zjOF-|46X=-3kCYxsrV-aEy-%1v|y%J;#jZ0MuMIe#m_f_n^9!oW?%Qyxb3SkP1Ul~ zr=0$<N2tgu{LS*mQ68LS|2@bI=50L7uH{RU1z$;zrATgFDS~)h5yzYtJu27Dgard# zPRR1Ut^Bl77WUG4Fled9eMAnL!SwF(?+nM=NbWw0Y}<s%JrH}?Hou`-ODNe)#VL+O zg%j|{Wk2dLbM#mW=%STHFP;-2BFeuKzSOL=_v{1ILe}7EUct$&G2S!;Np&{g=v9nx z{#z2S+)NE0)*BRXtmGp0@X-t?D7ir7;hIcS9_@8Ti|3QNGX{`ZQ_1jlh8)PrdB7MV z-;9R&OK?Qq+_LdRS3lO%%_w`&Ubb@#9r^EuvDFf#oQQ8vPyq^Z4SCFVg(2y)HYy}w z>sD4F+uFb1xjD-W=OE*4;V>B}(MMur#@e=~i<5@fY>JWFaOOo{Jf%#{$D}?D3IElA z@w?i4#u2pW0e&m+=a2`njtQy=3()z{(z|eh$*)+XUe|31ZqkT_P<{#(ZRV*I5Q=DV zsJ5M`DB}f5yWTe#G-=R9{WH`hb$t&XS(X!f<;Q9dhIqH(Ke}CpzyutXfWQCu_!Qg= zz*hxg1*II^hZ`y(;srvS>bJ#`*z`@kQj=b3Q3>8O{fo-1Wxb4r-daW3E|vkBRsGF% z<cf9C$5^2>z({~^BP(>3ox=)x7fD&!i>>X%;EJ{1eL;8=NgCYfkJFJR#z)ycY?be* zZd$Uow`F&u0-f_SRBvIA40j9=WsLL;5m{K$P&DR$6_moi0pX}C?%1zm-v=Z92y5>P zXX0<RP}MW80=B7609}o`JiHGIhxs~4&-g597?}KaA_7g)%&V`--O67ibG*^6E<FyB zz`J^0qJGf>E(^fnoj9axBOvG_6hS56p{=!;q@Yhk4aP}6!|khcXeitWO!%l(DU7kK z(gnsfdxJL2gFSU3wXH+}LRNQ?PoPQQf#1)cyqK9Z39W>h>U*Juygi5E8gdtH)ck^; z$*20barVdTe!80g&5OHoNz;c@8c_*-gKaxm-0~Tti!krISc4aPRB(4u_z!Uot`d#8 z1XZL!hg5qMoWE0$Kl|me$e7VlaY}d@We<n$Ge?<AqEU~1ar3)_m%2I=%^!+xO%r*G z>dxo`Xa^z-#dtX@VAp{`?6tkBVLr7CH`~e67f&HHwe^*VF&k3<aWb}}BacTNl{7={ z?MH4!rfO0Y^R3Z{vde2N?n6N7AAkQNo3^?)Mo)3c(QF4=zb|?48l=b^>5&fmg0XYr zpMj>%{?+JHhjy)IZMzG7oJT&qo0_TqUNWwSHF59k<)UPNYLnsA#CJci<a910pgssB z*{G5e>0*P#^=z~(1bpwqh)6sXogWEQ-To9flG(gf?~OjwefhY+Ew9L9258Q@!!%}w z!_a0x?&;?wMQ-BROCwr#w7m5yjF3n8vJx}oX6<sx5*9Tx*-Q<YS&s$<h}s*;<sC_# zSmwxRxHX>4q2?hS3Gr#L!7fQ!p>_U(XBZ}y>c!^C3Z;(VFxA4qjVlOiF?C1nKD-vV zk5TzO4Xr@{A@XlRW*fl5+nj{7ZP<~P2XzpOiSbuBt?*$tfn$F`e^)s%2t>uWaG~FU zhP9(2*<Sr^8y4~k9ki#-FeeKK!$>~~@ott$&^{cjV|BtN!^Sw`&V6AwK?1LG-%%FH zE$`phD*fR3j=q`d4?m-3lp#0z^o(P<<&B7(ZR)d{dd*t?H$^z5g8tb4qy<c4TbJTB z2fF9U*_pDTzO!MACxlc=ES)2)%X*)l-=pntUSzOk_ZK8^i(sO^ws5}t()bPkua;uu zwELJiUmoFRn3zOm{(F6YO#J;2OQ$jm9vYee7&<(hs1KJP6v56?ajg8jeKCDX>@x{p z7DOQPn4-(|YA!Jm=6~nL;it`hDkl(M?9GHRs+<b*+}#@fB71v#<+VHG{rCU(ow_%a zfC^TnWt5!Oux-B6sEFyrA)`WU1Lph^tIh*U^qk;_RRgsRswJW|-Rg1@r4+x#uovIu zdKxCAV`ww{oA%a{-=;e-Za#}c)lO=y%U^RA0<ZpsGyj0*?W~ovqaE5}vIM;AhC?Bp zQT<VyAKGE`FC#=lDOoJ?!eBtclhn$giqEZUkFqOj5gzhY&g(QaT7F^R!L2L3YTEZk zSmeKEri;Q>{yqD|A]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�~sAIJ<BC~Bh?UUP)Z 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%0DR1Nfloq*_P8Ns`!iUz`FCNM6IC@Ws{MR6yY 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 S6sV?}nb=ae?}Zcq0000R+bit= literal 0 HcmV?d00001 diff --git a/docs/benchmarks/deepswe/README.md b/docs/benchmarks/deepswe/README.md new file mode 100644 index 000000000..09a914889 --- /dev/null +++ b/docs/benchmarks/deepswe/README.md @@ -0,0 +1,38 @@ +# DeepSWE harness comparison + +Ten coding harnesses, one model, the same 113 tasks, one attempt each. + +| | | +| --- | --- | +| 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 000000000..ba8c10e98 --- /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 From a952053da4f6673bd90c4b1c7fdceada373f02e6 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:26:30 -0400 Subject: [PATCH 164/195] senior-dev: finite ceilings, a ceiling that holds, a limit the person is told about, and a hand-off cap that lasts Five money-and-limits defects the review of #1488 confirmed, fixed together because they meet in the same run specification, landing and loopback API. Every senior-dev run now has a finite dollar and wall-clock ceiling. A chat with no conversation limit used to hand the run zero, which meant unlimited, for an autonomous run nobody watches. Named defaults ($10 and 3h, one constant each) now apply, a smaller conversation limit still wins, and explicit shell flags still replace them. The approval card, the typed start's line and the shell road's first line say the pair that applies, and a test keeps the manual's figures tied to the constants. The loopback model API reserves each call's priced estimate (input size and the requested output cap, at the dearer of the asked model and its fallback seat) before forwarding it, and holds the reservation until the receipt settles. It used to check settled spend only, so eight concurrent calls against a $1 ceiling spent $7.20. A call with no published price reserves half the ceiling, and past half the recorded spend only one runs at a time. The refusal and the help text now say what the code does. A run that ends on a limit writes its own line into the conversation (which limit, what was spent, where the work is) before the wake is attempted, and the open window paints it, so a wake refused by that same limit no longer leaves a silent "ended". The cap on automatic re-hand-offs (two, and none after a limit) is kept with the conversation until the person next speaks, across wakes and a reopen; a silence-approved proposal counts as automatic, and a start that fails gives its count back. The finishing commit credits only the models that answered a call in the run, read from the loopback's settled log, and credits none when it cannot tell. Review of #1488: lane 5 finding F5.2, lane 2 findings F2.2, F2.3, F2.5, F2.6, and lane 5 finding F5.4. Lane 5's missing landed card (F5.5) did not reproduce here; a surface test pins the landing path while the task page is open. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- cmd/codeaf/carried.go | 31 +-- cmd/codeaf/carried_test.go | 22 ++ docs/changes/unreleased/1488-senior-dev.md | 5 + internal/delegate/cli.go | 15 +- internal/delegate/conversation.go | 25 ++ internal/delegate/conversation_test.go | 26 ++ internal/delegate/host.go | 48 ++++ internal/delegate/limits_test.go | 77 ++++++ internal/manual/chat/delegates.md | 18 +- internal/manual/chat/senior-dev.md | 50 ++-- internal/provider/modelapi/server.go | 180 ++++++++++--- internal/provider/modelapi/server_test.go | 153 +++++++++++ internal/provider/modelapi/wire.go | 26 +- internal/run/delegateworker.go | 3 + internal/run/enginewire.go | 1 + internal/session/agent.go | 1 + internal/session/delegate_door.go | 11 +- internal/session/program_attribution.go | 32 +++ internal/session/program_outcome.go | 181 ++++++++++++- internal/session/program_outcome_test.go | 275 +++++++++++++++++++- internal/session/programfolder.go | 8 +- internal/session/prompts/program-outcome.md | 2 +- internal/session/senior_dev_limits_test.go | 72 +++++ internal/session/session.go | 4 + internal/session/task.go | 7 +- internal/session/task_contract.go | 3 + internal/session/task_run_belt.go | 70 ++++- internal/session/wakecause.go | 3 + internal/tui3/senior_dev_ceiling_test.go | 46 ++++ internal/tui3/task.go | 10 + 30 files changed, 1292 insertions(+), 113 deletions(-) create mode 100644 internal/delegate/limits_test.go create mode 100644 internal/session/program_attribution.go create mode 100644 internal/session/senior_dev_limits_test.go create mode 100644 internal/tui3/senior_dev_ceiling_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index c56ef2987..4b7c88169 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -122,12 +122,9 @@ func carriedSignals() (context.Context, context.CancelFunc) { type carriedRoad struct { completerFor func(model string) modelapi.Completer serves func(model string) bool + modelPrice func(model string) (input, output float64, known 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 + signNamed bool } // carriedModels resolves a shell run's road. It is the person's own profile, @@ -153,20 +150,12 @@ func profileRoad() (carriedRoad, error) { return carriedRoad{ completerFor: adapters.forModel, serves: func(model string) bool { return session.ServesModel(sources, model) }, + modelPrice: settings.Models.PriceNow, seat: seats.Work.Model, - signModel: signedSeat(settings.ProfileDir, seats.Work.Model), + signNamed: config.AttributionModelAt(settings.ProfileDir), }, 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 { @@ -243,7 +232,7 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { // 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) + folder, err := carriedFolder(inv, record) if err != nil { fmt.Fprintln(carriedStderr, "error:", err) return exitCannotRun @@ -255,6 +244,9 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { finish := func(result string) { if folder != nil && !finished { finished = true + if err := session.SetProgramAnswerAttribution(folder, road.signNamed); err != nil { + fmt.Fprintln(carriedStderr, "could not read the models that answered:", err) + } view.left(folder.Finish(result).Sentence()) } } @@ -284,6 +276,7 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { TaskDir: record, CompleterFor: road.completerFor, Serves: road.serves, + ModelPrice: road.modelPrice, Seat: road.seat, Ceiling: inv.Ceilings.CostUSD, Bank: func(charge modelapi.Charge) { @@ -382,13 +375,13 @@ 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) { +func carriedFolder(inv *delegate.Invocation, record 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, + Holder: "a run started at a shell", Keep: record, Instead: "run it in the project's folder, or name that folder with --dir", }) } @@ -612,7 +605,7 @@ func (v *carriedView) begin() { if v.records != nil { return } - v.say("%s · working in %s", v.inv.Program.Name, v.where()) + v.say("%s · working in %s · %s", v.inv.Program.Name, v.where(), v.inv.Ceilings.Summary()) } // inFolder keeps the folder the run was readied in, for the line that says diff --git a/cmd/codeaf/carried_test.go b/cmd/codeaf/carried_test.go index f225a5560..a8fcfdd85 100644 --- a/cmd/codeaf/carried_test.go +++ b/cmd/codeaf/carried_test.go @@ -74,6 +74,28 @@ func TestTheFrontPageFitsWithTheProgramsThisBuildCarries(t *testing.T) { } } +func TestSeniorDevShellFirstLineNamesItsEffectiveCeiling(t *testing.T) { + program := fakeCarriedProgram() + program.Name = "senior-dev" + for _, tc := range []struct { + line []string + want string + }{ + {[]string{"repair"}, (delegate.Ceilings{}).SeniorDevDefaults().Summary()}, + {[]string{"--max-cost", "2", "--max-hours", "0.5", "repair"}, "up to $2.00 and 30m"}, + } { + inv, err := delegate.Parse(program, tc.line, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + newCarriedView(&out, inv, t.TempDir()).begin() + if !strings.Contains(out.String(), tc.want) { + t.Fatalf("first line %q lacks %q", out.String(), tc.want) + } + } +} + // 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 diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index d9a2b098e..f029585dd 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -39,6 +39,11 @@ invalidates: - "Changing `.gitignore` could commit a secret ignored when the run began, and test caches entered the task commit; the start-time ignored paths and the narrow generated-path list are excluded from eager and finishing commits." - "The folder hold let workspace restore, workspace merge and unnamed generated output write inside the held folder; it now fences those writes with the other file tools." - "A gitignored subfolder widened to its enclosing repository and an empty branch was deleted while its files remained; it now runs as a plain folder and says that nothing was committed." + - "A senior-dev run with no conversation budget was unlimited in both money and time. Every chat and shell run now starts with finite default ceilings; remaining conversation limits can lower them, shell flags can replace them, and the approval card or start line says what applies. Ordinary `/task` keeps its own limits." + - "The loopback model API checked only completed spend, so concurrent calls could all cross a dollar ceiling. It now reserves estimated cost atomically before forwarding priced calls, including a possible fallback to the run's seat, bounds concurrency when either model has no known price, and refuses a call whose estimate would cross the ceiling; an answer can still cost more than estimated." + - "A senior-dev run stopped on a limit could leave only a silent ended row when the same limit blocked the chat's wake turn. The conversation now receives an authored line naming the limit, spend and branch or folder before any model wake; an open window paints the same line from the standing lane." + - "The two automatic senior-dev re-hand-offs were counted only in the wake turn. The cap and the refusal after a limit now persist through later non-person turns and reopening the conversation, until the person speaks; silence approval counts as an automatic hand-off." + - "The run's commit could credit the configured worker model even when another model answered every call. Chat and shell commits now credit only models recorded as answering in the run's loopback log, and a run with no answered call adds no model trailer." --- `docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate` diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index d93b55f91..cd17a28ea 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -12,6 +12,7 @@ import ( "flag" "fmt" "io" + "math" "os" "path/filepath" "strconv" @@ -75,7 +76,7 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) 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") + cost := fs.Float64("max-cost", 0, "a dollar ceiling; reserve estimated call cost") 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) @@ -89,8 +90,8 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) } 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) + if *cost < 0 || *hours < 0 || math.IsNaN(*cost) || math.IsNaN(*hours) || math.IsInf(*cost, 0) || math.IsInf(*hours, 0) { + return nil, fmt.Errorf("%s %s: a ceiling must be a finite, non-negative number", program.Name, command.Name) } workspace := *dir if strings.TrimSpace(workspace) == "" { @@ -100,10 +101,14 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) if err != nil { return nil, fmt.Errorf("%s %s: --dir: %w", program.Name, command.Name, err) } + ceilings := Ceilings{CostUSD: *cost, Hours: *hours} + if program.Name == "senior-dev" { + ceilings = ceilings.SeniorDevDefaults() + } return &Invocation{ Program: program, Command: command, Workspace: abs, - Ceilings: Ceilings{CostUSD: *cost, Hours: *hours}, + Ceilings: ceilings, JSON: *asJSON, Args: fs.Args(), Line: append([]string(nil), line...), @@ -162,7 +167,7 @@ func Help(program Delegate, out io.Writer) { } 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-cost USD dollar ceiling; reserve estimated cost before each call\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 <command> --help` lists a command's own flags.\n", program.Name) diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go index c34d1fc57..20127e9c7 100644 --- a/internal/delegate/conversation.go +++ b/internal/delegate/conversation.go @@ -245,3 +245,28 @@ func ReadTurns(dir string, n int) ([]Turn, error) { } return turns, nil } + +// AnsweredModels is the distinct model set that actually answered this run, +// in first-answer order. An unfinished, refused or failed call credits nobody. +func AnsweredModels(dir string) ([]string, error) { + turns, err := ReadTurns(dir, 0) + if err != nil { + return nil, err + } + seen := map[string]bool{} + var models []string + for _, turn := range turns { + if turn.Ended.IsZero() || turn.Refused != "" || turn.Failed != "" { + continue + } + model := strings.TrimSpace(turn.Served) + if model == "" { + model = strings.TrimSpace(turn.Model) + } + if model != "" && !seen[model] { + seen[model] = true + models = append(models, model) + } + } + return models, nil +} diff --git a/internal/delegate/conversation_test.go b/internal/delegate/conversation_test.go index 6b1e43f8a..6d0807587 100644 --- a/internal/delegate/conversation_test.go +++ b/internal/delegate/conversation_test.go @@ -36,6 +36,32 @@ func TestReadTurnsKeepsEachCallsLatestRecordInStartOrder(t *testing.T) { } } +func TestCommitCreditsOnlyModelsThatAnsweredInTheRun(t *testing.T) { + dir := t.TempDir() + at := time.Now() + for _, turn := range []Turn{ + {Seq: 1, Model: "crew/unused", Refused: "limit", Started: at, Ended: at}, + {Seq: 2, Model: "worker/asked", Served: "minimax/m2.7", Started: at, Ended: at, Reply: "done"}, + {Seq: 3, Model: "kimi/k2.6", Started: at, Ended: at, Calls: []ToolUse{{Name: "bash"}}}, + {Seq: 4, Model: "other/failure", Started: at, Ended: at, Failed: "provider failed"}, + {Seq: 5, Model: "minimax/m2.7", Started: at, Ended: at, Reply: "more"}, + } { + if err := AppendTurn(dir, turn); err != nil { + t.Fatal(err) + } + } + models, err := AnsweredModels(dir) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(models, ","); got != "minimax/m2.7,kimi/k2.6" { + t.Fatalf("answered models = %q", got) + } + if empty, err := AnsweredModels(t.TempDir()); err != nil || len(empty) != 0 { + t.Fatalf("no calls = %v, %v", empty, err) + } +} + func TestATurnIsWrittenCapped(t *testing.T) { dir := t.TempDir() sent := make([]Said, 20) diff --git a/internal/delegate/host.go b/internal/delegate/host.go index fb0fdb0fc..da93db1a3 100644 --- a/internal/delegate/host.go +++ b/internal/delegate/host.go @@ -4,6 +4,8 @@ package delegate // environment its process starts in. import ( + "fmt" + "math" "net/http" "strings" "time" @@ -45,6 +47,52 @@ type Ceilings struct { Hours float64 } +const ( + // An autonomous senior-dev run with no person watching must stop on its own. + DefaultSeniorDevCostUSD = 10.0 + // An autonomous senior-dev run with no person watching must stop on its own. + DefaultSeniorDevHours = 3.0 +) + +// SeniorDev fills absent conversation limits and caps larger ones at defaults. +func (c Ceilings) SeniorDev() Ceilings { + if c.CostUSD <= 0 || c.CostUSD > DefaultSeniorDevCostUSD || math.IsNaN(c.CostUSD) { + c.CostUSD = DefaultSeniorDevCostUSD + } + if c.Hours <= 0 || c.Hours > DefaultSeniorDevHours || math.IsNaN(c.Hours) { + c.Hours = DefaultSeniorDevHours + } + return c +} + +// SeniorDevDefaults fills omitted shell limits while preserving explicit flags. +func (c Ceilings) SeniorDevDefaults() Ceilings { + if c.CostUSD <= 0 || math.IsNaN(c.CostUSD) || math.IsInf(c.CostUSD, 0) { + c.CostUSD = DefaultSeniorDevCostUSD + } + if c.Hours <= 0 || math.IsNaN(c.Hours) || math.IsInf(c.Hours, 0) { + c.Hours = DefaultSeniorDevHours + } + return c +} + +// Summary says the two ceilings as the person sees them at either start door. +func (c Ceilings) Summary() string { + return fmt.Sprintf("up to $%.2f and %s", c.CostUSD, c.TimeWord()) +} + +// TimeWord spells the wall ceiling without padded zero units. +func (c Ceilings) TimeWord() string { + wall := c.Elapsed() + word := wall.String() + if wall%time.Hour == 0 { + word = fmt.Sprintf("%dh", int(wall/time.Hour)) + } else if wall%time.Minute == 0 { + word = fmt.Sprintf("%dm", int(wall/time.Minute)) + } + return word +} + // Elapsed is the hours as a duration, zero for none. func (c Ceilings) Elapsed() time.Duration { return time.Duration(c.Hours * float64(time.Hour)) diff --git a/internal/delegate/limits_test.go b/internal/delegate/limits_test.go new file mode 100644 index 000000000..98d287da1 --- /dev/null +++ b/internal/delegate/limits_test.go @@ -0,0 +1,77 @@ +package delegate + +import ( + "bytes" + "math" + "strings" + "testing" +) + +func TestSeniorDevCeilingsAreFiniteAndCapExplicitLimits(t *testing.T) { + for _, tc := range []struct { + in, want Ceilings + }{ + {Ceilings{}, Ceilings{CostUSD: DefaultSeniorDevCostUSD, Hours: DefaultSeniorDevHours}}, + {Ceilings{CostUSD: 1, Hours: 0.5}, Ceilings{CostUSD: 1, Hours: 0.5}}, + {Ceilings{CostUSD: 30, Hours: 9}, Ceilings{CostUSD: DefaultSeniorDevCostUSD, Hours: DefaultSeniorDevHours}}, + } { + if got := tc.in.SeniorDev(); got != tc.want { + t.Fatalf("%+v became %+v, want %+v", tc.in, got, tc.want) + } + } + if got := (Ceilings{}).SeniorDev().Summary(); got != "up to $10.00 and 3h" { + t.Fatalf("default ceiling summary = %q", got) + } + if got := (Ceilings{CostUSD: 1, Hours: 0.5}).SeniorDev().Summary(); !strings.Contains(got, "$1.00") || !strings.Contains(got, "30m") { + t.Fatalf("smaller ceiling summary = %q", got) + } +} + +func TestSeniorDevShellFlagsReplaceDefaults(t *testing.T) { + for _, tc := range []struct { + in, want Ceilings + }{ + {Ceilings{}, Ceilings{CostUSD: DefaultSeniorDevCostUSD, Hours: DefaultSeniorDevHours}}, + {Ceilings{CostUSD: 30, Hours: 9}, Ceilings{CostUSD: 30, Hours: 9}}, + } { + if got := tc.in.SeniorDevDefaults(); got != tc.want { + t.Fatalf("shell ceiling %+v became %+v, want %+v", tc.in, got, tc.want) + } + } +} + +func TestSeniorDevShellParserSetsFiniteDefaultsAndHonorsFlags(t *testing.T) { + program := testProgram(nil) + program.Name = "senior-dev" + for _, tc := range []struct { + line []string + want Ceilings + }{ + {[]string{"repair"}, Ceilings{CostUSD: DefaultSeniorDevCostUSD, Hours: DefaultSeniorDevHours}}, + {[]string{"--max-cost", "30", "--max-hours", "9", "repair"}, Ceilings{CostUSD: 30, Hours: 9}}, + } { + inv, err := Parse(program, tc.line, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if inv.Ceilings != tc.want { + t.Fatalf("%q: ceiling %+v, want %+v", tc.line, inv.Ceilings, tc.want) + } + } +} + +func TestSeniorDevRejectsNonFiniteCeilings(t *testing.T) { + program := testProgram(nil) + program.Name = "senior-dev" + for _, line := range [][]string{ + {"--max-cost", "NaN", "repair"}, + {"--max-hours", "+Inf", "repair"}, + } { + if _, err := Parse(program, line, &bytes.Buffer{}); err == nil { + t.Fatalf("non-finite ceiling %q was accepted", line) + } + } + if got := (Ceilings{CostUSD: math.NaN(), Hours: math.Inf(1)}).SeniorDev(); got.CostUSD != DefaultSeniorDevCostUSD || got.Hours != DefaultSeniorDevHours { + t.Fatalf("non-finite conversation limit produced %+v", got) + } +} diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 4de719487..0e4797acb 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -140,14 +140,16 @@ folder with `--dir`. 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 `<name> 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 step cap.** senior-dev has finite dollar and wall-clock ceilings even when +the conversation sets none; `/budget` can lower them, and shell flags set them directly. +Before forwarding a call, codeaf reserves the larger estimate from the requested model +and its possible fallback seat when both have known prices, using input size and output +cap; if either price is unknown, it uses the unpriced bound. It refuses a call whose +estimate would cross the ceiling. An +answer can cost more than its estimate. When a model has no known price, codeaf reserves +half the dollar ceiling and limits concurrent calls once half the recorded spend is used; +an unpriced service's actual charge cannot be measured here. The wall-clock ceiling still +ends the run. **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. diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 969641279..ab7b3662d 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -219,7 +219,8 @@ broke — and acts on it: 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 +hand-off you ask for yourself is yours, and starts the count again. This count holds +through wake turns and a reopened conversation until you send a message. 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 @@ -233,10 +234,11 @@ branch first and the next run cuts its own. 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 has no step cap.** Every run has finite dollar and wall-clock ceilings: by default, +**up to $10.00 and 3h**. A conversation's `/budget` limits can lower either ceiling to +what remains. At a shell, `--max-cost` and `--max-hours` set either ceiling explicitly. +The proposal card, typed command's start note and shell run's first line say which ceiling +applies. These ceilings are enforced outside senior-dev whatever it does. **It reaches a model only through codeaf.** Its engine receives a short-lived token for codeaf's loopback model API, but its model-written shell commands inherit neither that @@ -388,6 +390,11 @@ and that branch is checked out there; your branch <yours> is as it was: `git -C switch <yours>` goes back to it, and `git -C '<folder>' merge <branch>` from there brings the work in``. Merge it when you are ready, or ask the chat to. +The finishing commit's model credit names only models recorded as answering a call in +that run, including a model that answered in place of the one asked for. If no model +answered, there is no `Assisted-by` trailer. The attribution setting still decides +whether answered model names are shown. + 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". @@ -474,15 +481,21 @@ model API. So every call is priced like one of codeaf's own, shows in the conver 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 +Before forwarding a call, codeaf reserves the larger estimate from the requested model +and its possible fallback seat when both have known prices, using the request's input size +and output cap (4,096 output tokens when none is named); if either price is unknown, it +uses the unpriced bound. It refuses a call whose estimate would cross the run's dollar +ceiling. In-flight calls can +finish above their estimates, so the final spend can exceed the ceiling by a call's cost. +The refusal says `the run's dollar ceiling of $5.00 is reached ($5.04 spent), so codeaf made no call`; +the shown spend is the money already charged, not the reserved estimate. 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`. +When a dollar or time limit ends the run, the conversation also gets a line naming the +limit, what the run spent and the branch or folder holding its work, even if that limit +prevents the chat from making a wake call. 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, @@ -498,7 +511,7 @@ whichever model did. A dated build or a variant of the model it asked for, such 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 +## senior-dev on a service that reports no prices — a local proxy, a Codex sign-in, unknown dollar cost 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 @@ -508,13 +521,12 @@ senior-dev makes through one is counted with its tokens and no dollars. The task 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. +**A missing price cannot become a dollar charge in the ledger.** To bound concurrency, +codeaf reserves half the run's dollar ceiling for a call with no known model price and +admits at most one such call in flight once the recorded spend reaches half the ceiling. +This limits simultaneous calls but cannot say what an unpriced service actually charged. +The run's wall-clock ceiling still ends it; set a different one with `/budget` or +`--max-hours` if you need a shorter or longer run. ## Why a stopped senior-dev run takes a moment to end — the price of the call it was in the middle of @@ -599,7 +611,7 @@ 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; +- `--max-cost USD` and `--max-hours H` — replace the default ceilings for a shell run; - `--json` — the program's records on stdout instead of readable lines. senior-dev's own flags on `run`: diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go index 028658d71..ff4173cfd 100644 --- a/internal/provider/modelapi/server.go +++ b/internal/provider/modelapi/server.go @@ -31,11 +31,10 @@ package modelapi // // ── 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. +// Each call reserves its estimated cost before it goes out; a call that would +// cross the ceiling is answered 402 in the router's own shape and written as a +// refused turn. An answer may cost more than its estimate. The run's supervisor +// ends a program that has spent its allowance. import ( "context" @@ -46,6 +45,7 @@ import ( "errors" "fmt" "io" + "math" "net" "net/http" "strings" @@ -103,6 +103,9 @@ type Config struct { Seat string // Ceiling is the run's dollar ceiling, zero for none. Ceiling float64 + // ModelPrice is the catalog's published input and output price per token. + // Nil means a local or custom model with no known price. + ModelPrice func(model string) (input, output float64, known bool) // 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) @@ -156,12 +159,14 @@ type Server struct { // 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 + mu sync.Mutex + token string + closed bool + spent float64 + reserved float64 + inflight int + seq int + threads threads // refused counts the calls answered 402 at the ceiling. refused int @@ -237,13 +242,6 @@ func (s *Server) RefusedAtCeiling() int { 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 @@ -500,14 +498,130 @@ func (r *record) close(fill func(turn *delegate.Turn)) delegate.Turn { // 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) { +func (s *Server) open(request *call, thread, served, model string) (*record, float64, float64, bool) { 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 + ceiling := s.config.Ceiling + if ceiling <= 0 { + s.inflight++ + return entry, s.spent, 0, true + } + reserve, known := s.estimate(request, model) + if !known { + // An unpriced local or custom model can still spend real money. Once + // half the ceiling is spent, admit at most one such call in flight. + reserve = ceiling / 2 + if left := ceiling - s.spent; reserve > left { + reserve = left + } + } + if ceilingReached(ceiling, s.spent+s.reserved) || + (!known && s.spent >= ceiling/2 && s.inflight > 0) || + reserve > ceiling-s.spent-s.reserved+ceilingDust { + s.refused++ + return entry, s.spent, 0, false + } + s.reserved += reserve + s.inflight++ + return entry, s.spent, reserve, true +} + +// defaultOutputCap is the conservative output allowance used for a request +// that names no max_tokens or max_completion_tokens. +const defaultOutputCap = 4096 + +// estimate uses the whole encoded input byte count as an upper token estimate; +// this intentionally counts JSON framing and tools as input too. A call can +// fall back to the run's seat, so its reservation covers the dearer model. +func (s *Server) estimate(request *call, model string) (float64, bool) { + if s.config.ModelPrice == nil { + return 0, false + } + cap := request.outputCap + if cap <= 0 { + cap = defaultOutputCap + } + price := func(candidate string) (float64, bool) { + input, output, known := s.config.ModelPrice(candidate) + if !known || input < 0 || output < 0 || math.IsNaN(input) || math.IsNaN(output) || + math.IsInf(input, 0) || math.IsInf(output, 0) { + return 0, false + } + return float64(request.inputBytes)*input + float64(cap)*output, true + } + reserve, known := price(model) + seat := strings.TrimSpace(s.config.Seat) + if seat != "" && seat != model { + // The first model can fail after admission and be answered on the seat. + // A seat with no published price uses the unpriced concurrency bound. + fallback, priced := price(seat) + if !known || !priced { + return 0, false + } + reserve = max(reserve, fallback) + } + return reserve, known +} + +func (s *Server) release(reserved float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.reserved -= reserved + s.inflight-- +} + +// callReservation keeps the estimate in the run's books until every receipt +// owed by this call has replaced it with a real charge or an unbilled answer. +type callReservation struct { + mu sync.Mutex + server *Server + amount float64 + pending int + ended bool + released bool +} + +func (r *callReservation) receiptPending() func() { + r.markPending() + done := r.server.owed.owe() + var once sync.Once + return func() { + once.Do(func() { + done() + r.finish(false) + }) + } +} + +func (r *callReservation) markPending() { + r.mu.Lock() + defer r.mu.Unlock() + r.pending++ +} + +func (r *callReservation) finish(ended bool) { + if r.finished(ended) { + r.server.release(r.amount) + } +} + +func (r *callReservation) finished(ended bool) bool { + r.mu.Lock() + defer r.mu.Unlock() + if ended { + r.ended = true + } else { + r.pending-- + } + release := r.ended && r.pending == 0 && !r.released + if release { + r.released = true + } + return release } // arrived remembers the field a thread's working came in on. @@ -527,18 +641,19 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { if thread == "" { thread = delegate.MainThread } - entry, spent := s.open(request, thread, served) + entry, spent, reserved, admitted := s.open(request, thread, served, model) - if ceiling := s.config.Ceiling; ceiling > 0 && ceilingReached(ceiling, spent) { + if !admitted { // 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) + refused := ceilingSentence(s.config.Ceiling, spent) s.log(entry.close(func(turn *delegate.Turn) { turn.Refused = refused })) writeError(w, http.StatusPaymentRequired, refused) return } + hold := &callReservation{server: s, amount: reserved} + defer hold.finish(true) s.log(entry.turn) ctx, stop := s.callContext(r.Context()) @@ -547,7 +662,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { bill := &tally{} catch := &catcher{} slot := &provider.ServedEndpoint{} - response, err := s.complete(ctx, out, request, model, bill, catch, slot, entry) + response, err := s.complete(ctx, out, request, model, bill, catch, slot, entry, hold) // 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 @@ -557,7 +672,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { model = fallback entry.serveOn(seat) catch = &catcher{} - response, err = s.complete(ctx, out, request, model, bill, catch, slot, entry) + response, err = s.complete(ctx, out, request, model, bill, catch, slot, entry, hold) } } @@ -591,7 +706,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { // 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) { +func (s *Server) complete(ctx context.Context, out *reply, request *call, model string, bill *tally, catch *catcher, slot *provider.ServedEndpoint, entry *record, hold *callReservation) (*ai.Response, error) { var completer Completer if s.config.CompleterFor != nil { completer = s.config.CompleterFor(model) @@ -600,7 +715,7 @@ func (s *Server) complete(ctx context.Context, out *reply, request *call, model return nil, errNoRoad } options := append(append([]ai.Option(nil), request.options...), ai.WithModel(model)) - ctx = s.settings(ctx, request, bill, catch, slot, entry) + ctx = s.settings(ctx, request, bill, catch, slot, entry, hold) type outcome struct { response *ai.Response err error @@ -638,7 +753,7 @@ func (s *Server) complete(ctx context.Context, out *reply, request *call, model // 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 { +func (s *Server) settings(ctx context.Context, request *call, bill *tally, catch *catcher, slot *provider.ServedEndpoint, entry *record, hold *callReservation) context.Context { role := s.config.Role if role == "" { role = lanes.RoleLeafUnattended @@ -661,7 +776,7 @@ func (s *Server) settings(ctx context.Context, request *call, bill *tally, catch 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) + ctx = provider.WithReceiptPending(ctx, hold.receiptPending) // 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 — @@ -949,8 +1064,11 @@ func (s *Server) failure(err error, request context.Context, model string) (int, return http.StatusBadGateway, firstLine(err.Error()) } -// ceilingSentence is the refusal a call made past the run's ceiling gets. +// ceilingSentence is the refusal for a call that would cross the ceiling. func ceilingSentence(ceiling, spent float64) string { + if !ceilingReached(ceiling, spent) { + return "the run's dollar ceiling of " + dollars(ceiling) + " would be reached by this call's estimated cost (" + dollars(spent) + " spent), so codeaf made no call" + } return "the run's dollar ceiling of " + dollars(ceiling) + " is reached (" + dollars(spent) + " spent), so codeaf made no call" } diff --git a/internal/provider/modelapi/server_test.go b/internal/provider/modelapi/server_test.go index 5276bb3ad..1dc77f4ee 100644 --- a/internal/provider/modelapi/server_test.go +++ b/internal/provider/modelapi/server_test.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" @@ -296,6 +297,158 @@ func TestACallPastTheCeilingIsRefusedBeforeItIsMade(t *testing.T) { } } +// Eight calls arriving together cannot each spend the same unreserved dollar. +func TestConcurrentCallsReserveTheCeilingBeforeForwarding(t *testing.T) { + started := make(chan struct{}, 8) + release := make(chan struct{}) + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + started <- struct{}{} + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + bill(ctx, model, 100, 20, 0, 0.40) + return saying(model, "ok"), nil + }} + server, api := open(t, modelapi.Config{CompleterFor: calls.completerFor, Ceiling: 1}) + var wg sync.WaitGroup + type answer struct { + status int + payload []byte + } + answers := make(chan answer, 8) + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + status, payload := post(t, api, api.Token, hello) + answers <- answer{status, payload} + }() + } + for range 2 { + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("two calls did not reach the stub") + } + } + close(release) + wg.Wait() + close(answers) + refused := 0 + for got := range answers { + if got.status == http.StatusPaymentRequired { + refused++ + if !strings.Contains(string(got.payload), "the run's dollar ceiling of $1.00") { + t.Fatalf("402 did not name the ceiling: %s", got.payload) + } + } + } + if spent := server.Spent(); spent > 1.40 || server.RefusedAtCeiling() == 0 { + t.Fatalf("eight calls spent $%.2f with %d refusals", spent, server.RefusedAtCeiling()) + } + if refused == 0 { + t.Fatal("all eight calls were forwarded") + } +} + +func TestEstimatedSequentialCallIsRefusedBeforeItCrossesTheCeiling(t *testing.T) { + calls := &script{reply: words("ok", 0.40)} + server, api := open(t, modelapi.Config{ + CompleterFor: calls.completerFor, Ceiling: 1, + ModelPrice: func(string) (float64, float64, bool) { return 0, 0.40, true }, + }) + body := `{"model":"priced/model","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":1}` + for range 2 { + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("under-ceiling call got %d: %s", status, payload) + } + } + status, payload := post(t, api, api.Token, body) + if status != http.StatusPaymentRequired || !strings.Contains(string(payload), "would be reached by this call's estimated cost") || len(calls.calls()) != 2 { + t.Fatalf("third call got %d: %s; upstream %d", status, payload, len(calls.calls())) + } + if server.Spent() != 0.80 { + t.Fatalf("spent %.2f, want two settled calls", server.Spent()) + } +} + +// A request can fall back to the run's seat after its first model refuses it. +// Its reservation must cover the model that can actually bill the answer. +func TestFallbackSeatPriceIsReservedBeforeTheAskedModelIsForwarded(t *testing.T) { + calls := &script{reply: func(ctx context.Context, model string, messages []ai.Message, request ai.Request) (*ai.Response, error) { + if model == "asked/model" { + return nil, provider.ErrNoAPIKey + } + return words("seated", 0.60)(ctx, model, messages, request) + }} + server, api := open(t, modelapi.Config{ + CompleterFor: calls.completerFor, Ceiling: 1, Seat: "seat/model", + ModelPrice: func(model string) (float64, float64, bool) { + if model == "seat/model" { + return 0, 0.60, true + } + return 0, 0.05, true + }, + }) + body := `{"model":"asked/model","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":1}` + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("first call got %d: %s", status, payload) + } + if status, payload := post(t, api, api.Token, body); status != http.StatusPaymentRequired || len(calls.calls()) != 2 { + t.Fatalf("second call got %d: %s; upstream %d", status, payload, len(calls.calls())) + } + if spent := server.Spent(); spent != 0.60 { + t.Fatalf("spent $%.2f, want only the first seat answer", spent) + } +} + +// A call with a pending receipt still occupies its estimate until its real +// charge arrives, even after its HTTP answer has returned to the program. +func TestPendingReceiptKeepsItsReservationUntilTheRealChargeArrives(t *testing.T) { + gate := make(chan struct{}) + defer func() { + select { + case <-gate: + default: + close(gate) + } + }() + settled := make(chan struct{}) + var callsMade atomic.Int32 + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + if callsMade.Add(1) == 1 { + done := provider.ReceiptPendingFrom(ctx)() + sink := provider.ReconcileSinkFrom(ctx) + go func() { + <-gate + sink(provider.Reconciled{Billed: provider.Billed{Model: model, PromptTokens: 1, CompletionTokens: 1, Cost: 0.40}, Found: true}) + done() + close(settled) + }() + } else { + bill(ctx, model, 1, 1, 0, 0.40) + } + return saying(model, "ok"), nil + }} + _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor, Ceiling: 1, + ModelPrice: func(string) (float64, float64, bool) { return 0, 0.60, true }, + }) + body := `{"model":"priced/model","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":1}` + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("first call got %d: %s", status, payload) + } + if status, payload := post(t, api, api.Token, body); status != http.StatusPaymentRequired { + t.Fatalf("pending receipt admitted a second call: %d %s", status, payload) + } + close(gate) + <-settled + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("settled receipt did not free the reservation: %d %s", status, payload) + } +} + // 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 diff --git a/internal/provider/modelapi/wire.go b/internal/provider/modelapi/wire.go index 631eee4ee..036c73681 100644 --- a/internal/provider/modelapi/wire.go +++ b/internal/provider/modelapi/wire.go @@ -76,14 +76,16 @@ type messageExtras struct { // 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 + inputBytes int + outputCap int + 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 @@ -111,9 +113,11 @@ func decodeRequest(body []byte) (*call, error) { return nil, errors.New("the request carries no messages") } decoded := &call{ - asked: strings.TrimSpace(request.Model), - cacheKey: strings.TrimSpace(request.PromptCacheKey), - stream: request.Stream, + asked: strings.TrimSpace(request.Model), + cacheKey: strings.TrimSpace(request.PromptCacheKey), + stream: request.Stream, + inputBytes: len(body), + outputCap: firstCeiling(request.MaxCompletionTokens, request.MaxTokens), } decoded.thread = decoded.cacheKey hasReasoning := false diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index b91dc7179..676c09ed4 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -96,6 +96,8 @@ type DelegateSetup struct { // 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 + // ModelPrice is the known per-token price for reserving model API calls. + ModelPrice func(model string) (input, output float64, known 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 @@ -443,6 +445,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err TaskDir: taskDir, CompleterFor: w.completerFor(), Serves: w.setup.Serves, + ModelPrice: w.setup.ModelPrice, Seat: w.setup.Seat, Ceiling: w.cost, Bank: meter.bank, diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 01b2a6241..a299b760e 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -57,6 +57,7 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar setup := DelegateSetup{ CompleterFor: spec.CompleterFor, Serves: spec.Serves, + ModelPrice: spec.ModelPrice, Seat: WorkSeat(spec.ProfileDir, spec.WorkModel), PlainFolder: spec.PlainFolder, Branch: spec.ProgramBranch, diff --git a/internal/session/agent.go b/internal/session/agent.go index 5a2a6e361..3fa674444 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -236,6 +236,7 @@ func newAgent(config Config, client Completer) (*Agent, error) { } restored := replayed.messages agent.file = file + agent.restoreProgramHold() // AND WHAT AN EARLIER PROCESS OF THIS SESSION MADE. It is the one thing // in the journal that cannot be re-derived from the transcript — whether // a file was there before the session touched it is a measurement, taken diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index df1305383..b2c0d090e 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -370,10 +370,14 @@ func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, } id := g.reserve() title := taskPersonTitle(brief) + note := "" + if program.Name == "senior-dev" { + note = a.seniorDevCeilings(a.Usage().CostUSD).Summary() + } if err := a.startKnownTaskRunVia(ctx, id, title, brief, nil, delegateStand(folder), "", &program); err != nil { return 0, "", "", err } - return id, title, "", nil + return id, title, note, nil } // delegateStand is where a program works: the folder itself, always. One that @@ -396,6 +400,11 @@ func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { if run.folder == nil { return RunLanding{Home: mergeInPlace} } + if err := SetProgramAnswerAttribution(run.folder, a.signsGitWork().named); err != nil { + if g := a.graph(); g != nil { + g.planNote("the program's answered models could not be read: " + err.Error()) + } + } outcome, result := runEndingWords(summary) if result == "" { result = outcome diff --git a/internal/session/program_attribution.go b/internal/session/program_attribution.go new file mode 100644 index 000000000..82fa5a7db --- /dev/null +++ b/internal/session/program_attribution.go @@ -0,0 +1,32 @@ +package session + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/exec" +) + +// SetProgramAnswerAttribution credits the models in the loopback call record +// that answered, rather than the crew selected before the run began. +func SetProgramAnswerAttribution(folder *ProgramFolder, named bool) error { + if folder == nil { + return nil + } + // A log that cannot be read cannot prove that the configured seat answered. + folder.NoAttribution = true + folder.SignModel = "" + models, err := delegate.AnsweredModels(folder.Keep) + if err != nil { + return err + } + folder.NoAttribution = len(models) == 0 + if named { + bare := make([]string, 0, len(models)) + for _, model := range models { + bare = append(bare, exec.BareModelName(model)) + } + folder.SignModel = strings.Join(bare, ", ") + } + return nil +} diff --git a/internal/session/program_outcome.go b/internal/session/program_outcome.go index 761a2a451..d9a693c8b 100644 --- a/internal/session/program_outcome.go +++ b/internal/session/program_outcome.go @@ -25,7 +25,9 @@ package session // count again. import ( + "encoding/json" "fmt" + "os" "strings" "time" @@ -148,26 +150,32 @@ func programNextStep(o programOutcome) string { return fmt.Sprintf("Its work does not stand yet. Read what failed; fix a small gap on its branch yourself, or hand the work back to %s with a brief sharpened by what failed. You may send it back %d more time%s on your own.", o.program, left, plural(left)) } -// rememberProgramOutcomeLocked keeps a program's ending for the turn it -// arrives in, so a hand-off that turn makes is known as a re-attempt of it. +// rememberProgramOutcomeLocked keeps a program's ending until the person next +// speaks, so later automatic turns still know a hand-off is a re-attempt. // The caller holds a.mu. func (a *Agent) rememberProgramOutcomeLocked(user userMessage) { if user.programOutcome != nil { outcome := *user.programOutcome a.programOutcomeNow = &outcome + a.programHold = &outcome + a.saveProgramHoldLocked() } } -// programRetryRefusal is why a hand-off to a program made in the turn a -// program's ending woke may not go, and "" when it may. It is the code half -// of the playbook's two bounds. +// programRetryRefusal is why an automatic hand-off before the person's next +// message may not go, and "" when it may. It is the code half of the playbook's +// two bounds. func (a *Agent) programRetryRefusal(via string) string { if strings.TrimSpace(via) == "" { return "" } a.mu.Lock() - now := a.programOutcomeNow + now := a.programHold + fault := a.programHoldErr a.mu.Unlock() + if fault != "" { + return fmt.Sprintf("%s cannot be sent back automatically: its hand-off history could not be saved (%s)", via, fault) + } if now == nil { return "" } @@ -186,7 +194,7 @@ func (a *Agent) programRetryRefusal(via string) string { func (a *Agent) programAttemptOf() programAttempt { a.mu.Lock() defer a.mu.Unlock() - if now := a.programOutcomeNow; now != nil { + if now := a.programHold; now != nil { return programAttempt{attempt: now.attempt + 1, auto: now.auto + 1} } return programAttempt{attempt: 1} @@ -194,13 +202,52 @@ func (a *Agent) programAttemptOf() programAttempt { // keepProgramAttempt writes down a started hand-off's place in its line, by // the row the run is published under. -func (a *Agent) keepProgramAttempt(row uint64, attempt programAttempt) { +func (a *Agent) keepProgramAttempt(row uint64, attempt programAttempt) *programOutcome { a.mu.Lock() defer a.mu.Unlock() + var prior *programOutcome + if a.programHold != nil { + copy := *a.programHold + prior = © + } if a.programAttempts == nil { a.programAttempts = map[uint64]programAttempt{} } a.programAttempts[row] = attempt + if a.programHold != nil { + next := *a.programHold + next.row = row + next.programAttempt = attempt + a.programHold = &next + a.saveProgramHoldLocked() + } + return prior +} + +// rollbackProgramAttempt returns a refused start's place to the count, because +// only a run that actually started can use one of the automatic hand-offs. +func (a *Agent) rollbackProgramAttempt(row uint64, prior *programOutcome) { + a.mu.Lock() + defer a.mu.Unlock() + delete(a.programAttempts, row) + if a.programHold == nil || a.programHold.row != row { + return + } + if prior == nil { + a.clearProgramHoldLocked() + return + } + copy := *prior + a.programHold = © + a.saveProgramHoldLocked() +} + +// rollbackFailedProgramStart returns the count only when a proposed program +// never started; ordinary tasks have no program attempt to return. +func (a *Agent) rollbackFailedProgramStart(via *delegate.Delegate, row uint64, prior *programOutcome, err error) { + if via != nil && err != nil { + a.rollbackProgramAttempt(row, prior) + } } // programAttemptFor is a run's place in its line, the first run of one when @@ -223,6 +270,10 @@ func (a *Agent) programLandingNote(run *beltRun, summary RunSummary, line string row: run.row, program: programName(run.delegate), verdict: programVerdictOf(summary), programAttempt: a.programAttemptFor(run.row), } + a.mu.Lock() + a.programHold = &outcome + a.saveProgramHoldLocked() + a.mu.Unlock() text := programOutcomeNote(outcome, line, a.beltRunSpent(run.row)) document := userText(text) if task := run.store.Task(run.root); landingOwesAnswer(task) { @@ -237,6 +288,77 @@ func (a *Agent) programLandingNote(run *beltRun, summary RunSummary, line string return note } +type programHoldRecord struct { + Row uint64 `json:"row"` + Program string `json:"program"` + Verdict programVerdict `json:"verdict"` + Attempt int `json:"attempt"` + Auto int `json:"auto"` +} + +// programHoldPath keeps the automatic hand-off bound beside the conversation +// journal, so a reopen does not make another unattended run newly legal. +func (a *Agent) programHoldPath() string { + if a.config.SessionFile == "" { + return "" + } + return a.config.SessionFile + ".program-handoff.json" +} + +func (a *Agent) restoreProgramHold() { + path := a.programHoldPath() + if path == "" { + return + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return + } + if err != nil { + a.programHoldErr = err.Error() + return + } + var record programHoldRecord + if err := json.Unmarshal(data, &record); err != nil { + a.programHoldErr = err.Error() + return + } + a.programHold = &programOutcome{row: record.Row, program: record.Program, verdict: record.Verdict, + programAttempt: programAttempt{attempt: record.Attempt, auto: record.Auto}} +} + +// saveProgramHoldLocked stores the whole bound atomically. A failed write +// refuses further automatic runs until the person's next message resets it. +func (a *Agent) saveProgramHoldLocked() { + path := a.programHoldPath() + if path == "" || a.programHold == nil { + return + } + o := a.programHold + data, err := json.Marshal(programHoldRecord{Row: o.row, Program: o.program, Verdict: o.verdict, + Attempt: o.attempt, Auto: o.auto}) + if err == nil { + err = os.WriteFile(path+".tmp", data, 0o600) + } + if err == nil { + err = os.Rename(path+".tmp", path) + } + if err != nil { + a.programHoldErr = err.Error() + } else { + a.programHoldErr = "" + } +} + +func (a *Agent) clearProgramHoldLocked() { + a.programHold, a.programHoldErr = nil, "" + if path := a.programHoldPath(); path != "" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + a.programHoldErr = err.Error() + } + } +} + // programPageNote is what a program run's own page keeps as its ending: that // the ending went to the conversation, and where the work is. THE PROGRAM'S // STATUS IS NOT ON IT. The page's notes used to carry the whole outcome line — @@ -250,3 +372,46 @@ func programPageNote(program string, landing RunLanding) string { } return said } + +// programLimitLine is written to the conversation before its wake is tried. +// The same conversation limit may refuse that wake, so the model cannot be +// the only messenger of the ending. +func programLimitLine(run *beltRun, summary RunSummary, landing RunLanding) string { + if programVerdictOf(summary) != programLimit { + return "" + } + kind := summary.Limit + if kind == "" && summary.Program != nil { + // senior-dev calls its own elapsed ceiling "wall", while the run + // supervisor reports the same cause as a time limit. + reason := strings.ToLower(summary.Program.Reason) + if strings.Contains(reason, "wall") || strings.Contains(reason, "time") { + kind = RunLimitTime + } + } + scope, limit := "the run's", "" + if kind == RunLimitTime { + if run.conversationTimeLimit { + scope = "the conversation's" + } + limit = (delegate.Ceilings{Hours: run.timeCeiling}).TimeWord() + } else { + if run.conversationCostLimit { + scope = "the conversation's" + } + limit = fmt.Sprintf("$%.2f", run.costCeiling) + } + spent := summary.USD + if spent <= 0 { + spent = run.spent + } + spentWord := "spent no metered dollars" + if spent > 0 { + spentWord = fmt.Sprintf("spent $%.2f", spent) + } + where := "in the folder " + run.ground + if landing.Branch != "" { + where = "on branch " + landing.Branch + " in " + run.ground + } + return fmt.Sprintf("%s stopped at %s %s limit · %s · its work is %s", programName(run.delegate), scope, limit, spentWord, where) +} diff --git a/internal/session/program_outcome_test.go b/internal/session/program_outcome_test.go index 56af69729..4d6786e4c 100644 --- a/internal/session/program_outcome_test.go +++ b/internal/session/program_outcome_test.go @@ -1,13 +1,22 @@ package session import ( + "context" + "io" + "net/http" "os" "path/filepath" "strings" + "sync/atomic" "testing" + "time" + "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" ) // EVERY WAY A PROGRAM'S RUN ENDS READS AS ONE VERDICT codeaf acts on. @@ -59,14 +68,14 @@ func TestTheOutcomeNoteSaysWhatToDoNowAndKeepsBothBounds(t *testing.T) { } } -// THE BOUNDS ARE CODE. In the turn a program's ending woke, a hand-off to a -// program after a limit is refused, and so is one past the retry cap; a -// person's own turn is never refused by either, and starts a new line. -func TestARetryPastTheCapOrAfterALimitIsRefusedOnlyInTheOutcomesTurn(t *testing.T) { +// THE BOUNDS ARE CODE. A program ending keeps its retry cap until the person +// speaks, including across an unrelated automatic turn. +func TestARetryPastTheCapOrAfterALimitIsRefusedUntilThePersonSpeaks(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) set := func(o *programOutcome) { agent.mu.Lock() agent.programOutcomeNow = o + agent.programHold = o agent.mu.Unlock() } if got := agent.programRetryRefusal("senior-dev"); got != "" { @@ -96,8 +105,150 @@ func TestARetryPastTheCapOrAfterALimitIsRefusedOnlyInTheOutcomesTurn(t *testing. agent.mu.Lock() agent.forgetOwedLocked() agent.mu.Unlock() - if got := agent.programRetryRefusal("senior-dev"); got != "" { - t.Fatalf("the next turn still carries the last one's ending: %q", got) + if got := agent.programRetryRefusal("senior-dev"); !strings.Contains(got, "ask the person first") { + t.Fatalf("the next automatic turn forgot the limit: %q", got) + } +} + +func TestAutomaticSeniorDevHandOffCapSurvivesTurnsAndReopenUntilPersonSpeaks(t *testing.T) { + root := t.TempDir() + config := Config{Workspace: root, Model: "test/model", System: "SYSTEM", + SessionFile: filepath.Join(root, "conversation.jsonl")} + a, err := newAgent(config, &scriptedCompleter{}) + if err != nil { + t.Fatal(err) + } + for number := 0; number <= programAutoRetries; number++ { + outcome := programOutcome{row: uint64(number + 1), program: "senior-dev", verdict: programFailed, + programAttempt: programAttempt{attempt: number + 1, auto: number}} + a.mu.Lock() + a.rememberProgramOutcomeLocked(userMessage{programOutcome: &outcome}) + a.forgetOwedLocked() + a.mu.Unlock() + if number < programAutoRetries { + if got := a.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("handoff %d refused: %s", number+1, got) + } + a.keepProgramAttempt(uint64(number+2), a.programAttemptOf()) + } + } + if got := a.programRetryRefusal("senior-dev"); !strings.Contains(got, "sent back to this work") { + t.Fatalf("third hand-off passed: %q", got) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + reopened, err := newAgent(config, &scriptedCompleter{}) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if got := reopened.programRetryRefusal("senior-dev"); !strings.Contains(got, "sent back to this work") { + t.Fatalf("reopen forgot the cap: %q", got) + } + reopened.mu.Lock() + reopened.rememberOwedLocked(userText("please try again")) + reopened.mu.Unlock() + if got := reopened.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("the person's new word did not reset the cap: %q", got) + } +} + +func TestAutomaticSeniorDevHandOffAfterLimitIsRefusedBeyondWakeTurn(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + outcome := programOutcome{row: 4, program: "senior-dev", verdict: programLimit, + programAttempt: programAttempt{attempt: 1}} + a.mu.Lock() + a.rememberProgramOutcomeLocked(userMessage{programOutcome: &outcome}) + a.forgetOwedLocked() + a.mu.Unlock() + if got := a.programRetryRefusal("senior-dev"); !strings.Contains(got, "ask the person first") { + t.Fatalf("later automatic turn passed the limit: %q", got) + } +} + +func TestFailedAutomaticProgramStartDoesNotUseAHandOff(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + outcome := programOutcome{row: 4, program: "senior-dev", verdict: programFailed, + programAttempt: programAttempt{attempt: 1}} + a.mu.Lock() + a.rememberProgramOutcomeLocked(userMessage{programOutcome: &outcome}) + a.mu.Unlock() + attempt := a.programAttemptOf() + prior := a.keepProgramAttempt(5, attempt) + a.rollbackProgramAttempt(5, prior) + if got := a.programAttemptOf(); got != attempt { + t.Fatalf("a start that failed consumed an automatic hand-off: %+v, want %+v", got, attempt) + } + if got := a.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("a start that failed barred another attempt: %s", got) + } +} + +func TestProgramCommitCreditsOnlyItsAnsweredModels(t *testing.T) { + for _, tc := range []struct { + name, want string + turns []delegate.Turn + }{ + {"two answered models", exec.AttributionAssistedBy + " (m2.7, k2.6)", []delegate.Turn{ + {Seq: 1, Model: "crew/unused", Refused: "limit", Ended: time.Now()}, + {Seq: 2, Model: "asked/model", Served: "minimax/m2.7", Ended: time.Now(), Reply: "yes"}, + {Seq: 3, Model: "kimi/k2.6", Ended: time.Now(), Reply: "done"}, + }}, + {"no answered calls", "", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newTestRepo(t) + program := testPrograms("senior-dev")[0] + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: program, Dir: repo, + Title: "Repair", Holder: "test", Keep: t.TempDir(), SignModel: "crew/unused"}) + if err != nil { + t.Fatal(err) + } + for _, turn := range tc.turns { + if err := delegate.AppendTurn(folder.Keep, turn); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(repo, "repair.txt"), []byte("done\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := SetProgramAnswerAttribution(folder, true); err != nil { + t.Fatal(err) + } + folder.Finish("finished") + message := gitOut(t, repo, "log", "-1", "--format=%B") + if tc.want == "" { + if strings.Contains(message, "Assisted-by:") { + t.Fatalf("no answered call still signed the commit: %s", message) + } + } else if !strings.Contains(message, tc.want) || strings.Contains(message, "crew/unused") { + t.Fatalf("commit attribution = %s", message) + } + }) + } +} + +func TestUnreadableProgramCallLogNeverCreditsTheConfiguredSeat(t *testing.T) { + repo := newTestRepo(t) + program := testPrograms("senior-dev")[0] + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: program, Dir: repo, + Title: "Repair", Holder: "test", Keep: t.TempDir(), SignModel: "crew/unused"}) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(folder.Keep, delegate.ConversationFile), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "repair.txt"), []byte("done\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := SetProgramAnswerAttribution(folder, true); err == nil { + t.Fatal("unreadable call log was accepted") + } + folder.Finish("finished") + if message := gitOut(t, repo, "log", "-1", "--format=%B"); strings.Contains(message, "Assisted-by:") { + t.Fatalf("unreadable call log credited the configured seat: %s", message) } } @@ -136,6 +287,118 @@ func TestAProgramsLandingWakesATurnWithThePlaybook(t *testing.T) { } } +// The session writes a limit ending before asking for another model turn, +// because that same limit may refuse the wake. +type chargedSeniorDevStub struct{ calls atomic.Int32 } + +func (s *chargedSeniorDevStub) CompleteWithMessages(ctx context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { + s.calls.Add(1) + if sink := provider.BillingSinkFrom(ctx); sink != nil { + sink(provider.Billed{Model: "stub/model", PromptTokens: 100, CompletionTokens: 20, Cost: 0.40}) + } + return textResponse("stub answer"), nil +} + +func TestSeniorDevLimitLandingIsVisibleWhenTheWakeIsRefused(t *testing.T) { + completer := &scriptedCompleter{} + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.SpendRailUSD = 1 + config.AskConsent = false + }) + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "the run", "1", "Repair", "repair the parser") + if err != nil { + t.Fatal(err) + } + defer store.Close() + program := testPrograms("senior-dev")[0] + updates, stop := agent.WatchTaskUpdates() + defer stop() + run := &beltRun{store: store, root: store.RootID(), row: 7, delegate: &program, + ground: "/project", costCeiling: 1, conversationCostLimit: true} + agent.beltMu.Lock() + agent.beltRun = run + agent.beltMu.Unlock() + stub := &chargedSeniorDevStub{} + model, err := modelapi.Open(modelapi.Config{ + Ceiling: 1, + CompleterFor: func(string) modelapi.Completer { return stub }, + Bank: func(charge modelapi.Charge) { + run.spent = charge.Spent + agent.mu.Lock() + agent.usage.CostUSD = charge.Spent + agent.mu.Unlock() + }, + }) + if err != nil { + t.Fatal(err) + } + defer model.Close() + api := model.API() + for range 3 { + request, err := http.NewRequest(http.MethodPost, modelapi.ChatURL(api.BaseURL), strings.NewReader(`{"model":"stub/model","messages":[{"role":"user","content":"work"}]}`)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer "+api.Token) + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(response.Body) + response.Body.Close() + if err != nil || response.StatusCode != http.StatusOK { + t.Fatalf("stub call: status %d, body %s, read error %v", response.StatusCode, body, err) + } + } + if got := stub.calls.Load(); got != 3 || run.spent < 1.19 || run.spent > 1.21 { + t.Fatalf("stub answered %d calls and spent $%.2f, want three $0.40 calls", got, run.spent) + } + agent.deliverBeltRunLanding(run, RunSummary{Outcome: "a limit you set stopped it", Limit: RunLimitCost, USD: run.spent}, + RunLanding{Branch: "task/repair"}) + agent.mu.Lock() + var transcript string + for _, message := range agent.messages { + transcript += messageContentText(message) + "\n" + } + agent.mu.Unlock() + for _, want := range []string{"stopped at the conversation's $1.00 limit", "spent $1.20", "task/repair"} { + if !strings.Contains(transcript, want) { + t.Fatalf("limit ending lacks %q: %s", want, transcript) + } + } + if completer.requests() != 0 { + t.Fatalf("the blocked wake called the model %d times", completer.requests()) + } + deadline := time.After(2 * time.Second) + for { + select { + case event := <-updates: + if event.Kind == EventNotice && strings.Contains(event.Text, "stopped at the conversation's $1.00 limit") { + return + } + case <-deadline: + t.Fatal("the open surface was not sent the limit line") + } + } +} + +func TestSeniorDevTimeLimitLineNamesTheFolderWithoutABranch(t *testing.T) { + program := testPrograms("senior-dev")[0] + run := &beltRun{delegate: &program, ground: "/project", timeCeiling: 0.5, spent: 0.40} + for _, summary := range []RunSummary{ + {Limit: RunLimitTime, USD: 0.40}, + {Program: &ProgramEnding{Status: delegate.StatusBudget, Reason: "senior-dev stopped on its own ceiling: wall 1800s >= budget 1800s"}, USD: 0.40}, + } { + line := programLimitLine(run, summary, RunLanding{}) + for _, want := range []string{"stopped at the run's 30m limit", "spent $0.40", "in the folder /project"} { + if !strings.Contains(line, want) { + t.Fatalf("time limit line lacks %q: %s", want, line) + } + } + } +} + // A SECOND RUN IN A FOLDER THE FIRST LEFT ON ITS BRANCH CARRIES ON THERE. It // names the person's own branch, keeps every run's work on one branch, and a // run that adds nothing never deletes what the first one committed. diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index 9f0c1feb2..d677a61bb 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -155,6 +155,9 @@ type ProgramFolder struct { // ([ProgramFolderOrder.SignModel]). Keep string `json:"keep,omitempty"` SignModel string `json:"signModel,omitempty"` + // NoAttribution is true when the run had no answered model call, so its + // finishing commit does not credit a model that did no work in this run. + NoAttribution bool `json:"noAttribution,omitempty"` // Ended is the sentence the run's folder was finished with. Empty is a // folder still owed its ending. Ended string `json:"ended,omitempty"` @@ -825,7 +828,10 @@ func (f *ProgramFolder) commitLeftovers(result string) string { message += "\n\n" + result } args := append([]string{"-c", "commit.gpgsign=false"}, codeafGitIdentity()...) - args = append(args, "commit", "-q", "--no-verify", "-m", signed(message, gitSignature{named: f.SignModel != "", model: f.SignModel})) + if !f.NoAttribution { + message = signed(message, gitSignature{named: f.SignModel != "", model: f.SignModel}) + } + args = append(args, "commit", "-q", "--no-verify", "-m", message) if out, err := git(f.Dir, args...); err != nil { return "git commit: " + firstLine(out) } diff --git a/internal/session/prompts/program-outcome.md b/internal/session/prompts/program-outcome.md index 2f6da6c2d..374ea3a0a 100644 --- a/internal/session/prompts/program-outcome.md +++ b/internal/session/prompts/program-outcome.md @@ -8,6 +8,6 @@ The note ends with a line in brackets: the task, how it came out (passed, unveri - limit: it stopped on a dollar or time ceiling. Never hand it back on your own: say briefly what is done and what is left, and ask the person whether to spend more. - crashed: it broke rather than finished. Hand it back once if the cause looks passing (network, provider, a timeout); otherwise tell the person what broke. -codeaf sends a program back to one piece of work at most twice on its own; a hand-off past that, or after a limit, is refused, and then the person decides. +codeaf sends a program back to one piece of work at most twice on its own between messages from the person; a hand-off past that, or after a limit, is refused, even after a wake turn or reopening the conversation. The person decides and a new message from them resets the count. Keep the person's view simple. Do not paste the program's status words or its log; say what now works, what does not, where the work is (the branch and folder), and the one thing they might do next. If the work still does not pass after the last attempt, say so plainly: never report unfinished work as done. diff --git a/internal/session/senior_dev_limits_test.go b/internal/session/senior_dev_limits_test.go new file mode 100644 index 000000000..fa0f5ee63 --- /dev/null +++ b/internal/session/senior_dev_limits_test.go @@ -0,0 +1,72 @@ +package session + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +func TestSeniorDevRunWithoutConversationLimitsHasFiniteCeilings(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + program := testPrograms("senior-dev")[0] + run := &beltRun{delegate: &program} + spec := a.beltRunSpec(run, "repair it") + if spec.CostUSD != delegate.DefaultSeniorDevCostUSD || spec.Elapsed != time.Duration(delegate.DefaultSeniorDevHours)*time.Hour { + t.Fatalf("senior-dev got cost %.2f and wall %v", spec.CostUSD, spec.Elapsed) + } +} + +func TestSeniorDevRunUsesRemainingConversationLimitsBelowDefaults(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.SpendRailUSD = 5 + config.Budget = Budget{Wall: 2 * time.Hour} + }) + a.mu.Lock() + a.usage.CostUSD = 1 + a.mu.Unlock() + a.startedAt = time.Time{} + program := testPrograms("senior-dev")[0] + spec := a.beltRunSpec(&beltRun{delegate: &program}, "repair it") + if spec.CostUSD != 4 || spec.Elapsed != 2*time.Hour { + t.Fatalf("senior-dev got cost %.2f and wall %v, want the remaining conversation limits", spec.CostUSD, spec.Elapsed) + } +} + +func TestTypedSeniorDevStartSaysItsEffectiveCeiling(t *testing.T) { + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + workspace := newTestRepo(t) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: t.TempDir()} + config.Delegates = testPrograms("senior-dev") + config.SpendRailUSD = 2 + }) + id, _, note, err := agent.StartDelegate(context.Background(), "senior-dev", "repair the parser") + if err != nil { + t.Fatal(err) + } + if note != "up to $2.00 and 3h" { + t.Fatalf("typed start said %q", note) + } + <-double.entered + endBeltRun(t, agent, double) + if id == 0 { + t.Fatal("the typed start returned no task") + } +} + +func TestSeniorDevManualDefaultFiguresFollowTheConstants(t *testing.T) { + page, err := os.ReadFile("../manual/chat/senior-dev.md") + if err != nil { + t.Fatal(err) + } + want := (delegate.Ceilings{}).SeniorDev().Summary() + if !strings.Contains(string(page), want) { + t.Fatalf("senior-dev manual lacks %q", want) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 5b060cf1b..816a6f23e 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2665,6 +2665,10 @@ type Agent struct { // woken with, nil for every other turn: a hand-off it makes is a re-attempt // of that run ([Agent.programRetryRefusal]). Cleared with owedAsks. programOutcomeNow *programOutcome + // programHold is the last program ending since the person's own words. It + // survives wake turns and reloads from the conversation's sidecar record. + programHold *programOutcome + programHoldErr string // programAttempts is each started program run's place in its line of runs, // by row ([Agent.keepProgramAttempt]). programAttempts map[uint64]programAttempt diff --git a/internal/session/task.go b/internal/session/task.go index bfe49be7f..72060254e 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -922,10 +922,12 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec stand = delegateStand(stand.dir) } asked := programAsked(spec) + var prior *programOutcome if via != nil { - a.keepProgramAttempt(p.id, a.programAttemptOf()) + prior = a.keepProgramAttempt(p.id, a.programAttemptOf()) } joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) + a.rollbackFailedProgramStart(via, p.id, prior, err) if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { return refusal.Error(), true, true } @@ -1243,6 +1245,9 @@ func (a *Agent) openTask(ctx context.Context, id uint64, spec taskSpec, elsewher deadline = a.taskClockNow().Add(countdown) } question := newTaskQuestion(id, spec, elsewhere, deadline, a.config) + if spec.via == "senior-dev" { + question.notice.Ceiling = a.seniorDevCeilings(a.usage.CostUSD).Summary() + } if a.taskAnswers == nil { a.taskAnswers = make(map[uint64]*taskQuestion, 1) } diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 5b6ad2899..7fe886e15 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -484,6 +484,9 @@ type TaskNotice struct { // publisher that forgets it has not changed it ([Agent.publishRunRow] carries // it forward). Program string + // Ceiling is the finite allowance a proposed program run will start with, + // spelled for the approval card. Ordinary tasks leave it empty. + Ceiling string // ── proposal fields (EventTaskProposal) ───────────────────────────── diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 7ae253632..315a209a2 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -66,6 +66,22 @@ func runCostLeft(limit, spent float64) float64 { return left } +// seniorDevCeilings caps the conversation's remaining allowance at the +// unattended run defaults, so an unset conversation limit is still finite. +func (a *Agent) seniorDevCeilings(spent float64) delegate.Ceilings { + wallLeft, _ := a.config.Budget.Left() + if a.config.Budget.Wall > 0 && !a.startedAt.IsZero() { + wallLeft = a.config.Budget.Wall - time.Since(a.startedAt) + if wallLeft <= 0 { + wallLeft = time.Nanosecond + } + } + return (delegate.Ceilings{ + CostUSD: runCostLeft(a.railCap(0), spent), + Hours: wallLeft.Hours(), + }).SeniorDev() +} + // RunSpec is one run as the door hands it to the engine: the store to drive, // the working copy its workers share, the run's own words, the conversation's // two limits, and the provider its workers are seated on. @@ -125,6 +141,8 @@ type RunSpec struct { // every model the program names, and answers a model nothing here can // reach on the run's work seat instead. Nil answers yes for every model. Serves func(model string) bool + // ModelPrice is the catalog price used to reserve each model API call. + ModelPrice func(model string) (input, output float64, known bool) // OnSpend observes the reconciled cumulative run spend while work is live. OnSpend func(float64) // OnCharge observes each priced call a worker that meters call by call @@ -321,6 +339,10 @@ type beltRun struct { // goroutine while the run is ending. ended time.Time spent float64 + // These are the ceilings fixed at hand-off, before the conversation spends + // more; the limit ending reads them even when its wake cannot run. + costCeiling, timeCeiling float64 + conversationCostLimit, conversationTimeLimit bool // delegate is the program this run's root is handed to, nil for a run the // conversation's own workers drive; folder is the folder a program that // edits files works in, held for the run and finished when it ends @@ -570,10 +592,29 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri PlanTask: planStoreID(storeID), }) - go a.driveBeltRun(runCtx, engine, run, a.beltRunSpec(run, brief)) + spec := a.beltRunSpec(run, brief) + if programName(via) == "senior-dev" { + run.costCeiling, run.timeCeiling = spec.CostUSD, spec.Elapsed.Hours() + run.conversationCostLimit = a.railCap(0) > 0 && runCostLeft(a.railCap(0), a.Usage().CostUSD) <= delegate.DefaultSeniorDevCostUSD + run.conversationTimeLimit = a.seniorDevConversationTimeLimit() + } + go a.driveBeltRun(runCtx, engine, run, spec) return false, nil } +// seniorDevConversationTimeLimit reports whether the person's remaining wall +// limit, rather than the unattended default, is the one that will stop the run. +func (a *Agent) seniorDevConversationTimeLimit() bool { + if a.config.Budget.Wall <= 0 { + return false + } + remaining := a.config.Budget.Wall + if !a.startedAt.IsZero() { + remaining -= time.Since(a.startedAt) + } + return remaining <= time.Duration(delegate.DefaultSeniorDevHours*float64(time.Hour)) +} + // lockBeltStart takes the conversation's start lock, the one door every road // that may open a run's store passes ([Agent.startOrJoinTaskRun] and // [Agent.ContinueRun]). It is held from the look for a live run until the run @@ -778,13 +819,18 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { wallLeft = time.Nanosecond } } + cost := runCostLeft(a.railCap(0), a.Usage().CostUSD) + if programName(run.delegate) == "senior-dev" { + ceilings := a.seniorDevCeilings(a.Usage().CostUSD) + cost, wallLeft = ceilings.CostUSD, ceilings.Elapsed() + } return RunSpec{ Store: run.store, Workspace: run.workspace, Title: run.title, Brief: brief, Slots: a.config.TaskParallel, - CostUSD: runCostLeft(a.railCap(0), a.Usage().CostUSD), + CostUSD: cost, Elapsed: wallLeft, // The step cap a node of this session's own tree carries, so a run // worker and a node worker stop at the same figure. @@ -798,6 +844,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { PlanModel: planSeat, CompleterFor: func(string) Completer { return a.beltRunCompleter() }, Serves: a.servesModel, + ModelPrice: a.config.ModelPrice, Conversation: a.runConversation(), Delegate: run.delegate, PlainFolder: run.folder != nil && run.folder.Plain(), @@ -1617,6 +1664,9 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { line := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) if run.delegate != nil { + if limit := programLimitLine(run, summary, landing); limit != "" { + a.recordProgramLimit(limit) + } a.accept(delivery{origin: fromRuntime, kind: msgResult, note: a.programLandingNote(run, summary, line)}) return } @@ -1638,6 +1688,22 @@ func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing a.mu.Unlock() } +// recordProgramLimit gives the open surface the same authored line the +// conversation keeps. The model wake may be refused by this very limit, so +// the standing lane must paint it without waiting for another turn. +func (a *Agent) recordProgramLimit(line string) { + note := userText(line) + note.authored = true + a.mu.Lock() + a.recordUserLocked(note) + watchers := append([]*eventStream(nil), a.taskWatchers...) + a.mu.Unlock() + event := Event{Kind: EventNotice, Text: line} + for _, watcher := range watchers { + watcher.send(event) + } +} + // landingOwesAnswer admits only an owed work root to the one bounded reply turn. func landingOwesAnswer(task *plandb.Task) bool { return task != nil && strings.TrimSpace(task.Question) != "" && task.ParentID == "" && task.Role != plandb.RoleCheck diff --git a/internal/session/wakecause.go b/internal/session/wakecause.go index d7392cc01..57bb78a6f 100644 --- a/internal/session/wakecause.go +++ b/internal/session/wakecause.go @@ -81,6 +81,9 @@ func (a *Agent) rememberOwedLocked(user userMessage) { // THE PERSON'S OWN MESSAGE, on the same test [Agent.rememberAskLocked] makes: // a note the session authored and a wake are the session talking to itself. if !user.authored && !user.wake { + if !user.resumed { + a.clearProgramHoldLocked() + } a.oweLocked(owedAsk{text: user.text(), from: owedByPerson}) } // AND WHAT EACH RESULT WAS OWED, which is the effective target where the diff --git a/internal/tui3/senior_dev_ceiling_test.go b/internal/tui3/senior_dev_ceiling_test.go new file mode 100644 index 000000000..e1fd4850a --- /dev/null +++ b/internal/tui3/senior_dev_ceiling_test.go @@ -0,0 +1,46 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestSeniorDevProposalCardNamesEffectiveCeiling(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.proposeTask(session.Event{Kind: session.EventTaskProposal, Tool: "propose_task", Task: &session.TaskNotice{ + ID: 7, Title: "Repair the parser", Brief: "repair it", Program: "senior-dev", Ceiling: "up to $1.00 and 30m", + }}) + card := a.cardFor(7) + if card == nil || !strings.Contains(a.taskMetaWord(card, 80), "up to $1.00 and 30m") { + t.Fatalf("proposal card lost its effective ceiling: %+v", card) + } +} + +// A landing while the task page is open must still enter the conversation. +func TestSeniorDevLandingWhileTaskPageOpenPostsAnEndedCard(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.Update(taskEventMsg{gen: a.taskGen, ev: session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 7, Title: "Repair the parser", State: session.TaskRunning, Program: "senior-dev", + }}}) + a.openRoom(7, "Repair the parser") + a.Update(taskEventMsg{gen: a.taskGen, ev: session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 7, Title: "Repair the parser", State: session.TaskDone, Program: "senior-dev", Report: "done", + }}}) + if at := a.doneEntryFor(7); at < 0 || a.entries[at].done == nil || a.entries[at].done.program != "senior-dev" { + t.Fatalf("landing with page open posted no senior-dev card: %+v", a.entries) + } +} + +func TestSeniorDevLimitNoticeAppearsOnTheOpenConversation(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + line := "senior-dev stopped at the conversation's $1.00 limit · spent $1.20 · its work is on branch task/repair" + a.taskEvent(session.Event{Kind: session.EventNotice, Text: line}) + for _, entry := range a.entries { + if entry.kind == entryNote && entry.text == line { + return + } + } + t.Fatal("the live conversation did not paint the limit notice") +} diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 6c6a1b463..32ee61595 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -85,6 +85,8 @@ type taskCard struct { // about it a person approving it cannot find out afterwards and do anything // about. program string + // ceiling is the finite allowance on a program proposal, before approval. + ceiling string // elsewhere is the one dim line saying which of this brief's files another // window's work is already in, as the engine wrote it (session's // TaskNotice.Elsewhere), and "" when there was nothing to say. @@ -888,6 +890,10 @@ func waitTask(ch <-chan session.Event, gen int) tea.Cmd { func (a *app) taskEvent(ev session.Event) tea.Cmd { var pilot, mentions tea.Cmd switch ev.Kind { + case session.EventNotice: + // A program's limit ending reaches this standing lane even when the + // same limit refuses the model turn that would otherwise announce it. + a.note(ev.Text) case session.EventTaskProposal: a.proposeTask(ev) case session.EventTaskUpdate: @@ -1297,6 +1303,7 @@ func (a *app) proposeTask(ev session.Event) { dependsOn: notice.DependsOn, model: strings.TrimSpace(notice.Model), program: strings.TrimSpace(notice.Program), + ceiling: strings.TrimSpace(notice.Ceiling), elsewhere: strings.TrimSpace(notice.Elsewhere), deadline: notice.Deadline, born: a.now(), @@ -2185,6 +2192,9 @@ func (a *app) taskBranchPoint() string { // then never read. A narrow frame cuts the hint and keeps the model. func (a *app) taskMetaWord(card *taskCard, width int) string { var parts []string + if card.ceiling != "" { + parts = append(parts, card.ceiling) + } if card.model != "" { parts = append(parts, taskModelTag+card.model) } From 9bdf25e7de887608ebf8fe846ba7b0040a582b19 Mon Sep 17 00:00:00 2001 From: agentfield-bot <agentfield-bot@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:31:49 -0400 Subject: [PATCH 165/195] README: the benchmark chart points to its numbers The README keeps the chart and one paragraph; the table, the later V4.1 Flash and Kimi K3 runs, the setup and the limits live in docs/benchmarks/deepswe. The chart's footer now says only "same model". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7ShhY74oyWjYGB3SougdE --- README.md | 38 ++++----------------------- assets/readme/benchmark-deepswe.webp | Bin 308434 -> 263716 bytes docs/benchmarks/deepswe/README.md | 23 ++++++++++++++++ 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 9d962dec3..8ccde4f36 100644 --- a/README.md +++ b/README.md @@ -146,39 +146,11 @@ A run is a task like any other, on `home`, with a room and a stop. <img src="assets/readme/benchmark-deepswe.webp" alt="First on DeepSWE: senior-dev, CodeAF's developer subharness, solved the most tasks (54.9%) at the lowest cost per solved task (1x). Every other harness solved less and paid more per solve: mini-swe-agent 1.9x, codex 2.1x, pi 2.4x, claude-code 3.4x, omp, kilo and opencode about 4.5x, muse-code 11.3x, deepseek-harness 26.6x." width="100%"> `/senior-dev`, CodeAF's developer subharness, against nine other coding harnesses -on the full DeepSWE set: 113 real GitHub issues, one -attempt each, the same model (DeepSeek V4 Flash through OpenRouter), graded by the -official verifiers. - -| 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. - -Read it with its limits. One seed per harness, so the gap to mini-swe-agent is -not statistically resolved. senior-dev sent the provider's default sampling; the -other nine sent temperature 1.0 and top-p 0.95. Five tasks in four other harnesses -produced no verifier result and count as unsolved. Cost is billed OpenRouter -spend divided by 113. - -Since then, on the same 113 tasks: 88 solved (77.9%, 95% CI 69.1% to 85.1%) with -DeepSeek V4.1 Flash, and 78 (69.0%) with Kimi K3. Those runs are senior-dev -alone, not a comparison. - -Per-harness numbers: [docs/benchmarks/deepswe](docs/benchmarks/deepswe/). -Earlier single-repository comparisons: [BENCHMARKS.md](BENCHMARKS.md). +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/assets/readme/benchmark-deepswe.webp b/assets/readme/benchmark-deepswe.webp index 047e106739444e899e591a1561e4bb09105042c2..0c6404068e50fff1f6c2febe6d43e9be2150fc98 100644 GIT binary patch literal 263716 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 q?&D+QsH{(X(Hi#oc5X#|CtGcM4~vEdTbEj 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%0Ah|4 z@EBtNLatO17ZCPaWst*}7SzpK_I%O!NXS#??)Y}`Y|*j1LJErV-r_=tQI`mtA!9X3 z`f8-NM&EYU^0X{&BIym~;c<ip!GJ!CoU9JGA;@L?tP<}>Ktfzf+&O-TRnp9jZqWmO zGV?op|Eyq!_*<C{t%u4D4mES0`ETDqgCt}AH!QgF@eS0sYc+XnG*RNQ)g7_J31_U$ z28|Q>SqxD~Qwq;clQu!HxH6`aqLH`vud&2CNv$;Ixi#oRr6R+I+`{W-&&?k2N<vsQ z&na0@8a)Sy!T|m!Ik%EwO9nRlKOQ82%emr@vzsLS?`|rz@5qMD930};c&GLh6VoWX zCK|d8ZQE-ZJwYrUPB_DA0=6#|RCgQXT`g42+pmz2o9n>m!y>W8iZrk4<t0->?~&Hl zXA~%Gu2@XKl0TsC6S4qe>F;9dP&@SH%Ze+<NHwkz;zAHM8Tk9(cVyh;v{=AYPE<L2 zQzssnT-GF+G2{~=0_8oixr?=ObU~Cs?lntBV9`}@+>@auG6-!Lo#qi!4MHag;aO=* zJgWaJ?FzVI6E$Ixt&E%3j(VuEG}SP}OZT^CG=2h`!3M=)fm$F<TO?4fKd#ia)F#$y zS%UgcY@ILB5hD<qcLHKA1PWc&A?-GYt$urK9Z7(o9}n#aERTqB>U3KCZzTHvW(q== z%MLYh_ICWJywzEJg%uAvrL#2l`wgG71sI%7n{-fobbM3Ma%T|p$}#xf(8riS^9DmH zWj%L|3T36sG$UbXDLmJ3=vHfg98(GzGskWe?1yDVbFPU0Y%{ra>UrP)$F9iW8y*p6 z!L*NrqrWH(iK@Y(*P?Y8X=yu#a9w{edxT<sCo*J9eY-beLzm36sNmhXSuDmKsFGa^ ze7;t81SRcX=H>12(WyF?C2zbOK}TLvX=Q_LpeH**s`DXh#qfp@I%ntDggLfFUMcIP z7_zU?YVA>@O9`WFu>Iq!4n2g{2{GlhN&Bdx_zQ|vVZwiYs~BEokU#$Ltj45@c-<Jv z^hdsD0t<$@2`@>!_fSD+*LkCdrk9@f(sdnFCCIiFR9$qKQt}rXX=wu?N+=zk9bf;m ze@w}78NVA_6!m{bP@<<-x~sBM{xJ8HpeIdVA_}`mb8-;Rh{gNvpPJxLq}3}-!TjaR zc(>(e+&MV#uuqV2m2@fhue$I@8~k<>Ks;M>qTt8tj#26JY|42#_jxi}abRGpq|CZ7 z=HB!f)uX`6d-p)!LF|zmovc&+Gskt{Frgt80TF@Fav#}yZE>2?<n#<XEtfwN(Uf_t zjct`OC8QAn6s5D+)lOCO$r+vOu33H>h2011oT(5-bjGd%S%k<n6{Sj2G~3OJ0GiY= zx++O6t?;mVPe0p~V<9ES)jmn;c>-Pufr)fCYOopBNF*K!a4jI+voPJerfyEGerwRG zsxK_f&s6`UPl_<Fx}D*q9(s1670?kxE*9k2Hm%En?fOZi`Jhx(fyGOXl!KPkmNZa< z7Yd|R6MkV~v#q{pxi$wi++r?ccr+>{%Fsu~shH)jCGT_0o4f!4vy8bN1R@DLCnze_ z*f@ucH%3;zj-)+Fog*Lm6sDzg&CJqO>>_Wv*z-0xD2SJ3gi8Ql?X^RH*tT^J><XgL zOtbK$Dx4H(qY~S-K?DPN<E&~UFuf_7v6?bCA*ob7E2WQk$%ypeu|u)in6rSqu@Y?Y zR>0H9KHPtzWNWzr%33~U2}WdUH!Lraa%@b%Nl3%*I9=E^(8B1QwTKhMcoU|WVr4HA zsMhEiWNy9fHU+TLt0qjc8cAAJbV?b<tTLj>TW<;9Wtv{_d@#l#YP9cACslU|O7=H^ z>TPKx{W}`5vm`l)jOeO15Ttpc6h4b|l->djnl(M7z{<7aYg4T3DWvS@!BR&@)sUb( zFN4q5^hg&WyBrkW`wOmPZw7rt9fu`Tk<-bvoJj`7h3|EIdo@gmpB2YOfbDiSY7HS> zAN~y@_>l%Wz~4rJZg+hoGu0DZmfH5rf}(L*X;>a;5c#z*;>63v_gSvWtPEh?bj&q_ zxj^0TndoHJ>;E^l0J@bXQ3cNbZFMU)uHOHJn=c?`C4$4H8z&B-#LgMKBbG{4)SrK- z;@3Z@Zf5^;t|-9^CO{_URW<$}l<#4ckU}A_dSTmD@E2vc<7{%{t>+-A!Q^|72f^a8 zx_B247qiQ-jpzF|En@u8i3sy~PxHdj;UIwOv!ozo(m~Zregm-U%zlS_VE>fC^Eo21 zd)pBLoOK8hkhSXa=)`=>wR8UU)2RWWr(?mFvWGy2Y0;ckk%r~9814DqWtf?ss~eZA zviD5Ggj3xI>{y^}G=;R^&;Oj_!OPG};49WNSeo@stCho_rjb>+q}#wBJUzc0=J_bP zZSmt^?G?+a7d>7D-bM&?-9F$mU~HIJg#)sCxX$yU)DYONp4-?UdzrNBi;DZvMwtwh z+2W8R{AivvkxH&(_dmx<v}L^pZ{kn#cdj^UtDj5Qp!|?`8%8Y$hR<(h1+%tgivC~e zYa8xb$<Hb2t}Q=GmjeeS(85TO1x*{F(#@`7)uks|(Z!8ssG$Q0(5~cBLrA7V@uo5O zMI1*!t+~WC9bn$_>lI(g{-QR_B3F3hwHHH4m9<>^K5hIAHXGnoHPt7vN1w1Ju*}ji zp24NnCEtXP{g{Wmho92#a>TdokC%+J*Pf6@dOlv>?q1F`oRheFapyy`BIPxR^@6VO zG>x^k@j0bU()1rUt0nnj;6Aob3o>R0*6%pVq#r3jVHT*yCF)LBp-m%2Z8D>M!TL8M z2clN)1-CJd@3FY`?GTfS5cca(z1|nM+Q#HCEb0~l?>y_%MI2^iA6DKmTZiR6dJMJ6 z^et`c)J)3PiY9X5nS!xxwQE_g|L|ukd@jz8Q)_fzG0%IICK(|s46~~wzMLf;m)isk zCw(G)Fv0sWd_?x*w5ZPGTKV-}=oy}cClV|_ih0fH{Bap-t)0|XY>n9^I{p_>d&L5F z?}JC=<qvl>3E5u=erI%m`qcuot<G}s(n&|!$Gd|5l-jmSzKG@kAFpR3CZLFGpghkE zi!~BR>EW1+11$QI?jc>@1GJHP^q#sZx}HCo1~|E;4W6qXu_|cafi;!|LiQqxqn0HY zO4>Ari}dNMm^Q3TKPt;@!;=TYBQM+hg<%dk4-P#rrK0<NSWjd_Q3~qV4xkhxG_9+i zG<;h`I{xCp2OZORFWSi4g5LJg$&W2Hb(mCWQN!!ZqGN=hWQ6sLqb1Tfp+}|jM9p`} z#jPka@^h;Zdo11VzL$1tstLHD_M*?CeZVyfKI%V~*<xquE^#tzM(p}B?Msv{G%_9j z-G&Tyg#r&zg=6?#22+DH?v75e8-P$P_hrehd~r0?b$<t>2ma;f4y86=7mtO7Zxl;y zU>sWAue+$RTKZ^4VtOqC%iughoVTjlONZk+Ee0nskg;co#H{`MUB;<zUX14RwOxGr z!}>Z%Ikb;MgW*OGp#bzTMY!^6z$h)<F(0d94Sa*v$jC7Js=1V*0?(SEPkik6hQ6V7 zqjIxlQ{VHh%ApiO2u}U-h;^%9GNjEbXh_j}frzxA41dalGxTXwE4qODVv`ElJB86F z96>_&1<J}j)Syg&RIx#Bg`}nSiw!|$86f?|wADb&uB?Vz<77>Xlzy2p6v?+;gk!(} z5<;K5bE%V7jjbA7B~8D{@pg$N>fihba{pRCK3aQDO4d}}-3^QCKK?tv2=BKk^>-3> zxjj>+P;%_!rN$v>Yc*DCv`l1f9~@pS1W^vieU%vOpwI{XY;al8fB|jGM5G=mr<_17 z&9A~K(iraBV8Q(MV+-!FwyI8uqtb$;3(REwF_6_Hd`uoPzZO>Q$7wS?_WfxZYgt1^ z?KAB^l92|Hc0nYx?B1kc)5=}Ipk(jwb8gNRh3m3P=d(d2Kyi&;&MWKKPEQ{0XSeF* zj}l~TXv{!GxdSQzM~A+FyccPrU*+<ramrT&d-qs^3g?H9Akg7iejsm!*=3IE1>q9# z?%wIws=9JYEuv*V!WQ<qvZqXPg{-z*)r<b&()xB>J_!5;AI56c)t%1Z;5|7p(kBMf zdfdZBpd1|=ImCG8v%Im(v5W|Lq}&umS*xxIjl}^eA9(w~)TiM=Kc`D2olM$mh^Psq z{=UB^PkqLPyp>h4=Y-;>Sfkd`Tb%DT{7>j9Vc)xc-FS!+fta<yPiFbj(?1MZvaY<3 z=MGqBe`Bs08@k8b)|h&W_p`03aCq;_j9AlpyVvt4CA<EsNT_XV%-ylFvjwVj+rQZy zLhgAeBn;41&)%Ri9$I*l2Bv?ctui<+Lp;e4XQebVpYk1SE^FP;FY5{6n_q#a>Q?D2 z&+%&#sF<Gr1`27&LAbdxgt^ua=;%7WuQED7;tlkZd16q{C{Dn%fES(hY2=SqvWd3u zV8*8TlYMRw^tqO7oxcnzsi1!`+lLHE2jC}7U?_Cn$M;ktU=k#Ssj;qp?tY{*CcN{J zo+i1FRV^hlmoiJx&RSV$WzO%v?%PqIp)Ts!$j~2g39)HKGshlpCyo1;Fq6_{7gS&a z=CAuKD{XsX=;z7vM=1#|__PffdU2*ot$u^@j>fgUms(?n@x8pwT}$v%g=xHN;)0{3 z<&M5U^>+M?I*4=v%U#`s3dCrel(n{8H9c!4f{2ZrzTL7nS7beOWu^#2)bvEOH=pVs z<=RizZvM_Ij(GQ!%&USCE;8y@^+oy<aDM0J!owbXD!@b7#|I*$*0ENrE1N(v$@tBW zA#MR5Qd6;4_Rc}|!*}GJnS{?-11T|WG%;fW923ndeC08=Sd})j)j0}C#1=`&Nogfm zCf2#sK*cGQ0h!fF53F~F?mG};H-|X3s@_0UQ7S9C?vGo7m(TNH=*xGVu6reAaa_=U zh2fu+kCPN=OK>QM)^1mzq0-#(Y@a=WISwto#(imVrQcam<|UqJjo3(FJ0!FM5$QM& zzcr*Re72kHyMa{_Vk0@xt3snNWy+!g_N|@gjC7UV57@E+iWTG`P`SDqagKLi#~IKa zk11%~TrNEc;Pcuk$C6my{RE<eNnAibgu~P(Pm#JfOd?PSwLiBie8<<g8aP0sBAvY( ziRV>>yxv7aP>284YmM+$Z_ENNm)v8iJ=Y}wyY;yJjf|(5dNt3vR39q~fsJ2Ndira7 z`red=V?3mCqOWTSf$+rWCu9b?U4N47GRDU{>=S&{(}*e+qY!uCnhId<lJ=yYh~7X( zZieo>wOJ0Ni9G-<Y`=9b%=PxkL2_%|F#PoagOkUr<<LCsZ8tHn>-(72fF>4UtYN6F zk95t!Z1~1I;WS!SPHh{IZ$ACB-=2Ul)TS;ew{0>a+HuNQYNHG)fXJQH@|9W9j1^VT zETVjz)yBn7F{vLbjfug_DXMe*je`y~RkdCBkm~ypF!DRjF|9SSEvqZBDUYwH^_+n! z?`Arx{mCRAai35!vWxdrul%{S_V*v>{hT{G{Ers0d`(APj=Zp6H{wakuNP)*E5}Bp zd5O$$YD2qj%|7pWOKR{>2j$2fVSOo^)8u!}Oua>`r=Cr02!3e_A?(#{YMRam1V9^< zN!2cq2o@njA)p*}@Oy2iOT(_bb@ng&j;&MpCzP2qcA8syJ0T*!Vo#T?!w0`V?>#FU zthL^gDR%H2J>M)eBE0#p_UovFXxh(Vb5OR=)C8Iko(Sg&NM+=EiOkgtV1p0358Vqg z(blpbM#Hs4dys=k6bf*GnGVATblh5opkfTC#Tx9k`CQCrlziE`C{K*KDuvLm$l=e3 z0-w{mY%)+QxVt9qz)o3}#uT(I!C~4&T8*U7jua+uN%Ue4kRbhtwkVTVzQ*Z@Ls+S_ zq1$)PVa!3#ea81^B?+O#IqH3utgg<Lr%z{MmlN#ibgHN<i7*cvz&BD33kyVS=$;4? zT8feta~WwnP5}~f1&s1pbeoWFQ}Wa*o=&fnUbR;%hmEOJm582SUHP}R}$JZi(o zi9*ctJ&?_TzBl{lwArV@l~e-_r(3tVW1|w{8bpx5=^2p~dM!ac(8P)~b4REyl>{%= zK%7^Zp5Y2UIY4tQ7}{jQxC*^sGg;_Rn=vZ45?KVW*~@~$1X4DXb0g6+^n<~%P4MZX zrp1loKC&>>0IhH2vrCT`?6#hO7rc#uN@Z$!Rqtcm7t6MVN8<LyA^&t|Rpo7Jb*xjN zbf0Pzb)XW&K9?NhyXT?Z^IuQtM%toIVy^@A$=7Aj{+|`s{*3o?pzm~buRMa0Q~X$T z4oAoNwL4UFAh)w!Z3XY$HSlbvIZUb5k1d9G{O-#=<`*8`qE{km0tS>n<=BK4CAG$l zsyNFOzZh6Uzxtg4WyKSHLIkZz-9(!p58Y0My9W*M)cvaO**-6qRy!33&47k&=`(6X zWnV7?&o|;sn^zLx+@i&7Xy09gb2r<H(UhvgLu7hO7UT@)72u3;n7Xb2jKz`CLq=hW z#7y_x(~p_%Si==N7B-s+gKA|+PVzya@dMj=NtA$u06*0-rJ^EP@v{!~UB}LZGA-!^ zDAhsvRnMrdPE^@0{*xgkJR~kz0E|}BF70=-UYk?f4tay6P0VVKmVw@#3IG6y{_GYv zge^HZ6-~NINE1E+o{qwXeK~`24Jw~mfSJ1@M<e20I47TuJO_FzK=xO7hn8wD*`OC( z3eLZv5{@E6vjwvBrE_2fs_xdu5#^>XtWs&t%GCtCKo%7bmAhvpo3fM{C0AksqIq@# zMsyX!QT5^0S@?6fF#2C6a}eI3*12B>so!6PGG?p~a}s9CNLz^aVnLoGJ7$XL@r)De zTruAIV^Xn5PD!iOw*wT3o%F^zpRK^7wstx1?C`~)`0>}cLB8)3*hass2K{^^KL|Xn zy3YCG=v5?O1X9rWL%$=a=!+{5{Rxi#5G5{Gq@Q;yev)_9yzYvF`3e}Bn6`?s-@oSy za0Z5d)RsD8S<PG+BWO85%-X<;D8Zmnu4#OQjl#O;7BnXno>!CbGefPakeg_kBom53 z`vO(r?Y0QhPF>@cCp61saPUkq_{_;zmwzMjpt!#8Q`1O{n$}A0Fn%ZC>-2j;LJU)g z2;(LqMo{RDm20|W0iDTOSd&w^JY^ieSq5NrKEBheJ?vQZC*pf%!%nzhRK*roU;JDG zW`O8d?`20#&DvEE8gFxGYZMNOd67%e4aN+*!R>LU3nMIMku6zBIAb4moSgGT{rrea z(rq;!<wUt9{5_6uP8`KzS@cw2?D=E3UikykgJ;=drLD9~JASL4GuCj_(f0T@?IYZb zRUb!TE8amVS`$&AI1!#(7h*xRx?e=K8az&3%iFdBI<c%%kMK@4Rky*R%|_j5#P+sc zKqBrnHo1|>05wED3!rdpc0=*kdA>I=v(#~oiHiH5z-h;A<j+PxW^KX38^T7J>pG^D zSR$f;cWp!G-7&uZzJ)hVk3kzH%L%YrGx>@rhe~3JOeW3&)9PwC5kWtIKEo*%%F*Jl z>zOOTxH~eArZQqs0T;Ue$-gspuDie>ID7!d?<C@Rf8;e6Heyw%kNX>cpX;*>q#HsW z1|iQcY~-T}y~}_fSpYOa{M12;bSGCIIaAIi@*p}3pFDrtt}{6b*_D92;&*P|?Y}yj zx<89#z6uPuIBz}T6r=r6GZ{38Hy^LXO+9j2I3sP>`eDMk`!m6Pq(R)7n_4*+T?CVF zI*BNzkl7^-a*>~RdYC)e*RdOK#+hIP$e+_K{g1g1RcG)qFxlrYF+dkY>5kOq4~FYY zcMv@|jKJ|O@KJF<hXXxKRCBPOxlBo2+maO%z`jkt0Tyn`w4yv$0r=J>M1}U5o7oLM z^-baKJPH{nuxdL|BAl+V%uS;l1#swlGs42|Dq!p$#pMD^F4sN8f`J$&=}LCe7x(-} z;@W)gZ-)q8YXn5jp7@`h)$Nf_^CzxGUG~<{SuI67xoT495nw94ZKsKN?w(wiJYX~< z)jfI_lp<|rnMBOGrfd1UMfL_{Q&!*UMSs_+(o9*n>wDAwW7>5Y1(YP$K^vuxh)@~# zz>PxQ`0`pr)=*{&scT2wVZ`aR_97ayqLnq??!n>b2q|S5fjeVAux$R~>)3XV>>3Uw zm1H3w+x25cD5HrYnjduYp(y=u{oA?^75f`GFpRtHGHe7Z=49Nzx5!>bQV=`4+7r|= zi=Rzt-R@9o?)6Ar3V!WU9d95+C`H57W#^sPw#&POrKj{&4s(Lmh3V;2N;UNUoP3$j zW_|{d90Bb_`?lizI7fJL(jmSD?*gp<V!*Pse%n{tLLriwL-l8=8SY^_oRWN_L(ozk z%-FzVBHNxvR)P1PnoZd^NRspMGi-Bi42RxiXmB=EfgLH9=3<D<8Lb_plC7D5-7x&V zp?tcBZBq}*NpfKCm}V{xKcZeenCi@m-{7bne$UeYQtf{9rLfrb4($9!0x(wCWj6aa zl=AZ;>nwWVKPN23`v*<RIaV_0GCuw4;t4!ENMAPJkwr%NyULeEpzl@S_1Rc%l2XCz zop}gQ&IWQhQC(%;FZ{0wt;rT$3%_jcu)C|f&(C8exb&o;a_<Vd3lwIS!UIGvh@n2z zp)5=(nUvW_M_MVBX|ZEKE<ZgyQtm?fqY=crs0Uqeo7)#}NmRy!NCT8zTgHzCFT3ZO zLO7cQV*+!(G_wAvd4Mfpa>MP-vtKSxWfwSbX5*e`FmHsI+?LUCC*g~_f}7xXU%2L! z$$oqs0A64gp>7UqA-)>h2C<Wk39(hx#-NBG`<K8k)dutbf>JK~(!_G=O%FadWF=~$ zOKXO((sUz*Y9)rCKK_XM1l%Z}VD+nX<~M_HrVMgQT&@U6+Ot>17(-dE!?>k5chREY zC8fl?0Y0?Or(m6bKaX^cS1{qw>gj%D>L~a-r$NUH??>aJCTe6j+|M=v(*Xm|Ky-?d zU6MGSz6u9cG$I+m&<#@g7XD+l@?t;H#8`@AOkRIJd(5Tyt#raVtW)B6PXFR#)|kou z$M1GRQO4l#rzJ-irS1fGd$Z2-jY8NC)p=xSGzUN)`F;)otg$mOZ8O1n=!w~ZF)|$U z3eDjl@V3}X{2&8(wHygeEJ4HuM2dZ!&8zE2Z6ADzh<zXx{DUgV-$YHp*KJ$)Q}xzb zIqt2O&v$kF<B3hq#)_aTfPm66CfgymG-p{8yeo3d#D;_{q}R>NgaE0<lxkJ0dezXg zvd^A+bPALRyzGlF1Ww?*TGMxxt<|Aswct|(F-;DVwZ45_GQVwtX@tra{B}9lT{N!z zf*0E2=8==k81-8q`QU*X8()m#VAEpbeV;yYeRi*M@#qM)lnvUpoSln6?KO6e`Ylvf zYE>Sh3?pZa7WB#mYvMl>AJUQoE%KUg@;3%Mj#2TV_iuA)BP2r^;vwtq3Q0e+Pzwr4 zr!zdi#bLCN$pSuXmKc%vlzcG?(TvwtG#e0Q3u3CgTKQ42ef-YHc-_7rXXpNJm4D#H z?M^bE&Aa+8chDuH&5M9iRoMgLs||{%;TlLvPnk6y01%DD*7M<6Af3~BwKMdUDb9s& zKT^S@EPEs$O<-s4mSs^FYgXofvx23<^XBE=WV$C9mlwzU<`LAv&3v_vaJfLY<}$4F z^%WeKd|*d;wLNv0-23{#G;Kzs#wN{|NYJxq>CU0y9nyd9dr#*mt|(r)F@=s)GHL|G zO(GcBqg`0(MZ0p`?oOCGV>(z0fL+$LN)dJ+zwLji*GA*f=*a389-3H3#%}4!vptff zpt>U++(kppbS9F@D8#D;{j3R=lCE?vpF-eX-lJgOZRPP$%&y3zknms!q%{whnjI(} zQ?pP&+v=F|s~iV(HxZK{bSV@^!=gAf3KdJ<3qG^0LERKF8zP(5z+s$tSRuN`&v^QN zZIL`N$-|ewN^LLik91?7{UXyK<PD59`Sb$7wdVs|lL0)!=T5ZuQ+kQYVPE1-AP%#n zUKxqtdW`(B>}d8%Ol{27k*w1XRm)~=#))#3kT9q)#5GaS0^aabx*LwRUb?xaLs2Y# z+?6}a4BOI>3Yfb2NR)XMi0>l>ckssf#!1eNfa)SyNzGy10frmvP&hxDG2COha7-o? z8Wg$SKY{8G$K#7E5k^fXxm_n1lYdoJ8PuKpom;4pLu7-SAj8g#!Q9JDBCj-_dS34a zH)CaNh%l}S8O`ZR#w*!8O|M0A>&X^>afQIXpjwQ_cQy6Ff>c~~F-6@Ecf-j%c@jMk zs*8hSHVpv?O#TeQHx6#bYScmiBGY!))6|Yr%}a~a24kx61a<|4qdQT0Ow|uzI!?_| z0f%7c6B`J{)s_6&0Goy%`w<U=XXlN@Ggm#yoa_`PLn`#7iMk6cAy&yB?%Nj&Iv+ys zGj4>HM_p!yFw;hgbnfVN+ps6OI|+tNy?(#Rk3J0ySu6ytQ$3Y0u<yynYZ0f@1^EH> z<u8^)T<go6SRfsl&a5k-2+#hE^Uz2WVDY#wIxwj34X=F8%{!1kk^qx83PnxP%Ut(z z#+F6w{?l#dEV9h8P7-gad{8|CmR=OKfpUTLte#Mp<oJLU=o3&Mq&7X0t59iVowDuC z3&u`5##k4y2edQ#tvsKZQrtf84sk6wl4^R#uye%4Ed=tT$7kFy4nS@uyl=QSwsa1< zK=^9rYQ!>j;fd0jzISZA8{VPU{>irx)p1(xi3Gc&8y_sch7f+>z82n$gkU=H_}^V? zmU*z|UL+UO`YBDNk9?@FOMxIQvU@`bx#HbeM?iy;5*7l8RYX#kjVuTdi{_jZ6Rtxh zPUhkBfGe0{=QXd7vs1jrl5~@!!p;|E5JrnvsJusHsv~&VEKG+M3GQRMv(Za{I6~kX zTYf@IxaEW`=HCPTfK4<Y77`$C&L@f(0xVt1vHKn;s7imANq0Z+&^r4YJ}w8lQ<XLC zzrm&tN||JCR$5TUK;C5IPysUU8;{HD?6D?XQ~);1x7WBk2biz;3DDQ)B5R7=Aw&oP zjN|zkZ7El{Ju7|bEtj|7R(}xk-A2!o8Zz;FLf+mPWdg_O>deR#mB9jsMAds`F1_Jn z`O8Rw4E8KTvpM1yq(S>jc7`7_U*F2?vInuB00o?+3%sG@%vNsr3eJHI*HQdjHz8cY z=9IIW$r|BW#5Q-#TT)=|rD7d#nnT&k0U{0^<pm$bd_=Q%iLQ|i+fwG!odkx+j&ih# zYV2iam!w5Z%QvG`kg;T}Z!=C}AX{bg9zJ1P;Tr9Nb$Z>T`)N1(dgc{^0inLnB5A~> z86$?J+=?ELwp{~jp-DVBYlswR1D9*%haUA%^z5@Z9)Z!FSZO&8g$B9Er%8S>5GN+9 zK|RCA5KZW-Bq*W5fL5UV5cxuYxdOB{bC`f5axF)LEjg-eH??}uY}Q>pVpl{#kC`59 zkyEMZ+oE*GP9qZ9K=NC7{weMtcAJ65agOszySDo9RRr{{6qgszDRWF!OZVy;hH$QD zs`#5EuVOIe*(oeay&tkyJv9)x+uexZ+O=G=YhwgG_NEH2eZI6G^MqkTU*<!P83c&p z)68QeFYl7XcPVk8ioGY~iSjL`_04bU*omo_a?R4ah)<O%yD8xE$aDlQ#P968G-7Fe zTpatoF4P1i-Ds5;B932-YK-!$BIfAY>c}k&d^au{pLK|iF7ZZ+=uWJkN@b9>G5?$v zs%Pg}@AQ%mYRu`5{=6*5QCwi=*aTRs{HjbpQA_1<6$W(}!K%!G%3jp2Y4i4Ak}%c2 z-=DnVCheuu|H-zw`_&i{oezJ)5>T}Kc%XmYaTbhf15(zlxOfQO>BH6v9%Tb*%z|JC z*yhV^96F~uvJvFc)wgnIb{nm=m8JM=8#XoomC|zjg9CRhkPRB~C27wjEKfb*3gsz7 z=@txk_oYHRUD%Z&m$23_0)z#TDD+pWMC~uxOE*x_u7j}!J!iQE1u`5_gEeQ=<Ce!c z={Ae-;>XX*=Ga^do~)Q6A4tR~y6-1lVcn!KvUI3|x{XgzwzUbVGJ&Qd64%WJTka|t zvBC#dnjIK+y%nxGChu#ifz8K~x2%^CB$O=ifp+Nf{_eE|rm2AdC~7tOE)Fg{?&{!F zqoLN)qEW0nn0n9CgGyyS1);&=BIGy1g&IZb8Np|8h9O3Yep2lk{9BT$a#sw0UsoI5 z%zpUw)hng6)s#{&q0<NdP_sx2lQJk5;>b*7W2&4Y`&d#f$TXdra9L+Y)^PItWFxUX zDhM7yrI~hHM|bZH7gmB<MpsbM{d>Pw@dYe@!}`KXg-jgue^e$ista=*TBaWOWh{v8 z^1A~f@8YJecfi!Q=ck}vOavxR1?cVxjj^zegoI*Jw|?<nYSjI&SS^05D?V$xye|@@ z<2pj{)G)Z>bRPJCQdq;)(}y=}m2}1{fW@>1^#;WXEsq){<V30Sfb;`oC9h0lf(KP_ zZrSOdsVqKopOA}p1Pq8OvtsC-w^w`$_x!JezyFp2idq*8&V(ygmPg*ssiDA3<^b#? zvsLOlVTCoBzeQlnHo~<2ze{U+B?}fd?3i8tgjlvS+jMmd0vL!egbB70St3nrlP1s- zOLv~j=VZe@p=3Pr<#ke=^18uspFJ1r5xj$zA(J>*k0{6^uYTtdT%&QJW8^p;r9srX z@3h*}@09+?UlsOu2@2u1cL2!cpAr-mfzjbjr`Ipuj;wce8CHgCg^8%F7!LYS1`-N3 zPP93$-ptlCj(mzT$Vj#}ZJ64QnYE6$Nj@PRdbzJWdJ+8r6_8OFD9pL-lCYIi$MhvM zaYBy@2&l9fw}vH*(Uk6@Xgi##O~z;c5A^m}FF`t(Z&M!UhI2+C)Hfb*S`zuy=W}u4 ze*i`c=Y^5{M;l4P*2>Bou$Uu7Bx6nfQY7N_v-;tCKuYgNn~2-9zQ51ZJgym!y)y); zPwFsFSzjBvF*nD;`>mhe<CR>|RH^l)FsB44^U7i}8*vh8tl4Y$rPDLR1tvIm$?u=? z^Tzq(K#&#G?S{sWQbL@T!ss-BZ<X*!bfJ~)@ZsoSHYWQW3>qrv?um^Ac>;$B<owxB zYX7k7ZAnpq`Wu<u9kA2qZ`*lG$C!GEr@f~Ltc7K$-+|X-pUh;P?Ba(OU`XcXplhtg z5co<)w6t#y%1%rImfj65yWhF@H)n+Su9V+ux;>P9-;tXk?|j5l2-+pS{BO_6!G~?z zA{|!&!*Q7u^UDp9<VDW>2g!R^+F>)b<IJ5x&;$#jEE%=;ds=MM&uE?WYjw{_4dQqV z_{hUxhdn|*F$j(&&Etm<O9ls))2;4$az8h?{wjPI6lR*`g!6w9w-K5N9&g9B$;Lf4 z47d$gv2g$y1SVbl#^E7TFwp%8x)%*mce8eVjRJO7;hhTNSDV8Lv&rqcwqru+`{`B< zrzdg({knb~|4P~di_g<URs*xVBJOHqQNTt8lC2X@S{P?MfT{p~G>ZmXdNc>R_^7$O z72|`2Jl0uZF5NJVrqaaWR1!&KVPpI*=H@IosBj0eix7I&F9nwuA1wk%LLhG;P7Sa* zcRYuw?9%$n0W1Wkr39~n9ceX0<Xm)A7}X_O#AH*luk7Y|JztNb3ya3~hWcrQ3pygA zB(3Fu$R9czv|gV#U)th|tX)g(T|j|Eh#<y6Ysv;xb7;(yn)Js(wGeWTc)TFO6EWrg zXh3-0U{;>}lh6gmkV{f8Wro|MB(531ihcUGk~#9p7?y#6fX61r%n8bt)<j*?-@DL< zC))puKEwvEXYFY6kWf9n$qp<;%5n|Lc&LP93*l(hQ2BR@f|OnciG;^j1H=vKeOq&x zl0qN$JD}k%Kk1IJy#7D6oox(c`m?C1%y1e*&QgT}r$SMB0rK|TZ_)%nEX9E|R1P2p zJPlbe<N~#ZJ}pPowP3mEACd$;bdqbqAfkWWZS4I2vJJh#m#*OEkki;qIEx@u@a1$d znvKY?sdnPeZ<l21+P4WVs0RK(m^FUk_SmKBhh~p&StXA<DPPux<w*Sex^VyYlmRaZ zohP&aa3@*|P@d`7r;M?>Luz|JiRx(1DDSeX2FPY)H*Mg`?2GDQQ_3^UHhQv2`F9ke z_q6+A6<;auhr=ZF!f7m4{y*ES!vjPuZ+$1xePn6%emb<YHgT2dFL9XN^Dk|q1AdLR zHdNF)8ag)FB(F3Y(qCSvs_Fe)I{4~U(1~vVS3s!0^gn&2w0b50w<dV4y#Fa-7h1Rx zc7B4~-_d#X&o{$&MT9D$!0^mqSm^KLd_!M<SWyZ@4Ri0-YswGcmbeUy3TeJ9%YrFL z2LGAnjO`leS6pF`eZAHsT>%~MPFteLqv{7mUc>v$7m=v`5qZ7$_hnx~*_BK}%pGu} z`Xnca0J(LaA)Bc60&>It?`lRny#cZNkrOVC>vdS#R{105`V4NN@~6`Z^4EIJDh=zI z6^n2u7MqC0!VG`u=$6NB{=2F_*9VdBU$6+@mZfJ{xyp{^gC^2>g`hN>r#P&S^FM3- z4-p$IZ)>!@wC>DZIV2&c<`yd{0Y6CTHLX%|g-9$GP4WXBJlfs;fL14-{l`X6UG{s~ z=P>@2sx;{Os~CPd`me#HJ8T*6v@N;~oFdCv)b^f)ts@fmk&(KfxQ=Wzs2||jd>$=z zr8fe7#kSRit?_ao`y(RO^d$fDVNLiVrhW@~U%RBUsh>%Hs^|I+-y28qMeLI9>-7A5 zKzb@Ti6pCp=T7PF12DAPTOXrwezo?_lxWTdyV111u+9;b9GpcKpzeHBzA)QD-~Lhx zWl$d3HmNxp>BLTjfqHhVCy&pe1Jijf;L^p2>EObt77h?AxNnY|fQelRWiG=wcx8$@ zgdV${Lh_|@-(kuySEGsiOYh`$%#Hqh|Bfv<<Su$wo|v6anE5O+nB@PmjT%^{b77W( zv2KIDq1jDUg-75$dUx)=MWOb>@BS?7*Fxad(%aOn(AKXc29R#!Z0x!MU)*Z6y2@LT z48E)MICSwkV3vd9*{cLYpYFzWZwiI(6trX*%zJXpkL+V$t0F2RB-eZmttlzM_GyU1 zbh-WEKzui2XaG^(ws?t#M)YIs5wu((7ZARPtF_QfLZlO8(z2DFj}Cb^M4xg873W=^ zf}L9z5KmQ4;iMLP{rmlF93mr8D^U;sHvJOU##I=|X(Xz@dm9sLA%>4hR*>p{deaE7 z>8WLYsfWNh(pFZEtnvP!CWZ$`>3ScZO-7o=OP?bP$d8}EerHHj97pUDI+}{c^^A{( zf5viO40x@kr5KNBTm`geDJ={MyQDEJ44`%lW5OsF{Vu4qn-CH=Q{_sNgX@8ks44E* z-^(+l?L7NqG;BA$gI%kcRgy!5R>Ll3EJw_M^d>?>5Nw;I=rlFA>ov8EhjNC3#Y(U} zuU%pK<HbYW<PoQ<SxtY2#L)3~n%{ykRN`NH3Gli4g0383Fl0VE+C1vv=u`S`7~k0n z>qU?4f2;k=jru2}M*G8fhF9RqgcFOfar+ijNrT)FhMG!fc^1-9f_@?-J|7zzm@VCQ z3EEZCe3YKwp@W;PwQ+!PRj+RUy&bOPec?Szoq45bNr7>RW2y{<jI~fNgFX9lr@l86 zAA!sszv0x-x4cDLJ78LloHkWB1cFI-x&GEtq{|6~o?lKWu7@}mn{m3)AW*+6dV8hq zW8n#iUVh8cKN*JD7-5ZOFq?MW>PkRB&7fyZAD8-J+M&@HvISB*r^K$bKfA0}E{C>q z&X7$5qXUzvJhvFWTs9;GYij5Qt(Hy?*^ke5P|lRDh%KUHq^cHa*qU~C$cl<Vpo0Uc z)L!e4g7El4c&=L28;lGwmO^6yka}wp=A){RMvezhtMd<A17stR4KtZi6%dXRQa|_y zw4+1-_l2+k2o2!5m$yjQfCAfj3mIUb=Bd7aI&}DEwZqwa!je>hcqKrTp*{kmU0Qf^ zwe$3c(q0bfHknN8y{4KgZDu79Qn4n|t}}-c&Z-W9l|1C3N9!)_(MR@D#gp6sjgtHL zl$G7E2ITH1j<={@iQX(uj9U3v8YT=ugIQLqKWv66d1$bOTc~eV{SnSTc0ud-BQa9+ z0_5hiPPqS&R^xk%X7c?M%k#d3_MAHQq9<s7G~Eis=Q^7;7#5Jys7RaijQyj~6Kz<+ z-3#doN~2+k0*yAH%GvYcPmNpE)YY4xj=+_rdnz@97SxV^UwnM6I+SpP9>*6my1v9+ zA4<om_z1N>TA6Rx>8DIowq37uoJN!rHK0~mr<PqM$<LwUH_Kg8?EGdgV^eRNFF1YB zzo7<WM51UA);_3`v{4nRnf`;4--6V>uRVztQnk{5dHjz}PUb%xD<Dz6$?2@8F_^DL zq*9)#WhfuMdqyzVF3ptR8NYQ#{5*lbVQJIqq#Ygpi|xJ+Cq0vQ=8Zo(JU5_}j~g?p z?!>^Hka5`24Vx#M=HNlPN^`g)*Yzblt5@A366?@G?><aB!>Y5rT33C?36U*y0*87q z<oQ|j;4*3SLYgWC3a^bI20@zOsTmOJ*;32TKx~1meKU5xh^e?(KYUaaE53Y^+24E- zlru`pK>Y8eGAoEl*POp~JcQPmAw)qOFOAe#BV~lyY|XAcV*tCdsT(E%f+mctPluTA zaiRu$p8ah08DP}d^B|DMX%ou&hrV2_yMWWOHmYeF`Rc)i=k4fbD71r$3_5ilAi_n5 zhpUB4#B=WEDga9$aAD1uE>?tHJ68Bogq139fqMG@P5h#GUC=k-#hy<o<8)J$>j+fJ z+_IO~w1=lI6BG8q0&gz}egf;KT?I4|MME~67cbtgh6;TKY3icc^XgyKs2NJKg@51V zUv?j+B`ONDF^7nHnk{-g<0Rq#TiU>vK$TKBL7O`u)=bCjlQgV|vAI*$SJch0>bA^E zQ~QXf;cU4~ek4G1kLO$wf7%BBNGElZn)-5&>$q}{n35Gi7)A_w{%3*;GC2B;yk>4) zyLY>?xmDa8Cfdm#wH3f+{{U)D`bp-PhKnwtn5(3SuSvMzf-?h@(2t7NICCt4X}cU& zCAmF4yjm|J)m5h+a5(+goDtAN5+gBxQNddwAUhoF{Feims(NXUj7eO5W6@Qru`AMU z@44LxYW%w*o>9_R{Z#Os?py#mw(haWtOi8z5Gy#_9>r!CYKN{nQyDItR3a@1vkl=S zv$Y&r)v`O@WPZQ`D!TQLm($|fSz_d-fidjuBbU{d+D*+<awIO_jrdgCHL+BDh#oI^ zCQi|=9g9OVSnX%5Fx2&YK7t)L77d*;lDz`>ROLsqsArHU8*H0`Y$@DOlBkqV$(^2P zTox8@xiq|o%*g1BYMX?7uep8Dw?%Po&XY0BlaLYh-q46&@3iwPOkdp*lI1m0^qYUS zihxUDNjjYV5G-Ut_Nq#b2{?u2U$snZe?xe!%KQZQJmtcmh&#JI)Ek_f6Eru)+Cw@h ztiOp8jZij?(eyVwYzwj*B8jzYd9+KD$MGWu$Bx%{9Ne|m7?Ewf2%P?xf<S;c1tk}O zWXqwinGjV_<o#T~^u<Rp!+?OT)j7mwV0p?V!k8p{0qc9+9rLr#bbt4)AmDGQWyJU| zFEac}ol<Z?(=h#f?@P7f(9Fl|%)&)?_JHmA(VTBv%y}ndcD6dmB-$sBZN&G0$3rhV z+_qL3QADJDSn33QI73@^OLkKc@mnj)r925t+u$bYJrb`U)op7bt^B|2=((p0q~p*Q zJ8u%i`iqha%(Z`)g+au0ZizT|33$@aXsV(KF(gZk$xeaza3^S2tPFOtEOAEAgq_+_ zsRH>MLPVzpCZTdZIm1PL7KjxVOi0BVjnSjiIPQIOHj668H75Zvxhd$<Q8kRD{iy+c z*X~*uwi})hE++#YK=%kdgNz9+t>NxCYbQb!A$0DRHvwJCYJH}>pbE@m8#S&uGj^w~ zswB4AxVP&Z(Sftn=;xHQ)6M55q)$n47kNLe=uUU>thFJC4!8N}25tJuBTO885*O?9 zv=~%zv;SM`Gfx%sAwY=?Lje*#VNUdu=P@tNP?HBP<X{g#i35-y{vw%N&b(Xnv=*ZZ zQ41s$=HpT75A5}OQaLHquk>NFpq%$A)XgK$EbtI9Jv&k~XQ9!U!V)Z^3QMJvbaGaD z+V+dM_YlLgih&Z!Z2O6Yo9mtc@A05J*hTvLtox?b_Tn%~`jg9??7Cg;N@jWBQ4puu z*9s|MP5wWUnk?7gvM-&;lsA{f6F44nJ1v!+5%&xEj&82?{eQKO{o0a@06B)bW73mG zV`?_zRjx~h*iqW>)fIya*owbtv#3OFx`W6QM!nFT*joA)4SsGUuA;!(;9Spu0ueH( zA<OD)>WK#m_Xu^!ExN7OUcUey3u`(Mf_4c#+#IB=X9{B4h5vrh?N`v=uDM1_?YN-5 zTyzHCymE?#ZSmci*LGZ`PPHv3#3-xV2)mXZB|%>W)^#Yu{qf%qTXH`z7faK&B(W?V zt#$7pA~X+?To^r&+G*386g+Z9sQ)bn5&YmdN0Fl+Lu7eRmJ{v_Xhbixw)hD+!0>mk zzs8dv5&3Ala18geD-<OQaDBM|At#6M+8^MB{Q$gZbHV*Z<H#?yXvZ0+!X2W}4V6RR zi!nI|i8G*wrD*N=CZYbzJSbM6VVDsqGXohtL8+B_a_Pv+PyUI5+QSpjl%X1w%!k82 zne0~96l%8><aD&2nTH2`8nrl_A8T-o<7QPGVaQo0_q)_eDp(kEzQciLdBgrB(jLj@ zl2~~v7;UJy0G+B+B-py@zVcx$BH3`T+>JvtUlQFgsNDH;KW9By2H?L=(%6KYUh)kR z8*yfrcdG|YMCo@NdWs@MG{Z7u*FErxh3k+Mh#ha&^EQ<($^76Xj2M|iB+lw0kCU0V zz@JQwjW9b7_SLN2jAr1LLm|vxA1Px^6B>KR95RvaRQg$F8qp@QB-fd;trUYqW);)1 zeY_e2l9$D8D)ewU>@3YX96pzBRq>*vZuCJlw&^gA&27S$*>}Wl5+qnB4xwG{UF=CZ z;>)MsnCytfyXuP^*XU%rhWW?OKN?9Y?tgrQY2OFH>rZiZDzs+qEMbhy^MLrrbPgxv zRN-DkTy71Kph>l$B!&e)irhy+CQ&Z-@&nl!h6XyM0^sm;`R0yeNfj@2rq2`x>v4wk zA}km)iR)V=_`@N{DR^uBqa!|3Zt9`og9le%tcKd7H~&vqGn}m#@#D}NFk>!tAN)O% z@vs`h^Odh-rP#Ep2E1t3Cj-oRZ@i?{j`<RqxySiBn6MtgRxqH9%S2Xs^$#AhV7%3i ztyc_xhRc(p*&-kZ0W}a(@b=Qm_TgLz*y@w`Yl!r+%cSf?Mp-s83!fo{cSZ=tCIT#L zVI+L!kHA_4>9&W&Y>^YAW+V<w_3~dBH)m!_7oZPFxJ8DsNe2Z@*wls>zp?=Ex=(ke zYLlIHN^vaACWMe9av^N6CduOXZWXV%<;k}4V;UE|7^&=iUaG2tdy=WGt$w^P^rB?6 z5I?3K$=U?&qK@hH(nS#=(nhwv5gby^hi~7Wlt_EvQR5*tKpIq+s@ML_O_msm)2QTM zM?Wj9>6t(!Fe!;2-DF~~wBuLxZBq?N@jUebB7vAX9#2em>aXGpB)(=BB}t9enZ$VM zC!T6$RtJh_9oiKZ@}IU%m#e`M#QeGks*pA38wzE`K9`(k$HaQlu%1Dt+j&BY!zG+V zN_|Yf=R7`iLpbQ6SVi%PD>QGIJUV1@B?|Gk_OM?Y_f&t(Ybyg~=2@`+n+EA5?=+z} zOr#J{B%QR4sJuug5SIgpFWA8Q1pYq#!Xhe<)^y~~aAri<8IjLQrwA(JMH}DGJiio% zogb}_&#|T`UaC<-5QZ}=kNPE`u;CQnYKveZCOB)&&Ld-R-43rA<56`!1<o?N1AaY- zv<*M;*{G4=%gFf*<{_o8VvhzR+H!K#Q5|s2D~2wxLWWK3!TKloXz@0G{Ys^5bp*3P zX4+j0@l!G#jh>UVx_gP=HW~;LW;l}b$#8_^V$Ph^C$^B1{eqj!iVh7U5oJG!rPA#g zve9@BF01-~&6!X@30L2e+jrfMR2iQIf~{FoXq4puZ*yvA0{hPP0lW#dzhAFC_74Jk zUz-(&6b7Mfo=zO`koQDq_kW|8d7($kn8H;AN&d*#c|c)k7@TM2VcZu1aXP{k)l3Qv zNU`J=3Q~`h$t5dCPeVii*-Ef`-JfleqU^)#u&sZSlz_aN?SLBVunO5~6p`OOLJ7|# zJ_ssjSpNqG=00l$I2<WCzi4DM_gasT6yD--I==-A&YO2wW9{h}VWZJe>CJyJ*x-ky z%ezbaT2x^{WdKx&&DPNdadN@q6#Qmo%O4Mv)uzE<scS%5N$$z3ug9QkVd;-87~TlM zr$0vjYzIU<yu-I_R>o@<>6V|jBiUj_KgMj=2x*x*fFB3Mo0z#F`8LdhH{w>fyaero zt%UV8^%cE0O$oL0pT##Sl?jGFW(oP~o-1_l)ikaVcG>*I!|0gDpEM?Ues%IY>3PXE z9dve!fN$2jd@P*+;7Ai#UXCuA)hK80IjtFdP5t1958@3ykic@1J|t^uK*stN95D#> z9*IH76cTuvv^3D;Rnj7o18L^NoY@TbGimAj9=6rE%~0G8DcaR68ieU1r}mi^hTI5k z{Rqa vv~@Sy};xvu9%CQr3UkVe<TIUh@_-~Tp7lR`s!m!VnNWrghLJ$HB=#uCiC zq}X@TIt~_QN|o0!ju=~~h5GLI)a8ngRDcG>_PH<01=88LR?UgV`_gjutT;wCHG@=( ze&Yr`V7Iv2&VFl`SYDpktr9xt7t!E%x9O<?vT~+nVO?~>^MOay&Brq1E>K&KasH4b zJ$tCcGsL5AOH)(_+fquVtTkvqoP)X~qNX2=q>pF2{>_IZBJ$keY`pps6jN}^#J1k` z-Cd3jHlJt9t24o^U2r!cxNT%=yI@4d0|a8oSi?MlX&6mvI766H+CjsQ9lUR-A@>r8 zZ`z8nOMU2f)oY^|VWJ1%iBiUrx8JN^({073y*|MW3$zBL#E4DK#ENeteb%~D<yzW| z)Ho|afR@}P12S`OCeV4-x^DphZ=EreL3XIe9~U|RQ+;7N_inszp~rdb91h8}h%aaz z&27z3b<opD1O;}=X>nN*fHG6c)XVK9gUH;oM-5iiQJ0Ja*xaf59P^L$5<}WnEq|J2 zG)y?kw_}?{F-Z-#lGx)AkbXM_==5IY6&K3-k)x#GFSun1Z9kO_Aqu-IF@su+!i^n_ zlM0WucR?odxVXJ~4$hFZSp{4n-s9nf*thkR1^m*td#Ftw(xL1TepP_(?;XgGP$#B4 zXc<>db8w}a;iS$%2ozn#!va<sOjS-RzBr%TMoGxLFlFlOj9a0BzV^oQPC`&iG9Y8> zQo$2O=4c+cblohL`5~q|tCQY!gJ-_Y<W9k4Wv@^+q-7&%^0_HZVuOX=1}j|z*wkct zsei_Hzx)Rc4>p@6=}8rRGyGWPd;hUMgShp4p(mKwY2AF?80dB^@>O;mxG%5D6E2i+ z%eD3vy^ws{h^6JXDK&D~HylR|#Fg0TGm~lH0#ZI`B}P{5nG%GsjKCTaa?y1ERO}W^ zr6%B8Bp|~uo6IBQX#MIosF^M(p)kHOuIy&pIcC^Sx|NERS6=sOcinS{IJ!Oninj=u z>tdmVU0^5!>AHcERn1pa%Gf(%euLx6#qb3!xcB!jXN@c8L~hX1Tj*jp;veM$;kmVc zI;$5Z7J*~-8b<CnGHU3v1bTPe#8%fheyK3JJAp4T#H3`SO(`9V^rfXqw-F(Nn_9Eq z3)Z*E1xxDiM)ANf{M&ZJUa_jl_PnYP(%Bv3Jh045cbj5iaLJKX<P&?p1BZp%Mrq65 zSR>1GPw;ihx<?$o-+JCStcs@woVuvfr!<xD7Kl_}lOxQOUv@&MaHh67m@3TEi)+XF z>b&`eD4)r!vHH=d6IXUR|BIi(99g}N&7%LM+B$b9Y25xiFv^q-109nrr3HYmy}an7 zPaTe*?i2|IJy;B_Uw{Gg9r>at_);xZ+f-zRlgi7#*S|x<O5dFl?>DBBmb^TF>k3;P zO(#=rr8q%c?0WkxinK-A+66{2O$4ewR2mqWUX$;2;ULW`Uf^tHJj>|TqW>89>x;75 zg*wk8+cU?et4<F&9q~w4lJF|rfiMRt_CGOz>*I~*c<e{L*_t0l&2G6v&7$33Ym_ae zofmX2a^6MFqN8EBE9{bggcqKEPFq8(J*MsDm_t0D4Cu9Ccy?hY3~sS}5#mB8z-Rg# zH-(iCNv2Orrkf93ZFN$bzGu<nhDQWGj1IPM?Xx>?>L*5s0PiFwOZ&Doq`gM)32Hel zR}+|7<)h|vYxkmmKP+)JWw}qaVAI#Lv2zWty)!T5m%?2Irt_8(uJD=d0*#*!j#EV& zOo{Hv*63P7ji$U?Bj~q$1r4uHSk78WLd;{AK!g?VZv1ciwlWysiXRxz5zS8!Ti)dJ zmdw2NRSn_OxTZ?dr*zrllH-*R`oY8OK2TVE4O6`n<7*qP8^KgAw>5v<>41z4x&7Ma zd#j0}_(8%@S2Qx~>m=s5Go4-&!F-;jQ2c|G+xRJ}F^O3Wqn0m}isr8Q%0bK*&%F}9 zT_Wr6)*-aIAk)S=3EdpgSw+Wa5|Q3Zhmx<9m?6svNiY8eAQhfK$JjG%J9#RbJ(h?8 zubVl*nLk{A<OS#n-C=IU%_o?VX1{Wd|KPtZZ92_CBflnDGJLIn?CqHgNYCtRjzCG; zH#^fM>#vFA+l^7&*=4c*;m!s9M9)DWkpEL6&~0;UE&aAF6u8}7WfZwmd`aJ+A2+$i ztORBd8u$0Y1Ai|seNr6*Zc?x{pr!u|#=_M+M@2Cu7s4uZe&X?~s)iDHj%AvY%Im_z zez^3_F09@n*z&2b(93)2LSFmkzxCh#%5@V$GH(%@v{gz5<ByP6rKljh&TbEZq1hwo zGuS*VG{D|&?&K4NpD|g9H`r;VyxcJK%!}HU@R*7`iIQ@|5@NqCGKVE;msELJACaiH zzH+H>Rfdi`^WFkemf1@Fg7EBZLfCH&qTPfmFFuyizkyRimlnhLtF`)o-R&<Qowj*^ zLv8Dnk@%RIzv=<uj)|IHI3fQ~Sr4mmSX#Y5oR+?iFJ}N}@(`D?28mr7oe*CORDl3S zoms4+g+EARO<L@^Q9kvlWAv-~+_6#9v%!@y+MbrVlj#TZ^Yw^-!x9!`Zdpa@(_DR( z4c|a@@JQz-SA!#2U>Yd_Fh0?1tV)5KhBapef⁡swmHv@?pq;WcAx$qy1~O+dcjX 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%0Aez=yBZhijPn7BxiHa$$64;YI>;_#c}bx-6i7 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%0A@lbXisM~pUDjb z32a!xYM`Vbe1F%~@S<jH0ODt3pNNz}dx5Fq=kuO^zw;2@7e>MN47`hkne$*l7`^$S zl&NuE-Z`ug27GOyx8!PLxRz))DW3#x^yXo9+YXWx<$7F3mgI8Gl}R#3z2ppy^DsCG zU4LT2JAL$MpQqQ`z-Q4$U{1tFLB1TtYRy@*4T1Jxwq^u&bTW?3>??78hox5GaeAAu zx}WsT-!Eu~zS!}w*nP+U3shE`RTkQW2mf37w&=CaA}WGtfJg!R(<k=^;$PUvz=Jk< z?5JzXZDZTxEgs~wAD)UvWb01xCKK-xED}=*bs{P&Brg_vATE!-v5|6W>2rpC2_fK| zM*yn1=JrudYp`RjpYwgRXF`wPCbhR!S%uZp;+BZk5Y;}1Q|XHF@8{*RatVR{_k5tR z)m+vfRlNcMZKM@j`b8#J2Ao!e=#2<*vxiVh6yrHCf5NMtXUs}nwb%2dcS`ow)rGp3 z1@HPOAZ6-Ebmc(Sm2?dXA@~t!T=*e&ODr_CD|e7MTF?@CVT{1#$da4UxKvBIoeqOO z{n3eDh^3f#NnN>AKMkhk8XjL~w+Gpldj0Caekm2=z^Z|{S;N1JD;H>!`++P9#DH!e z1Ki%$PIFkD*n9Iyzia>L>by=1U9fv?VqM)-(|-q!8<bDa!T&d{3iYR5WJI;|w6p7W zr*@}DpDZEk<(H7Vg;nI{g3L%nibZO~J`}RQ<gnd~5D_2F$185)2TS{=qCXfsCd>2- zzzrHAdLLX(Oo;!KsD{8)oV!CH|2Nq<c%Z#xS?BQ*@VU)M%6*jk10+|Q0d|J(nY(0) z7~5I6R7eD7q-p3@Rje`+Ka9BWht8~USeg;ht6J3vo~-@63GoQR);*$$ckk=g$?hIY z1Cm$>Vi`~GWB#rzm%aO5c!icsdc58wZlo*>rE#byheufW3Bb5Uzdc+53Wo)E@Dgd@ z50gf*$YT{rg1$kniZZ~_^G*Ju(8hQc1FglpG4`44uW>re=Jx>7{#{+rAHG@D!cA(& z%4;_FG>=(4bhB9-x6-75-kVIcvt2wThNV6(IHAlH&0YSRhH^@|cd#O^R{6_DCa(UC zLy`&-a!=Xe@Q#`ED>3x$%}-)MRKz&nZU7;HZX*eNLTHCI>@2Id7LV_&8KHasK`mi~ z?rJ_3|BRrkPl<$iEwu=7=Siw{JPr3F6w9%|O-<PnBu4M)LBR_cgn^%%>2^Dz;KMgH z57s<ILh5}D-vkC<*!-(12{b}tSmOCnOpxJa44zNUU655h$Ow`3e0SP7+M^rhJ9-uP z!nLm9suengHtitPffg)i&h7D;BA<8Qo9`*p8AYA<{9l!Eg#<C|k$}?<w-kLP`Ucl= z%^R1;2ZW<nFCB%qzk5+{bE_?ws6XGHPM_v(LAXOwt|58p8*rZIMm_Q2j7Aa9K~QR= zjr<Rt_y^kPcrN(CeQruz-QAr)eJmk#`RjAU=79KbUctsNRkdZ75=H_(o`b~Wwq%LP zLC@%?lOx}X&HE#HKN{tJuNbNN2nx&YgXQZCa=gQOW8%nh;AWviaOI@Mu^f;Pos#)o zs{e!R*8yA)!KRnqSyW6N@G1APVRl!HdpUwQBjtpSDE&%Je$N=fAqahK20eexx}5h0 zPGH4%*cpDhpX_8-729rE8V&t-!l&%nSue$0wb_r7rzF(4Dt&d#g01+ho?}I!sxPX= zwPuQ`K<1izBMFJ={Oh4X=WHp@uH5tQ6ZI7<+xV94pmI_jOPLy9gQ6)|iwJo8R9j&n z4?Rd$3Y^?g58({>(f@<e2KpOHjsMnlAt98AW%u3OeqV&i%Shk9HwiqaG@0kvY*6~n zrEC~zc<^dei5twz=&*U;Vw_@U=JZY>7?14xc{2^t{B6R%M?L|PHAqoFuj|>kw0kf0 z9-uBhKuEA1$&QeMm(OImuIn>hz>j46gYIGd+89!q+X7B7%;AZ}*jueto&UQ?AfVz6 zoGpVb3x3^N3!0I0H?|k0w9^UZsIEXW{f%Hxd0Rpyu?`K<@R6?xYN=bzeC8Y(k4OhJ zdZJ(mv8rB%wY<`SX#xI*sv;~EcJ1Z+vfiPMJQj`3cJFH`AAl8!(tC8?Ap|Vs0t`|U zS`xh`a_Qd2@c<wkG_2H-iW;wr!#(G)t2C$^jP}eZZs1G?+4FML<Hu@N+do*~3!1#4 z*TyMNBV2N?cjP>**)Kr8Z?c~sV{~4`;pzlWpf_TGtFq(36=HXG9to_7<g04bDf!$s zPm5+d(!v^`z*k9J7<6Za_PP84EZasi0t&5g%3)lKu4lSVQv2o7e!2dC^Jw-;L-V^s zN&?c@=)+54`q=fC*$m5@3(j0w^qtNYx3@+2lC5l=hEd`FW=nB0iF<>Fd_UiW!I3<w z>pM*SzPAeoCtRx|yK=T0f#j%zDCZU`FHMOotnk1`{zl0d{eUJ|dK)E8rXNmngqb<T z*{0~I5{f>Na`caba^Qy&ZBgu<YUOZEm_>nU_t3H%v!#qlLfcrlN7d7u+LUM8oKCMP zN|LUvYuo@He+O4!7bZy(xn9{X+Wm%hr|aDnchC(HIN+Txquxt4oigY?d@gCaz)&uK zc93HLcBlsm>m5|t1v4L?kPp}zF5loBw2LBlKW4U#2!-I`j{Y>oQD$-Z{#@T3d@OVG zv<ko8wPQBCJ>OPqn?bfQQr`jd;3f2CX3i#wjvVw4DZt(+I)W$6@5fJDpnZJ-hjKXb zzwl+#MVWKqp`>BY;bn89>v<Mc<eX{V)r#f!HZQSyqq!D1e(7#F3agMOtfNJ(pl8`D z#0QmzO$hpJTnhCz2^G`TDJ6#}nd)sd#(Q0{>+aY%K;zJBoM@+u=S2sw54cNw2~dc! z$C;au`*jI(!J=2A*|X&0oe`YLVZ#VKFdSEu3B<@2h0F!9nwvU$E|&4BW4Slb(P#So z3Ve!9@g!8X9`qKiS~&knk!(J!(kj`T7~k0vm?Wy{#LrBZQpUj?neO-)6i&M<w*S1< zLNORAoJUM)mf0YZew#%E?kJ{EHqd+cXNDtEY<$JAmAEiz$}_UcY_{G_91@%(A#31` z446~LP7HY+{d=gVA^}GvE3ms}*bj%?V}IC5Ru+&3<{khrT_CqZ21Ot<VLB)R0Il*P z`gQlrx(Ih*C-f)_(oCqczGA&(x>*3zZ=yOln{TaJ!#YOz>(W!Cee*Zf0j~Sh=bK-1 zb<#{ts`2d83|Z75VMS*a-KbJ%rgD$#by5D4aM8UVb9lPuu=eJmCYy#ClBB@7y(8$W zi}p{+F{QdeW0UBt=6R@H)E3l6YS00o8Obgrr0aSvLj74NPdK$ji!BUkC~7sW{P2Ye zk%ws<c+kCW3#G9IUlz9A#FC31*NK=h$<qA?#)}8CVdi*nxbE}(RU=O`9!Elyz+%6_ z9prf1j69rRYK%_{PQdF{aftEUr6&9As)x+e0P|~jT=q03x*-%VVQK9pk{l{?9nh?L zM^`{br3MaJ|LnU+lV|;d*()~6ce#C0WM2`S8^J4Qm%*T-+nB0GBc|*B^>-$FA2q^@ z!3Nh}zJ(mOO$q#z_X4U!ES)CuvvpkzRp>&D`-|8PG_CFfH^*1D9#|(`4ncZ|t|{0v z$KcTr9v83M$#P2IJRVKlNjWZ4AD*u*J)f=jw|bxBPI8P9s-D>2uiLSIx4yp-G&=;M zg#w?B<_A`-8!{^8L3ppmAw9&Fc8wNUU9so8_sr+tXKGDGzz&MRcU-D?)BjRW&?&rW zZF1QRqgvX)Wtjm4*Z;%@AsPJFoT5mhmZW-ywT|Kho2;~fNY-)eZCDFI%Gc~hKrbfu zt7XE6yMd_Tt`(<a!SYK{kK+mWYA=85pTa+`INsK}Mco^w-rHLW&$CmUtc}q_z><v) zf^~S3eTI~6a6vr3!vM+)IoG5CZ%Ak>Fb$1H;9TWjAL76e3D~{xJfL2oL50@ZK(8wg zfC65Ddzx+fJf&7w`W1T@W(3f;B4RTTRU5Z#stG|#|5nQ|GOJ@e{ADB5e-pyP%(mG^ z-A~#I+V-v=)U*bspHBW}w(fc-kdI6B5s<$bnq`V-ZT?*9G&pP|U8f8n-1&smWu(8K zh2zC{4mAJ#<xD)(5H=-e9VhRSmXUrA_-@Xg4&hS$2jxukI<|Zm0F%`f(r4_g6;sNZ z9fwaeXk-L6umRfH)bupYOd=|q^T(F$#V?M!%J%xx0H=;%<k7*0wEb{vnF8Y$D+BPu zIDJ_M_LKL7(JE~xsjX~XA-}3{w(+1*928w0LqIe-4VY@<^Yak?TV(&;qO2bbACl|3 zIDeIo*AQx`7II#VGW~vFohaGgHu0|5=N1VoX*{AR5THxoCj*!5=FjXh?72>P(|6SU zR8&r<JIkj0*FIpL#u)TycAnNGrN^H3xHb?4AQokCMv^hfbFPL#%zFS3^GAlj+z+yJ zeDpTgM?i%)F-uePkrT+BdcV2RRe}-DYy?)2E3>K{WMT9W_lgi&0t0eeLTTgU;|&Lj z2&UTVJ+nqvt#D;}tqWg#ox*QcF%decGE2?i5#|78g#KINQO?^KzXEG`VmT0&&_RIA z@(hi)t`I6{azv!9%`cDDF;K+8q)m=iR_No8D}ci+uM+=gWc*cB<C77Lx9tx0^23h- zM02$b!{qE{h4_y7N_!1pQcdy(oh0kmIq5wshfWeLv~&Jta6_;toESx0Nt_=V)+gtb zJir!<t>JRjW$@<q{{^N<V~zFXlLCaZEZBVGVCL^J_$kC>_>i(No%7;l8iXskejrbO ze1!&j8qVy@{5T`1&aT!liVplz?yAu(!oDB0cCM8OD<=#F4mfH_tZdxpuUKEjy^syH zy@>t;rlq^jE8Sst;*4-_*#iMB<OC$)(ezTEe$tqD<FOaD>S5eHq&C4TUDOx{n66BF zF^{<`7t^I~IBjs@ULBE(6}}s9SR={MP2H5`uPo8>k|FU>ol|U6E;h#0nhjQ4P*(!2 zWoR2Mysr@P?`=3GCw&hHXt}5BJv7MGu&pFh!Vv2HtEYyQ>5KTO;hAN6Ht@@d60dWH z3D~>}G8W=Cgl!^KAx-6gn>c6J2m{zeiQ&yhtt?=E^(Z67Nfp%s@7UN*ol-5O;TYHA z&8%gX?traGI(6yJoTn?aE)t6>BGpdb5@snTX~;Kul%@L*vcAxNWd0Z6@-E1P2Sr%8 zGTEK$UrLi`dH=fGsT&A9o>%}M+Wi#h3nO7qT}YB2)zu1$aw8oV&$kl{N^|n}PfVW2 zPlkBf#HiST_*)ney1{VN0lB71H={~THRc)gaz;9QZH$@Qk_(+P7O`}1E{V_F)vm$$ z1vXC9!UiXW+0vkl8H}@vJ7LDprKSvCUBfY>Y8fLf+p)$f!rk_#*<6lqz^}LdTwbX- zG)`d-5o00vAzSFpS!DeI1e0Q}o_D(tz97atxL{BBzkqOr?3E<~Lj@B957|6Wt}#IF zExZUx-<;3E$!S--BT*d>U-k5@!B4@>^>QBvu)CKE<#OmKz$~0S5U);|l>%t035spK zQZN-q(6?pR>3EA<N?5l<BY|6?Q-Ey<S#t*%U-8bCwP_y9?dmsYMI%esn3I<;jwB>h z?F6+I&V1Ee0<^d_Z>NH=G4EENc7GG56Op+2E)7+fM@?6fNAGOi)--=9y~fU0Yh-S_ zKCVVI8^T|B4bOv&Vk~{qNg|yn%4_(7ubi#e&vyke;;7VvLz=29>8!`(+I;a;KzC+G z<g~dCp+%wE*6?iWjVGyHjx<AeP79A+_H-C~Ttia<hGY?5mcbc$Y{~71GO3=j91DWV zy6NX@$>Z_gfx~o}IA!L-6`QR#X&X5tS*M7YJ$mA*;qnAleX+qUlPJ-F5cTKR$7{rR z&>lOIP9)@gI@VWU@6O2xu)><Sr~FtZOGsZAstL{Iv36{lVbTC&{9ueyrTw`nISLP8 z0c5#90M<bff<Ts_Y<Fy_sXdXa-vF|OSMW-2+qkV(<l2FfsENUy_kOseP%Seco14w1 zbJD8A^l~M6$8KrpoZ9FyqellMO3OH`_g-FVmj>Kem{bgVWz}g#J*24SAxt?zx9V3- z=InRa5C~j^;xaLMCe$^`s)gKw?V)HtcYCm2la!0l6<|@PRs+Ot#Y$8_TyAQB?!=Sj z!O2+!RlpVFg9Jvug$`raO$d-SNqhcIp=O(#an<h44P8%fgs?0Jz$Zh1Kf;$MF9x<c z+I?Qlbv!m57xTFj2EPu&%EHwspGXYQB^094y9<I)7D@l5AqXP7<~;Zuul^NG637}+ zVb{pWhzO_J`0^B7Lv%STg=Ggll}rTTM$x}d18XAd6Ly@aZye{chetF&%wuUf9BR{a z!EgU>Fv^v)7sn(caJs9bcM;^ArVCuG$qvs5ZjjOf6ift+C<i9T1n`1V&9D7&`5%Dn zJ`TpN3aatt`cRvo96v3@1@ogx6ZX#^Uws!Bghu2c@_V!exZHEhDvCg@WfFz>I4;!^ zcSLDxc_Prskl5NPO5ng2cKl%{h%oD`{8A7rAd%xS`wkzp^P)wXNBrS<(5cB!tN!eO zh?sVqV92LJ?a{!$IiBeXj4^-}19+sx&R$M0-KsZt3$_GoA&ZnNqU(T-MEM9ypgh_K z*xNlL-9duh&pKQyXvf{G0$e98P^z#6Ym(eIu4{W}CgD~@oMJmfYnsPy)H>h5iuG^O zWa9Y_K{kqm%IBqUe2P~odhUr@mYeiL)F@M_)eEcZqYy^rc!_338E;dl1-){Y83I+- zF%R8OQ&keygYSiRVH~nvHDT5tn)d3!KoG^9ca8XsRuee(W+_u@CELV@Ef1o^N1V~^ zr-Sh=K}FoZvF}6IEdcyAgR8v#<OaBfth0tG_o3jDZ)ZO?66-Z@EKHfWq*5(-vlV*g zC7bWX=t+TTjVg16&L_Tky(}lkH6HIzXkzlM>vej<qK`+9j-~^H1r2^t^S8fyR(QNa zW+4N~f=gM(m?+>IQcoBVE5W437E-@Wh+WD9v7N|V)|(zDsHuzPCDoUnI3oT(U<`&S zIYeLJc|8A*Jp{9OO}mF$C@m8-QgL3POJuX={2Q_ZJP4Tyt+3LBkpxHRuh^a+IDijW zKBx|a35R<PU_wk%0x#kC(5QROmA?K7+ZXRV9ohwqu6b4<>V)--Sz-5)$jNpw{x}+| zs4>eEwzRAI;<CZuVMoLa=&SW3HqdU&fLjbq-xhFzEm`s{@q)+lWW1k|jmyEjD{mKE z(NfYNzb-pAL$pINK#F`n2WW*F=NC>nZ`FMLMqHz`>K)+#%%<@oh26<v$9VCB2`%(c z=*avh*{5Jx`GZlMF}F6h>8BRJXQNZ~!hz}OQxvF(1}xUZz+ly5T8@D)xXnSrh0Q=Z zo8c?|?5kgMv-MO+@TV<?I9yXL`!bXSc1i-{jIX}_faNHP7ut?&)W#GS>veeF>}l$; zZE`=Urt74=wEj@{^)W%&mG<SMWxWSh1aEwCN7Y#N8g>O*O@gi(oiqfSyJ<kIh=$k% zcic@nSGoB|lL0&*xeKpPfrs5|)B_QM8rcuui!W>)xTychc0gSR@)5a!I~%Ix$n$O< zk827$0GaorV{3%gvbu4b*A8Wqu4(9`Y6SkP8eTE5`T}Itk?QPcc{`Tbti&$B{xP`- z3$ij0L`aH|rvx)vb-ZD6Cp<qC$&Kb%b_&)m=6n?}6<g6*-;()KI@f?G*n)UNL*cx{ zGjP*KYyy$?FIB8nxYi$|&`4OczYQjvEmb{XS#EYw25Uqeiu0Qo)P>L;tfcUg_@3=U z`=#68*vJs%7@iIGV_kanZdMe6HGwPTAgO*^{^ln^==j1yG%p^<(^T&rKcY;=)pdNo zj(~Rwwo1_9C%4N$cm-PyRMvDP4SzN26$bl9CssH9l{GP+!xvQrlh?9|FWxFKUm-Hu zKHt#w&m*&UyAslU;JAojO03qRaL9;|XTX9oyHZ!sqjp5$OmWvt4krA6x4*R{W?AH} zFMDcnc>N&$hWBK`{%XwOPn#Cau-i@H^%1=GL{B%PFXwpTKm+c}k>FsCKg~31Ozo%8 z>IOJi1WN3h`{0-ke`LtLN5T>|@CAyp&Q-xtW>!kavX()uIjD5a$xS2Vj2546;<h6W z4M1bnkUa`{#)&<VB_9MSYG%)Vzr*yBV<`S_03=BIBhEZ1PR-lwA<r%a_9K^s1X)LF z2fd^pi3{&#LJwKVoe6bN9MLSs1$x?a2$JbUTj_)VSDQ&353BcnT%l_2vv4D5qm9S6 zp=&veAjhnqe{yXPRp`b36ZR*@Y`#TaS4GillJzwt&BS$r8n=dIj9jK+ZtyZjxEa&i zN~UF(c2EM46X5~5g68|%7|$QJ9WRP2bfwWGRwLjAp3d#We7l%;3x4f4Fz_o-fzRvb zez%Pftied+87vf9Jx}hSqdw`tMt(iyO8AUjSdZ0*_!Zs4jvA?W$~!!^@?MD3s}7wq z%gmKx(9$BaN53FiH+UFS{f?ge%RebiMxAz42wrd@$@_~RMlO0gjxF884y54GPcg@b z0g+or$FjH|r69%3A4u)tpV27br!}by#cl%zWQ;T)#6;OrRY?Dz_3gW-lER3EW)1O+ z8_S{AwnCJ!SR8r?Sn&*0{ddg@s=kC!kCpv$wvN~1nbKIG&p+uUFKK2T)mYpbb& zANs$$&T#^Gw52tze;Y8q2mopLraG;sLTEtkm6$<fmc<hd8OOJy$xyQ#6coL#6#ROt zU5^MH%s8?KJQp3)L1lnc1?CTY3L61Kz7Pbk((LhDC@H{rM2t9N{=n0XkXIYTW(&|5 z6f1h3TW>^@v~*6jjTy*+!%kOG7(4Fa81Ti=p%|1c1*)&U*B*6_QN@wnGNWoat6yG} zp08+KO?VLmmnVYbOdn{L2z(&WNSEF6fX$!<4y+5u7!xInEVyO~uqjBAKZ1o(x^w$J zye@781oLmW*k+DRlBYhTmtGv3KI+Jgzw3=cGCjsfU)@{b@a($T@`ZWEX5RH1e8RnW zKVw*fq+D`Yfb+7N-N#|QN7q}p<^cQZwcXJQc1*fF^t^__8_gFFT@p;&OBN2fHVtQk zFZjHlcpgy*Ybo01f%`0QIE#1n3G*YjV~4dk8uC|iBZ^!6k%n0glGCFGH`!*E7JBYQ zhxeI?kkD=NOAVKBsvDfl@IX^rblB-`9QYCV!@Dh>o94g=@hSStZ9OPlgxPr@KDz6I zP8$f@tWuC>tU~m<+h~}l*QhAs?${-)@k7IqX^8l9cxx`E?-#za1@^*T+gNxNYAg2S z2}+w_(L+c~%ac8tqH>ZEw05v+6O_R0^KMSdgR$y<cOtw{(4HG&Azez*F{1>A(`Rz} zqW{Q<Q#=7%B!DYHF>t2q6*xmaXvX>O$rG*|v$KkWKW$6{J`Y{p@#{VhH-eW@a5Mk6 z_JduizvXE+SAE!_^)A?n$BA5M+LA@hMX7ZQFPaI-*_#OJ7={0V&$<9Zi)8IoFwZ|K ziWd^tAIx!9a11K6=_#bZT}dfaGyn26`jyn25BDohLQG~V2MZhCFQf@!y4ScCBh$h> zCjA|<4!1Jjl$caorfV3)bsXYIdGRfH=Ah4s6N!liPvv`;kb-^)8wZTN`YS9a1Y3}j zki5;}BDxn=0JzUvfCZzUg&0a^xrGg&KRv>|WyE1zUnAIKv9Lxwc@D<~^N<`S2jx}a zA4l|L${;z|r5|6LS8!-$F>N~{-Zh+i{jCwzk{WymB)-G~?i3v(F%Pqc*nO3K+vxSa zK5CbFjpC7r&9%#>2FJ9J6*f1Oh)BBu%9W>gWei&FWt-8%0}k?A!LYNS4&TyDFav*_ z7NVCbZ~hV*rLEU`a3r&z{ydW<8nn_4zmWThcbrRgpNj<I`yPuRkj`>$Q}8eIURQ9! z<5}4OTe%O#P9gh|j2^V<b@q5Xd2psyXI|1zRuX*NU}F)Y=DxLhntZAz2<7f+-1AcN zh%e>giMGv7Lx)rT3;7jd)lk~Z>GsE*HzYL;%>MUeSWu!o!e#!z$!w65E7cgJN>~>c zcyFu!Xf}~*9Tdc;9aZxk9oHaDLCRpRD<|>bhmCDg6jugwY#hD!aWpk%DS|-1R2zrH zTUCnA%1*w$e1er|j|pjq#uITBzl$oQwyFMV&o4<CakKY4LmvG+8%oKT#ZTSiV>E%C zOq{-AfQW`h4<3NAlVkQ<5OFF3*|pT%U*?YGoV5?G>X_pdw}zzZXv|4CX1M{`ly2;j zSP_5qcV1Gm4~BCu)<uN_c0bwT9`x|R`Zj$s`Jo3>&O_^lh)&q<hn6NOLzLBtDgTuC zXDbq!<saty5NhWG_}ptdXAzi87y>LA?yS4vXCd(<PmavZzb@jhFB@^-#krSRaFMG` zBa$2P2Z}P<sLs)uTkOwl?w;<u*yGdY!?*PN(;{b5X^UoSQ(mo8S4_eW8T69JQtWu! zpB~DHJth7i|1~Maxz?IXhSjR>FdUVUEUvJ|8EX91ZQEofZzHp`KxKFu2CE(Rk6BID ziJ;9_A0P-77meBgvHe-?TOu4Czl_EgCjDq23{cgqf5A6RQ*_5Pq<6JnD_<JhCGqgv z<F_NM)~EZW;rC~`c`hQQj#zL$qG?$`J`QhCrTIoyve$haa^Be18HcPPRRUY(t7^w! zzC*<hA@Cg3E6i?d8m&{8q-%CUuJpc<OpP%uY!|5Xe&`@K!@Swa{_}aD%&L8sWS4*< zB0k(o1Z12p!=S)N7-A9!Jl%0t7NrrNbLKw+y(Lw;RcMkFjvP6Ts=j+D!%Qpk7r4>7 zc`??8EaJjPYCe@pt5+)L3IIHK(&<j~P7`AAfULN*VY%IDlvE>g<uv{@_@PJfj=`o4 zDOJpDT0rW+K9p(*h>y*s{HB<H)(xMdt(DMOu;2*|#3R<23U=3)yU*AQXJ-@@ZCukp z3Y?B3pXME@_$w+$l>9Y0&Fc-|zQHf_Q>Ofm;c0`I8vm&K?miW#!zUL+rRrK1bUK3s zt_}I4kSBUOqW-rvDJU7=d*1#|iHKF=k!bx0cE^L1@8@6{nV=b8q@p$2&Iq&NNy=t@ zm-}ZU@UyjC+N?`H7ysWXpy{R{RV0?|!$Zp^fKvrpLu7CC=kN`I<u;A%FkvdjM-c@< z+Z6#NEkvbyb-Ts(yf1D}*rd-NbskDJP7@Y|S@Scb%8!<;jwj=B=Y{NO_@7Gl%LC?= z1ndPUEm&uh3?g>{<73AvQccAxY^e~8D8`co&|7&NHYD2Mx)L9uKp@2|d&UF0F_0dT zCdfAR#GYxXzyHSsU`*Pt#I3jE=6FIh#UuQjIzj(SFi~^Q4j_*RW3WV)y}L;gQQ+mh z|JM~vU;cs*d`Wc5`dqK5Utz!sD>7607>rt}pl3OG^!dzfSdEzC;8hD0&G$An>GNRI zUHxqcY>D;bFns!P^Csc}j{Pn>zbKO34+qE3^X<$Kxt%qj!_XhqS7WWL^b4<{UJs5* zJ*uMt-gM=k6ld830C4y=at~l5*>Y@1LyhC1yk}g2>?@^wvC=!wiPF*PrQIuv#}7@o z{ejK+lYKTUwY?_r9hR;E$>JvMsGdqlgy@lG<-V2*V?FQ>s(GtBIH_EM7#ZgxWWVbL z@T<-=wU85Dgsw@ZWgVlhlZO>fBS3x0_{|*49CQYP4fkhuSNR@rh8Ra<=3T!Q@G!%Z zs=ZEnkQxbS+LJMVw-SYqU2o}B1Jouckj87<khISpn2(}hxdFY!U<B1cHjkU8epX*8 zz%6E^wS;(nOX>?3R&;`aGZ=Q-d=~QW`b8O7ALGR3r*WHJm5u@#i`7kUsWH1U1wr(K z+i~=oR(yw~1<*{;?Nn$;xyS`0?j#3|4pVxvnaKbfK19CYBFc%*HC!1><|Qh$V$%Pt z_XG*jb1nt948j4Y>-?o?$W`HXw%Tk23q*)wI_nN7P*x2%kQtRhUmpSx>v*zF&oOAi zAR^AEA3&0q+7rPJ;rS+qxCKjP0ATVpS=K}y>sd(Xy+@n^02;{iK=+Mb6D;kzM{*M9 zR1UPw8zLjh;olE@BR(^CQrNRE_E=JPoRArySU9q388t&Vz@!Rlx_eLz7??bDFT@nz z7+8Xo2K=3*n$@=EmDAo1K=s?IP+MLL!qVj@JS$!4UpH_jEeP|&8G+SE@_lQJ&VtP> z$+Rs1M?kp0f8}5i-~V_dOWoQSK!P2TF#-N_4p52NQ%dQw4a&N3@-&Lpik9pz+*_@( z<E1@UtMkG(5X@CgFmA7CwsOM8)-H4%xnvT#6FOFT{(+&BkXz)IocdY&4XSIT<?Lv7 zXVUux?y$pJastLQQYs1$!|r;{kE-<u3Ta5^qEo|3{yQ|P=)S7Lo#OWW=I17<DpGL9 z;jc;CEpA?b$;9?#>ga4;j%yPP03FwU*>g;dgHF;SV(!H!ImV{JMiyHN`+G$H9hu0Q zXWpPi4~VD9YB^|<9Z@=;8vK|PZTKt(nG*!5YMWyzJ2YZ${o4tM#eQxGHWqjxz&CZ) zA__1IoR=)RBn!?XFsC?I>~2)~or*(C2RA1}p}q{0B10fmq-+Xtlz;2-rNA{wB(B?2 z4OQ<+dTUPk1ld7ExIeSi{RI<q;FOrt4^7dbNklNex^-jIFE@24mx@2$XAFwKFEXbC z#7<mDkiH~`7yUHk;1TJHvOfJAt*%P5tZQl-)g%Ag8}pG+TOz!?fwdKSi{3Ej6ni09 z7{g<Ms2M7=pg&-LXnrL9F@hW`MxMJacQ5bewxt23Az<B#UxKikxBpIJwCuAfvqK(k z@;=;lm!&I=TOhya$1hY-x3*xira;yVwmK0w-}XM*ETxY3l>+%ARj8r?PJyFP$KxB2 zl^9e=LkT|Aqx5cvuLh#hPzbWZcE))wd2PQL@=d?IJO2UM{fF~=*}MDKSF26})B0~O zonWeaMT=zuhe|C6%=Wqdu6T9`nWW<#OLW#`g`q@~TkL*PG#-}}M7Qv_woj2LW|>Lx zw%NosPXEg+Z#38Q>#}RV1pWXO#?K+j+a)&;W}oWEkZ`C7x%gege^YYD9L-$Hx*_@J zrLszq3$s<u$c{L4quR0V0;q_1?p^HHy}giIC-$;6GMuiI&3k4)xebR+XH((@)lvFD z9*k>ckt_e~t)YN(yUS4@F~fy|l8?a<7|)e<tGA}^<hab6zws=F8Ji%;5^;;`qa(M0 z+4?Cg=0lOy%;U;ITHGdYYyT#}uK(s7D<J@}YA>Up8Z8+?R)hvbf&7YyAt<~hJ${ea zBkXOlw45`4FiX_IU`>9KVWXu!cXk=b8v>LjQjRRTlu6H8d(v&>X7yA$>wC~990@{~ zz0+sdJw-*tz%5(|nk){VfVInO$WW4FASvu`sq|Q9T63PGb6e;}h+^rt7rkhc;Og^C zA(6Q9?5k4DG_#d=uNw)Nt$4~@6Aju&BFE&@ST*k5t1a4uk6kv^>rZ*LXzy1H=qDVY z4mbm5<ih83#$7=QuMFX*r9Jt${BeKI6XWe@d#u=tp$)8n)z$zFtJr1=n%E*~mMgYD zdE8npGZ`dqbV=I1AyNP8(*HYaaE&j>j^T@IY2DDH-~zQ9{Y;Ak*OP^p568J8$>Nbh zwJP^Dw<9%(mU#akhSO-U#%yUui}w{Wdbx$>g`+w=t8??)AgR_w3|PNc$gr-NAc;EB zmSX9ddqD2F)AtF6#6QubxROAnM#r&U?&OQHy;8nsa}(CH4*92y4>)?6-{ehDr}EO( z*?z%4ffo8nPj*7?q{Wh9PGg>NmKu9o@nx_eG<7ybQ_67K{U>Y%v9M68^}Utx%Ky-} z_7;tw#baF0*n2)W^pV9wR7+)bx`)g_NCQ>~&^qQy^Ax9B73-fvvG{Q78-WZ^-*y~P z;XK0(zaPmbZN}1QpjmPT+qr!`gTVcohf0V;1ALfWq&+`>8dtTJV=)N@_NEo(?0@pW zs-vG279ytnxaRPa;XemETJjQ4?RsGtD<*iiuv_tH?zg}+_OEitW&mYuxnC4>6ZGP_ z5B?&5)fC=a5dBng0~dJblg8Hfh{<-l?&=nSZ~Q_MS7&MRHAxwe1OD7y?qA=(|BH&1 zdw)rhY80Mu;v<9(_tV`|Y1m{t<S^Y3*$C!v4F0Y*N;Mz$BVW<E!W9H;+glyYShN^c z#lv^QFBPw6;r=b5kMny{Rq!pq^j+OsHi;_s^z?qq(+t*N?$)X+9a+Q3?)V(L+}ag- ze|~Flvm^Do`=vfn%jkS7%s(tl$0UuXr*ri4^xxL@<70EXzZiCbsnpg?Gj)HPSVXP^ zjUm)SW6$6lR#3do73#s0WN?w`Vn7{SBD+Q?GH3U#W+L7d6+Gv0$;DmpZ7RGa2=2EB zn(_ve@JA{XUHC*zlLRSruWHvAVr49dgNwPl<-Jf<m2Tt3*^a*H7=6Z%lIf`AuoEG; zV61Vx_g_;K1T~!yWHRb6$vQ+!6zuD^6#H-(rFxgI2%MAOk1LeX`Jxp+g4WFJ8}dH{ z*wgQC{U2?v<mUqDE9#lS-!;}1S|ixFJIU+rp53)=tie(*j?^8CvTXf8t>=5v6l9Xe z1ageBi6~VRqPq<J8DmT&jjPi?*%L$jR7_sZ9zJGKXzt_5tA0CKu~IO2@BA8eUH!NI z79^)%*wh+0G5&c>Y@OL&I%{5IuzdqRDULQJxp=b`^JSB?{V}6@3$h8oSo@g&;H|ZK zdeFsWiYrrvxbp1BbbbOdQf+Ne_$iEkbJ6LCD-4UqtJ9Kydd&?0|N28I-653Ya+O~e z29po*cINE3_sXi+XYpL!rtqYOt;7;9(M`jo@yS-qbe`%Y%UZJ<#l^7Se*al3@-|S< z8<@M{t^o6OK`lShALbR;TJrKswNC7b;MQ89@di!@SUvu@U==8x?HG{weZ@Tk*{BQD zaI|#vMF-m!l^psX6<)Rx_IUCk_+yjPXWot|Kg%*$KPI#$7ijl%(u53eIwjz$N^ggD zvwg;A46RoVzssn#h_n`yUYpi9*rqe0Vdd?<vbc!eS6B}43F`#cFZooTwd6<^r8Dc{ z-On8QaG}z#d|I?WNkL$EtJXzghE5Y9|9x2$Gh?(DU2Ka{MEGMwITFnYETu*|1Pqxn zzJRnI6=}>8T`+2NG^&357;F&3$TyZvvegJ_wz%g!tUPZnjhpbSRgsS&%OE;M>z?5* zu@h*-E~L{Beu_UciVycc-&a=Zy79zMrZp(y`C-I>DdOxT{w{q2A8&_tvfM-D_3n!I z`sX|dr=#p5l?Ws$Z1;^;cW>J28RY&O61p)#k@53iEYpE9qV#Vi83=XW<t9RpsL(d( z-tW~Mu7_Vt<-Eo=kOkNUU7V7w6Ca#{WmAp6r~ib%SDrew^Zq3MClpyo1ER4LH4x1j zPq;Do!nU@Uo8E)=^sD)b)rLfAzEpZUmqC08xazU0t9Aoq7%hBXlvFh7MMRpL0XlWr zkFvt%2s!%hw<xzFw8o(SZ}@`sZx22{DTtD1#kB0g=h)8t+5tzE8zN*}r9HaCL>h|o za-KK-^ZBn|<<_usc=MXWRlA9NB{YZB;O%+q{=;qkegk`IQ)dgzTsRR)rLbZ9k@##A zil)Agh?1$y_gRg%7CN?mXJbCcP_!o-S`#sTo~7RO!~sbpG%I^MChB;$k=HlIN*B@R zz3#+uK%M)eeBxoClu49h=hmL**MeJ!s(~p@zqa#mAq@=howUk!3H*X<b26%hpm~2! z#eGaxiLN-8Xr}mW^h@ibvWU4+{{w*+X70d|8ks*yGOA~cJFvu)Q}bF3nw*->VUJIw zKUyF8RJk;Z6}`Hwo3%<2xB-oHT&!=eyipB!{O-EOQ7&x0+&$&Ij8{JvJ*3ttJpIk^ zv_|zPT$0N*-jKN)3PxM#Mt$Ox8*Jv%<3zu|Jy$Zo>Cjqs-5Kju2b4`!2S{Ia$?%)9 zu60*P-WNXWy5sxl-y>N1vgNn!MEhT{a{8*``AdpNR=Wr)EvRu-5*CJs*C&md|M?G6 z5oj*aaN^4hjWOb|lz`ZF%e!Rf1J;G5mQ?P5Z792Po=Tj9+4AJ`KGF+F@l`ACE1my) zK<t?*-|71epY5rg!k9i=Ko$(8!iS{sURgqjEb$eO_31#@cMcfdjpF&!sI&ol;(qJ( z*w6oUb^bG7mO7iMGKx7g;@wbU!rkQP_rYE^QJ`+l@}7*Y(MfO?mwZA8o&O7v;t{4> zr*Or-%@CW)dL^)OL@z8~S<Z%WZHbf|42Gy5>AT;%XK=S-iY-=7^GrBr(qQlowxJu1 zGxQJ0+6pvCe52V)>8-AF3;uln%hMl)2F>t_p100t!*hc=4Z*CFj^*+VaC?uL*7e_5 zczcT#A$1GvxXQj_)CJwo>Se#l2=@OQT4+L0$I4n2CJL#p3dzP-@)x+NYguZIJ-&aQ z8$*jESkRwW+kptLwJ3)qLIwO;R7n$W2CeSkbL$6+vrf8r-@R?%q&%cXh{9!SZ~6S@ zpM93%))Bv}h=B=#)xiS)#r`Zq+aCv`yP9~YEBEKf(>ldmv2Z$yr3LhEKNgP==~~Bd zd$%B<kMp5|k*6E|XBPidBkh4*c<HhBtya*HqRbucsvc5PL<^A#F+L-1u~v`Q<vk|J zXDiq*Q`;r*`B>{ro5-*w;;7-^)r90==+4Q(9Rcj0RqmUc#AG!+iqT1k8inT-ntgk! z!t)<#oqPpMe%D>soDn_j7)GPfvgEb{*^#y?BRUV<8-y#4SsO+7(NFax@A5^CY|#$* zmOT55nVPUD)*u>|V*gOUGWz#R0jx!c2UEz2v?%jUR=kebhP+d6?DD~Y`)v&>xYr`J zBL#*-B>xEV<f1vvI@V`aKltc!dC;d{SS6{;1J$L<I?CQ16D|kp#siy4IcvWtr$?2A zlL58ZFru42Y;zOq=FP@Lc2iWBKQP7Htwc#bTE2CkB)<~d%rcQV?oEir$)O)zIF|9) z763?Hr*m_S*kx7NtzTp{HR|?ee|SdoOx_x))u?G2iTWlp0lENi{@)H9<rX{^^Nrxi z7d)O;{GZww6-a+g;Cu%jJ<IiATvAna--841&K(_+(Okm!#tD9IjI0OIW|PzMt`>gv z-7j|+YPZ?-6RL7M^&Xc!Mh>mhlum6eH77uW(psKnfMfT&w6+3p(dM#e`08n5wzH;t z1VEz!R|%ypOgux4D~y1xxvLfdi~spv-Q<1?;yi?4KLu%NISomSP&QN3P&b!@0xpjb zd;9`9SXMMlROkTFb5AiVVP2ZIIckvBZ%!nYebdwGE`_ZlS#%FB@DgbZRaA$L#uZx> z5n%k5(pMkc3|T<mz!+=9(E=ux!9x!L{f{rGVp18;j_Rm0|2&blp8U<MY$BiAQMWa8 z3XVlH=AZ{-<2Y!%-d0e9gwJ(Ufv+#MmOz#t1|hz3R{I+K18t;IF_fiVWTuCA;xj$H zdH2jAA0pimF|nk!*EIK%G}^(rqNF22nRJm^AOP15`gH1ebK(vCn3+78o2=aQvrEQ6 z1`T_n=`wP<h0xLYT0U|Av{x{&k#CA6zYcXe%dxW+4BkXx=W8+I$2Yb>qy@KBzb02R zaB^KS9LDFBjpIHeRWlYtpB8e5{0{<s&+==R$_Fz}i-q!`ZH_O`%weW-sMDk(7{@dc zsmB-|&E@pVRuEm)E$4urt%;cN+FJ0Q2U*-##)hK{mz$y5_M=d1#gv6^y_uTdG}M4P zJYtr;AwCb!*rU06LEhZi2t32?hwaUx{!!+~ugbeGYh11|MUOJ2s){af0-ggn7&Y!` zK_@4Q=R4eq%$ipbBKOKu_5wI%iPSsk75Q`7wE20pp#2cjHmL)A8uEO(AL#O`H%S)o z7vXoE<jbHZ*eq%vv(j5ZO3a%>R}g4P<<{P4g9=n)kYq>uyjT+>>~_GxbY)s6GTsql zgmwQgII1DO5R}I+dXw8z1X^kcV&gwSBbNH4VA@f2y*L{pha}8jMPN-9eEmEM@?z6? zofryw)A-?3!Qyx~0Cfryv-v6asRBQOmC*%$RuX-`pQax2NPcGtcqc-yC>j6NL@0h8 z$gnvihyLO+P^jcWD0qBiUjE4I%RShpXc!rhs|B%&N0l9DgwZ`9S+7bPRgUON)Fp|G zt9C^=zOPH*f$BTCzAR7%yfDmv_cf}`O?g$lfu|p2vjOGy-}=aanpy|Msrx)u><yS^ zhI#h>ReqH!;3SWSJZ7s=6Q!n9@M=U+MS5sjRQP}!&p~^&zrlvEuCcm(NJAu@it}Fa zW;_0{pPnR8i+cs<P@x=Zz{khOJ^(-5Nsqs<TCWSxGux+Oeymfvzo|b&cXz2hZ<8MY z;Vk5wG0D6lcGv)YN{zsbR?3PZ&UZg71o5@_?sX8aBAS?F@yaM339CP4jEz;BJpkP@ zs(9{W{vh~uk+(!PpE{GgOSayF!G<xeUedho=p$wlm_kdKTfiWO2`x#q{+;MPYjdh2 zU%cX`F5PFOSW{vo#Cq9GH_$mFWs6iyf<pCK{jL9@$NS?;=oWyExc@>jSBf{Hx=rAC z0v_YHgKd#AP8}aC{#vN3**9Cq*G7LNJhFudo6yc10`z<+tPW1wnK-^yWbS90=2Kzb zoLb%E2Xol#1z28pgKk1g>6|fpi_OScq`+@)7+`M=z%z|xcX@yC-kS1Rl<Rxmu4vlh zBk)j>$+7N*y|8v-jBk47yTvv{w?}x|%w02j*QLaa=iq~n%^I_@dg#6A5n+8J+&ILD zzYL@j=<$}XOhU08CN2Gwyc%HL<Cvf>@QrFe(@83)q;M3_08`Mg`n3*>1NWFu+}DyP zoy;1bdmVb}Q|dFh$npy);Ys7r)#fxlfk*lJn@MXtR2i`0#zvhT8rKyZpz8FC)QD5b zN6n@KltU5a=fT*8A8Dd`*MHud(Kb6+KTXU#yL#bOi(q{bah$cZx!amR#aWp4DnEI} z^7S7n)o;hYQQIiEylc~%<fMr0S}V^qBDm`V5tO6w^*oD9Afk+<B^jWw<A%=h3#=v+ z$4!hhB3C5!{)gd;PCF0T>Y#~L;!>v5Vm2Bz+{<kBR&ZUf0miSfF?9=y+wqWW4+Rcp zlGfp&yoV#Okuo8R8cnOmxs$6)wz4UBT_&8(wz>DuzdHs=er+OnFO9~IVoD_Akxr6l zzr)WYQ^y|U2~QgW303PiC+!ynU7R78WkKZX3}w}c|FI5nzN-A$RXp%S4TIiyv3q~Z z*f$Y)H}<tSWob*bNbOmSm6Qhw^t}?0EzTFrRfHTX;<*^RubiU4f{3%CCGq3@Ao~fk zvURUpae)+y=LP0>=EM=&?$)B-aTt(G`tsNsULn~BAMCGe16q-0ckliTr{67(0@D|b zbezb>%xa06v3k868eGR(%U`IEHE=pf#qwyyg3tO_LXy?HyxQ>S3gK)D9G7%a)xGpi zTh-4wTg=0=p-smOkWYs3iCJXY^cPbbrY$pd56rc}V+_`sGBU@jzIzmnw$E!}D&jo% z2wipOZGZfB%98Y!;LV)i-<B;!Nf$%Qv!q|TZ5N-ZZzG(!Em9;ctEkMWjOD|2P>(f6 zweNPNJmng)W|_u)6m~jD-{Um_X6SHzlL-s#Y4VW?mHMl_>fP>3+0MM}^l?O6^?S^< zOt7@ZgFCnhTO~mr&FSnEYNO!o51@7Izq8#8H|l+xB@9<4vQiTUOt<ojMkUB%lLgbE zwnZX`F4bjyZwF{2c+YjvNB70EJGt>SZ%SDy$?aE~Qs15s_f!ego;0;8Z8bM8zt0H= zn1TH-N@IaS2`;}ivRMOWcw0Tn1@F`7Jvy-80zv;I(r7#7!l@}M9>mz|T?2jwdnL|r ztykMZeL|^7<O~iw2VfJeXSPiMXGX!jkc}OAApBi1dbx}<XK<w$<>S=)N@L~IsrT|a z*#oBZ0NN~h+iknFz$zS6BmfVU7KANNT!*c(q|#@RM~NMyq6yo!YM|R@0&6xurDl9x z`y(23fG+!!M(+Q6A4t;8T+k&4TMl-QGBANs9ddc5OMJy}V`<BsRS&pE5?=>vGd8^? zcPy+ibh}bMB59!DrzY~;m$9YS0zuZg9dUo7RBvZ=#au3jG30J6>^9s1G%V>v$Fq!# zmd^?0dUMNd<?9HXPh_q#CUOyfZmt*t1Sf!X*1N>5{_NB;NB6ZTpho30kF*2?^17;H z8`&tty;A`U%631h5<fKD&R&IvcK4X^nD4OC9~U)krwT9F!Z*x`N``heq5i*{yw#Bo z=LZ?h8QXwv<esIKmNm{L`@*k?wxSUyGe3crn*&sD8JA`n-{N5a6VeDFgnsJU+4g~U zBBxK!RClBPscE!Rp8CBDG&9%+F!wx<?<d_Pi%D?Fa-J%#g$rO}lA1W+%bp*lW$Z@) zxNiULbU#cTtf!0Lj@9%QBjnPDWd^W;RDXC!!&z|PO#2-`4_$lwQAIl-3E=<;<R#Qi zyNr)t=niNK)=$VU-=o~VA0gI4@Fz3M4?&oE`oE&gx}s8-&pzSjrQ7Vtz>mp34xpy3 zFP6uw#YoD8R>zeR*$MAos%>g~iuZ5Q4LbVz7ScvEtX1LQj)*Rr*%H|!H`MpgM(#sF zTBW)@rQ%gcPxr#x@%lv+w6oYBB6Y(&X7gbVwaQ7w07sszv39e%gzs6m0VG|K^S%t@ zxd-Zrw1*(ZlxmlZq>nN6jd8ejdU)QWnblVR>xD;THd`;4H6B%LyU5>}*XoS$$fia; ze$R$8_`S~B!}(V6VcjNWTlfEtD+i<OCf3O$J1iDh?=q(yzYu4u0*@4IzGGH5Zr7lF zU(mnOB!}H+=D`7=a>N5wb1!Xqo62N*t<?rvVE4EFrqIIg7On3`u_EPC+X!jD{v2R@ znB_c(rE>(BMLEbDpfAm>z73gXG20ze42aEU880&KJ3sHTQ2xtHb}N}%elzAm9URXr z+gY<R-*1N5GYn94nQQb8Li#=J)83+-D)u<%Mygq@WDVmZc)i$8&cRho)X;#j#3*^^ zTO?`G=L@}swSH9$`=#`&MCS&npjLzL117SHb8<BOC}0c8#W(ffF&`1|E5o=9y6k3~ znWuqxMjH^TUOscnhq`!|RrhJW!K{l$-^IjuLOhhr-V88sh#s#ga9OoR{0fwR`4e{~ zUImu`7?8b3$-+Wt7xB=*RFDxtFm0N7_=Bc}FdBa68h*#Z_&vML95(&_TNvy^`W$bq z|1B{>6$>6m<TBrV16@Td%&(9d7mGJ$I2Q^o(HnI}*de_WScl`Mqn8;1m4%br6^LiI zI?`(srL8LKE{Ltv##+7Hz{}NyxLy17nGc8ZCfDid<WYyM#6Wt~wF<-!$r48~{omJp zr4mahPE!)5Yh$9i0r@}Zn10cH^W5rf?Qab6`{JXBbx;LW-Y<;k2OAsP!`BzU9M)WN zM>NvdWSfNkk++EmQyQQ74i*XX?VH)ubU}oze+8#z_2l`9CosG1Vb@4wyb&Gto<aOz zf+V3sxv^imXm(2yj3csW6a5OX@s5VCmv&nSKz~)^pd;TO;k0#K-zml_=v_#04i^dZ zyX&=DmEOQ$pv1-Y`vGB)bXd|LMJSW1WSH739nXO}2ONwA>5BrnK%Tewz$|*gb!>gO z5|WgPJoo2{rzX8;INx-4R_!hm%}yQwmaRlw)$yRnR_sIv0`A13#!z`-$loP8QTlZf z;8Z$m^n_r0&o&9dKND;ydD>R3dwX+BCvI^e2VL~ImqGID>Bq0MruUozlg;4G#`Hf} z6bPkKL&MOwj=^aEvDNFFegJD0eiUapSE;ELxR46dDe%{a-Jkpf?YvavkTxpGkW8D0 z8y<NPZbR+u{eyv=|J&iJC88U;4J2H+uo)8UZ{E2;AModn_I$iS7$805wV4*+#&_AX zc+cMt<G67XMm{uuF>Nx^?EEO-U^x99Wcf3z>b_tL{h~(<aDARU{ln=_-u1xeC8Qmb zl#=J4S5IT)h&YYLkb!EgcMTTaaiB6E4H^DPl=yn#W&UpxT_X=5i6+2fk)P897#!WA zFEd+SGzj16NZ1jM=c}H+804dei{#4`jHlXWN|X$36-ZyXVBrb8=6#tpz!4u-gsp*m zEEs>M;4F4NP(aJJfnxrj34>g0Sr~F!Lu}(Vo4tx#2W1|{lZ#31!<1<S7IM^bRt240 zX*gHW#;p6+9C%lr4fLkt^0gXqt(n<M>Rs886jgG(6qD8!xo+y$`@%~X!bH50i0;7? z;NZdU>~P66=kZpI7Ysm*6Y_T!qIy-N_Ty@F9;$22y0^O7qf-Wg|Auy)Cr+aVDx%-! zB2^_WMydfP==%z}gpn2+?EmHxJx5UjTB$l)JfT_@G@kVZqIjnaV(k*-1zH;!7fqv0 zge9h-QENo?YtiC~ANuUJr)Q#m)Wx3s?Wb9JGDdnfBc(QYT@ggDdzncYuimeHYiy8I zUmsZ8%KA4Uc;fU?I@8F_Ez)nx!a>q$5ocpqeSdw!pQ++l<>mh}uRWK&A4TYlXyn~d z6s&7TkTW();afWOvRO`JXvhQ_HnSFD4!%wIeo6>%vl5yd9wO1|OeX1RB0Jk3MxV2i z_ja2AE5DZ(fe4H?L_=%4`S8s+LOC>Sd{AAJ;KfcZ%iuBGvXN}oyP?$%36+eEG2+(L zFT2b!zBof?8{|Fx1L`)TIgDch&J2q@tzO&eD6NMJDSVo>T7MVpR8byST?;@SR%PZU zIb(M%QbunBht}$J(|WPrmfOZ^EV=l<dMq>CWXLCl0k_X(IiPh*4zRU2jEWhWphpn; z8lgKtMamsBu&dNXQAaZt>Rp6%ZMLrz796IHG3r>+yQ@T_(;Vm+Y?g&#l6I3EXfZ2a z(z>Vc(ZwW$cc(f`P|0?8!C_dZ-xuD7)vrm!vg-10bM@>R(|DFj(R4+ZIeRW-R&oCb zvV^pJcTOG8TWRn(LHh{gJP-Hz0s$rZ=<DC>e5jB4d#;njh_DH54<ab83d_YF2L+~H z8pd0QxkPx+es}M9Csn35T}Fj9XBPeDUq{qTaLgQK3VBf#)3TJ?4GpJ`A_z<mp?b&8 z$YMJ|8w3Xq^6l~ET0`}@4+$><76pAzf8fi6Kz(TGY4ti+Z^%7eJ5*jRnr8mg57V8Z z8>E*6t8t~lfxIaIvX@-m`a*lPDdNBQql)d2gWCSU%04kJc_zI8@Lf2vT>NS|?np{t zkje62dN&hZB@JhN>zQz<l3~4^MTf_iD%haEiZ@>Es`Oxsw(PgQTO(NzE>TpZ>;thD z50gm?SGGuz7iLW8FwQ~ifACK~;;ItR>=jEP_%yLdEHlEh_N%Ys%rC~1pHy*q_jU=O zR)!}?)?6UIvaw7Xvu2v;Bbs!cgONPRK1dg&a5?wS->J#oOv-0c#o*y#b?LADO3M3J z2zCY3MtH6TL-Z{zkvZKC(QUrML@7s-_>p*Mr-=x{-CH?Be2M7C!(SwKJI)J0OqEg) z?e>@+-vyXk;5D>xNXy^-`FN$iu;(^Sa9=|zbRVBZWVjmK){JA_>EiixH;t<-RhMX; zc;cuVU)T3GMTKM=<?vv4yK?=zn!!ENZ1S$K`T0r;Au*b!j7)%eGnKWRfDAL*Z<+{~ z%sCJNuW#nDoTqDwS-DZgjsR*n=}%0wOo-h{IMR>pHejo4kln`IzF_&1HzF3o&E3z8 z^umzPa9Ay}kkWT{AFppz3Vu%iRU^hEZ_etxDt1-z(1i8jnLd4~-<Wf5o89$}Id65} zq;}d%M$6)_3%|rhlA<DO6YIvl%86y#)uO216U|sX!2rXeFDaju^&kGio!1<yJ$h98 z7aI}cR!?pJGMmaTT(a1DzWt+vk+Pi)O)+D4w7>J1_D=P%a>VhraV44Sj(#uFdKuai zUDoCrPZkmO)qWI;bIg+4<9U8%<-SXqop5L=_Yl0h15@)J<DR+>v1&k}(*ZQJ2h_mD z*WDem12^p6;Rsr1!AuUSDV6h0cC2J>bVao@LulD}tVUY$yvEQ!Ndf*;&f8}2o5}m0 zEBdr~Cbiaj9C}%|+S}F0MAdQy`(suoBOkBfWqz&QLi5j$JUvchq#P$}q7h?2oVcyP zlr|k={~Cv@*a9bgP&pxrL#%ot)_aT<Xx=Mb_at=8lFM(zZAkQb2Umgy{xe785mdx! zo2Fg9XLycf*g0zsy<A09Qw~y-;dDfSeQ}&a>pUT-207`d172ST*tmbR$m-^I*m!s& zPj5}VTA}#sLd|d-Da@*uVgsk*T|d()KHorFXuBe6)TJt(xGS7<KNK`&z{vf%1=Tj! zPTtA{?BJ_X`osM40qGwpkK!785ai&oJ74*Zf$+za0-ORLDJU#Ta7%Yv@7TK6W2{yb zko~I=8U)k*jt_y;SFDsFRs`!I?*8YJ`Dd$EK3fd@iH=*SK2zj>9k-h;8AK@m-gd;p z6y$6;K59_~j?ZcIq4IpMf1TW>K_KfUW6#$*L^=i9ULhCn2{K*KNxsS-)U;qNF_xcO zZxl!;!EYpV49$>Wj6g>s9*HiEGI{?cEs_8u0J6-rE%q*Q7boVY;tIN@ZCXQiAD>my zo*WyQZCz#5zL1beqE6B0?(<i*Cu+Alv<G`W(R=YfyQ2sA-yb1Gjaomt9QFG?IuPGq zU|mKcq5fH0kN<OrY=n@do-f5mG?9kh3B^<lH#KT3v{{X|Qk6Flot0sGwZ*e@jW3yy z&tN5Yy#+@q90R{<_NXDgRwn-sr7uDn^U(}bzX8#nEJUg=WeS@X97j`pK^mH_Q+G<G zTlQPzNnB<88!QIzx66RXsxei5lYJUb0aKyso5ePE(d<hyTc+NlJT^C9{6kiO|B&tW z4Te}!tpnjG&}R!JMoJfEdHsaZwr#rE_TvAVnY8O96h!*iS_1}1dUGzhi7v!;*q!Kq zN|iJA^JLj>x3|5~sE8Q`gjC>e)~Y+VXfavq=bqDhyN)H|LL;K&Bg=@s74JW{sOjP1 zZ5E6j4S!EB2Db@%&a|TQ=7%9vl|fwZ@$NZK<fM0+*^P-de-`2o??6+9vDCSyOR4Da z9IVA$L^IuEd%KqOxYyUu5jcI{Jq6ru4_Lj%+1Jm}0&==Kcec7>c=%)VI}d>d)R3@6 z&n7dQeVZ(v`?LD7Dx)+|95_U0?Xz3LTRxkQg70&c*uR!hI}xU#Gb{({53Eci7fD}O zkoBzo-icci>6krpmf7zE-*q*N!xT&6ekfl!&(JCkZV?^X0$mc`Hp%|p(2i*((@a0u zyE5kpgx?dw+z+!}NJP+~F6|Yg={uM`Aio^z%Q_!^v|&Lq#MdQ`g|PL6E9_FjwC^)T zXV`03%}tM1Ctwh;bt(RTr<KN&wBsY$RnP7IOF9<hVax8*>U3;&o)jPwy*z#-@A9)r zM?g(R6_?!@RPDdkbK<?~?A5uPZ5#uF_U<B(4|!PWv*%kwgGrsM>?6Ca({#nzdg5t| zW*>fO!a3TS<Lt5p_+$f1vWV~8`VXpd^!PeaT<D|kD>Y0iulk;m<l3X6$815>u)Cio zk?W++|5vu5DqmBs6rOdbrVMCGj?c?>EK$K@SVTC|DwoiZrFZw~kb8OKEofHbWUZ;= zr}tr%J~KDCis?R^zm5B8$8!NxypKVJq*B{rF>xrMxu?5wsUv9kR-+iEW>XZKs{fJy zgPDWSV_BYS>MuaEgJ~s<6kjA?7KWe_0;D2p0SJP$?@kc_;Tdtux=_xbqI(8^er4#L zY4xgP`dvF)7f{+BiZ$FbdMrDt5!obwwJLux4s)2#^KyTjdT+-t<n$3Lc$nCwG3@)~ z#SZQvZVqa1=bm}~C46VJ(kKN0mVZJSk|Tp}zhA-LG|7nLe|}K=+2ZEn)Awk9)=R<c zpdjxaMpJd`tfSdo$<s0wrEo}mtYp2i|K}C^CwFPyI46dup!^<Tpr@K8#`%zyB5}W0 z)Hl%8p<j*sMPmeFe_Z~C$LlP5mA<tOqupzA?%7*eHZbTkg+?fuG_w==BXHWlDgL1k zE<N{K*WF{6H%l}BWd?D{;(!S-5(hVlS;c8B@Ug2q%s_m4kZ2DCW8dsr`)FPRgWYjO zS}T<5g4@taBB@NtNL0IJIwO|Ja<tZx_LH^<;`dA*MvlGOHq@3IE-jCNmuaEwS~@)h zjTKJ-=H4Cc6fHg;h(p7QNx`}H`rF=F!2xmdX%~^oYgX^CURY!a$2&7~HVv3Qs^~V) z9>?-_uN_R>msD%dSQ(@JRxuHwQ%faw5oP%PA8%f#6uF2*=iriJ|C`IS6%6@@pZMlt z&zat1_~VW+<pmfe(IuI>b(lH7ckIvedC-~=-ca{M`d{I)J9kum4Z&Z{%@f!MxW{T7 z;0qu4Bf1XtSNN1U!u%l;`6zcatrT_2RCVm~r3t`+y1;9Y`bT?6+}534g#IN^4&t@s zp@jE_pfRy;^dEb7u;#kQ2b9FiG5WHSA{kbnc)6rKPce>|Zo6c833TaLwvkBwwe<Ib zc;u2Oaq&aJi$r|Y9oC2adfG!|#2=bhs%eNLu{+fWS^3SqY+W%MQ4`7_1-E6S3Q+P{ zUD|WU9}ApHWjw!TszaMu#G3C<082x#*>NTPY{Ep(mQte~k+jCmkmI`Epvme^V$QBq zM#MCuQLabu|KaGeyVES$D>Q_7{B{=dgopHC#mvycGeX$((GA<8yymwGg%Y6TvvrKS z_S4+!K#oq#Nb}?4q-Z*2An)#V*!M@jD4I6^gHi83`s29l=6I7(Qq>+czDqVa6Yx$Y zOmy)Fx9~~AKJ8iA+Ecd6Xb_<nMrHO>Bnq^8=iV3Y6^@^pHuo!$D*ISUl(wb%X5JO5 z?9SFKFF&t~u~;3w)^Ky{N)KEi<>xHAemvTE^GNjIk4j5F9Y@T+lFpytA^akGJl7=7 z<O-@hW01*1>q5=#v?w)!A$l)--&r=q+Ar;MvBH~EZD)uE^$FlQhp~)SVd=kdjY#j& z&7f5eMAN%+M#A;7I3DRJu-%a9b*YSZFb+7U|I6z#YB@Aoulnom$35&o5QqxrISzv9 z64OJ@?>>974DQ@vZpDQsm$d><*CvTWuf!Gacx~JVX36zwkA1&iaxVLg&pM5W>K@+Q zQp!G)tbuODI$@CwrASYRb!on=K9Q4et$$F=yr$ti4@wWh7L`7likoJDvkw#|a}qxK zS9p!Ra^-F-pw@diMWt%k4|dvXYp;%Km+9%74B-uES6-vd%Fg@n<B89)wQ#~LcE*Dj z<iKaxsqNmzLhW?0qT#z;^%&7qpJil=k=eKm!dQi6%8Mv)oTXF4|3OUHMD>BuRQINH zuu*&%W#QC(bCYsiyzejK{>H!!o>`@6TDw&XBLl4K7j>uyWty!fqW}!IYKUQK)O;}i z%6fqi?l7suIx@BN53b@!mzLqn+c54N1q|IkNhp8TVEoX((<zsU75p0&3)P;rsw_@b z2-J6PLGOJ7Rt6L{;IBT7jBh%(|7n;L85g63Dl;ynfls4BjK39Y!hao;RFlJLO7My_ zNvN^R9+qnogU!X!kkw%DW?OcVWwo6m)I=o_Yqg$O$HbYfL|KNeVP#LL4R^&7VMFTW zoOycAEd?2@t{P$tK5`&vv<J-Sx1y)m0K^75;8I?<L>FVH(1+S3<Fc6+R57`H*U9*5 z7OHrNw00_$UvhA<mP5NIJ>K+P9Apw6!he@E`7|uPnOK9kmswXU(`Ihzd*-$u!YO;! zq7yap04f>26nIH#e-r`i?w{4>WAdrbrN-VDn)ZlScHb~EMOqtJuOe3Ks}~N!f|^oL z>rFz^;yg|wtGL{}r;7)78LF&r-T=K6Rk(4%i9~AF0C^3Uf%QF@J<t5M14#<mc#-1x zIT+G`I+Ri4^!Pp-s<HFIbp0mU<^2P#ncryOS^xAm^$Uu8-Ibuxz@*QNp4@(<&0y2_ z{A8Mcr{Ly~-LCudA-4d2j~7(os>^xt<g(ICorQ5g<Y(D4lAZzv2Vly67`{+?w0(|j zp2?z5wLz|b-&GE_H$El#s=va~XSu2Of6<J$)^b)i$FJ$yhHQm5k*-zapLLY28@A7J z$?`adk_j}zgPogX;HeX(>1r**fx}cqwvI;;;DyMxGG=&Tfe;sZOf>+wpM}x@(|>Nm z$?@^WIY)5y3zs_EN2W6ERi#7DQ`&%-o~k_bU8U+u(}}I@7$`m2frC!CBt9cT?YhKy zG((~Y_{goA=I-Dj1fAZ`H}8{G^0iQk|MM!xVvKi;NL8Vj>n&^btRV*j=H*gTK^f3F zD}PAqD8OEM8mxVfl2rjm#nq&FH+U*I+Jkn82KirDx!w^(5l=z{!L3hjQs$YL2d}wi z>8Td;Tja|RScCLe3*=DW3bCl{{ub!)g6T*hqad0m9)Oh$^^;zsVhjU0gi<3WGG<T} zHS+9e;CV@K16!iY&Ia!3P78@KjX$Is!p|4iowoF6*>UFC@Hh<}f=Ab@PspxJT_v~6 zO<yyP0_Z2?_tL>S8p7it%VE_Hb|=_;G!&h}AdGqBFx2u-A<a>!XSR?~EW`vjIU1!g zAN=q(`GE?a$oKKt<$Ge#WZQGRlMt{*b3(#<8naa1IIseR?%d2-h64ny@q+I*V^fpr z6#Th2xJrCUE@oaxu1a?bz)pO4^4`@&Rk)R0;#X@0Mm69{3SCDhSv_AJnjk%?Lkpmp z(f~K3y`kXG=Y68+g<~h+!9xqbfwY?5EvO~{*V#-O1YD#i8UPz#2<nNse~Z21w*de} z{|^uyUCVyWaCR!<R%j+!1`&JIQ@9v*EQ(vWF&=Wh9BjC`jyQ7DWYY+;yUo5qRY~8# zTp)4o;kr(>1aG-D+C^3|0I_w>I>RjO!YSmif{8<}(42p8w<Y>`Xn$CYE$&^MmRS3< zt^dOZn6@U)#IYxH{SL3<S+Ld#)sl{mtb2MG$u?CK2~hx93G{9FjStYDli5i%U2b$Y zf#`h($w_b*fVgK04D{dyi+lL;n6=h5`2}B94MnSig=2&RQp43nj650Pt_?Vc6W!O# zfAwZlY937tWh8n3lsXG}&Z*q#5mbVHdn;3X;VlcFV14a?V<1o#K!!YvFf8p^gmi+% zXti)d-ciwJNWk_&4T7>*%Pk@Di9qBXvU*O?<*$zhx3dLito%@#3PcvO$0Ve_Bf+<T zXlWNbP76fL>O0k>1W-;EZXMgvvo!>i8GjS)zWD%0H?-OKcuItX_%EZVVtpx`*dqUz zF9uArqEWmmG-vJRwDqq2VC*5_(uY9@GZWq-lY``$in=WA_B084eTUO30eRp2crC92 zSwMF)hZRDa@n@?na3;7d-+1eb6cY+y)U9ThO~i~D2kJ8%TubMOvj@mPO~BrcCyKIN z{G?+!AnVCilC=<AY;9uZ0Kh27sqkQNXr*+H?;K!u=|XKrv4F$PHE~6x8n7?&G0nz> z(RH!9jisV`<&U}$f%L{v_*-;UZWDW=WLG*nsJumpC&v@K$h+=~He|)S`j9a0^2RAD z0)n#8A?}^3?VMNOPk_qR%FFbZ{T?zOTzWZ0hQg}uCx5X;hJITP@~S9G+ro3Z*Hf#_ z;>hN&(z3~YbT8i+Ce^P22I*D|i>Ie;UZ;YAa=($NFMqlOwH8}hFk_XRsSK5XBSu6t z>BC2lwdzI7#GK-stYD{#2Rg*1Y+{U#=C!|;umQjg4acP~MagI@f*a7dV$OYLEBHy^ zBkUi3o|NJ#9R_~+<@ZkBlstc_*}S=Xr3G=vuGvtBZxA|B>Lm|9suj>Ea_=sYj1ZxS zxDYCXF8S_~0t-v?4b%E^aP=uVYq>+kzG}o(2$_mAM67gpa;6SpsO58rqn24TG6ukw zvDb&GOQI;fVZ=o8W?8@ew$l+<M}=aa;592BD&TA7jb%@!1mK?hSI7zSF(?O8ynX5L z>@9ZMVZ!iOUiodLQHZmKoRX_xpus-Q)PI|I9%BywyTEp*?UWTDwl@mH$SJejx);$x zj5l4oI|l=u>j@K|%fUq!KFBQ`YjT_U3)CAgQA-80BagopTY*86he3>{b^HD(L6$-& z&>Tr|^-O9qmTX-vvOZ|!p}O=)K&D)ZJwnJ737s9v?Bp8dnV#-Ri>S}0%ge+T>WrQ* z!St4zKDiH`?M(?T`PpE>!;Y2I6bbyD!BVC4ADz?wWRn<EBZEmEiKAFZZ-st^gH6I+ zybwNlhC7u)E=y-tWx8FZ8{AGM!MHa>SkK2&Vqw+k!*Bx{nM(X#{|n3M!5b2ci)Hq- z9rh)TV|xb1P`#vXzlBH+ro$1k5Bf!oX9My6CM%QqZO?V9s#hX!QaiGi|A+fI(t0J0 zerKvdS@EhVihKakW#f)fO+?acJ*CnE@(;n8>3$p2frn<Ws7SWcHX+c75z#uIS2HOg z@oZ(pP3&UETUtvyS?dRyMjC(M(^zEe0wZo92Gm=~TyBsW(=6V5T@B68T-%$8h)-HU z!^;J|s;j@l6E}IKx}LyP-;d%v8x9CZ6X$pJldWY2$KtV5s0WvM-jVAR2E#ua<heMX zM=wgZIXJ7lLtKL~kXGLOSncsQ6BZIOk@XShu=f>3tsJ?uNG~oGsXqpPe(9dUpMc*g zU3o9>QNPN=U9^i1_QKg%79TVQvSUL3`dXG;I}!;pw`zP2J*92nBp8&~!I9=qK|Eq( zz&7@I$=83*EX)Khh;y*yz_MoUOed`QSHYWHbc5XIvD$jRz@+Cj@;L%X7CNQU<2j$5 zS6CRJy#ZbdFl<!lg%42?wq<kYP`Mg_@RC7bzdX3vN^g`g?OC-lhT$v&ec*Hd!Ee(C z%ogOlghqDXi58;P+b>qMvIn*ZtRXtlmOcPQ#$KEg=K-D|?OQ%`)}Ts-14F;HK?6H? zGB^MO9+PCChELUW<pRmMv%gn#{WMfGlbHRCHX)@|dq5dKi*CUqXT*EX4Uw_%EoU%i z<?+;=$fx)x{dq)1#?M}%m+(0IrZC#pKBlxOb-8@Q>iTHKOzD~JZY)g?l=g-zpjn@; zWur)PkWsZca<+av6b;o3*fFe6Ge`5pw{N-1D>V~bVKdM46qVYU^J+UrM<#8H%t-N= z`=4%|G0NHuqq=Xfsa0m+Vx1l`!0UL>@02>z#Ly|OF3m1V(OVn-3~Gb?)s&7tkL`rN zMe9jOrS#*S^2+5wuCs0N%pEfL{HII2fg5#*jxC%t6P^`^qo`;07sh4|atU#n3JQgN zi(;g=XPtBg(r*?HHP!4x#I+BB)G9uf2UT^_9kWTvPlgDYZyTwKFtb&f+SCDC)a=~t zvuP~bQKwaruaTbu&~Fjj4ILm}8$OzB1OI8wxdV?TV%Z8b7NH8b#kZtY3&v71P}Jvc z>z(KLINKmun&F=-VZOUK#lD=ci-KCuxu!9egg}DCe?Ol<*Hx@b$ILFH1pe<eXLZ25 zhJ3j;2j-{kzyjuj3u9NZkDIy){_dga&e9hNU^S#l&pnAAHfk-HQd`?Y-BtN9cFaMh zMI)eitoOXIcqpA?cG6zg9nGQ0paTOkCUgeAm8>AXAEQYmi8<dODg)!EfinR$zdZ|} zRPT3)!#mkJo(~*^1VB%FC=ju-WxGAW{zey1K)ulRpM*zIzO3F;=h?FK-=_N4i<Yd? zVW>0Op8#3iy#o2%Ip}K9yd`Io2CAXCd9o2M=BHV`NBF&`C;aJ32#r&OTf_KOpCk>c zJ=6s|zB+40Yhsp_ScfN_HbTzr=er+37hGRz24_;ewTOTg=Uv@<*N2~dklKsv2jhIl ztp7rLTaPP;(N8=w5;x>J`ikk_Gvr0sq8#@PFFo5RA}PwFV4{m>;jcdx^E>EH7MZar zVfo6_GR-mN%$|HYTe4uOh3mR4IBGS{mo-)yc9%7Z5C5azW~qhmw@@~SHX64Qq`-Lh zo*yRUKY*fcr*9P@R4B;B!6^sV{V7r%Up<&*7tLGs`_Rg1zq5@3Hvg+uZ-bsA`-|sI z90LuFa1BuQN2xin$d8RFVVbNdG8JX@{>(+eWFZe~PL0qI_-5)FzQ|QBQ4i;1ypO%m zIPa5U=`R!G15F_=O(MEWqHmE|x;K)HA}4@2iniF99Mg};H`ToK6EYFK{fCe9?0fD! ze(NKuHyUIm2rNph9vwvK+g}SYg{V6f+~42Woy_&TOg_Zml#~cG1IlSniq}q7&gMjJ z+SGglXD7!49BD>dpm8RJw8g$l7z4+9P==Li63VtD9yprlfuFio;4-103$q|84U<bO zG0a2yJ?(@{5%-VlOEhTb-uLBYDA#!7ccqzvdo7k|_H9`USsUl<cL=ahh|6<d<|vs_ zcLt-~o=%Ky@ylu$@%7w1k`8zNChZCW0$?^3|8t2x$byW+;oX=$jKHty+C}h**m_Em zNSZCIEvHh^x2W%kfmEs!%%UgBAf3R{+oB8N4tFtOeOOir7QseVi<{7w;oFV{ zKNb%>hd-d<4KR+fPBY3<xW2B$$g-}EP6Kv$c(sKa@HSTR@j}NA4P@g(q;zmgF_LJe zyK_>pH))j>Es^BGfs2sSm8TAG|ADUePuA>n$6^Wp2z-wPvYHY9y+L2X6nM|!Q2%7t zA`tl!81uR$prt!^Z_r>1g@Qdmbw=xDt`BV}Z`!Iuj1B<6@u>Fc1D+_s>0co}L8?^v z`!Z+Ihe(_N%v!!~&EXK%lVtTl($O_)193W!JCu|iH8obzVpNpRX4n;$90ESsz~DAg zfv~o3Md;ORxQo;blLq7rXK-Fn!`|>EoW9<Bb94a%Si(qx?I@%C#=vh=XbnS98OniV zMj#FV*1|)8-h*Vo41<X^Z3M2t-mW1~9TLB;Ekp{qZL&z!P6NzNE<tyY%|u3T&p~pt z{fp#6-GRA@<%jOgI_e*{WllyS{$+b$bBNNe%h-BjP+b)29*Fp8);?B#pue6OR|t~i zC(HJ8t$4+#AK%<o+JjiSG0&_E>GWOE%}bhP5cOM%RiGj9vtSDZ0D!Wek<NH@lz3E2 zVaH-m6u+~?3b0`Ne)Ao29l^drT>xx6wFWw+J8Opo#|-(b?{o1Rq=k<93NzTz@_osc zz)R(0ng@2bBpyrG(-LHLDNG-tOqM8=n$fCgG#NaTl;8^?k^Z3PKvvxn{2+r<jpt)p z{8o<|mYD4;(QiALU*J+D{Z?6!;R9X925-zHyQ;G>qT0`(%K)gToMa_(okFIS#orqg zXp4G~qnQVIgs`Zf@yMmf|7$JG1hOl6*l4xk3Ctn)21ZOQ)@&VM=6}|I2Zw)USm{lX zQlRho_818>_dCTnso7I`NB&cc@^2yM)5d-$N1U1h&rp)ocgO?$z79H~M-EyZp=<II z-#}JxqCgN-wozGy&95y!rAVNL>nv)gcV*kd5e>m|+}Cv6guWOf@+2J90Cv;x)+@S) zr_-DHk02Fzt~Xu%3s_D;ERR6jK?1lPMr?akaKDwHWC|e2cm~XqD!NmlcZ+X>7}6X# zwRRX_RztCTx<p6u3z)?Tw;Is6K(^}<7DI7MGT$=nvFu$z1lqY&Tp>J<Nb$-dm_D)? zHXmZK!n>CBm9-2bbM)Kb`9aeS@*q_VJNrX}XSyx-=-9RGr#8-gr}JLXRu5^&rksL% z<VNerm>!KJ;0t6~X14?z9^+hm2hl%mqG+=Sval&Dp12#8lV|dwLYU<|+-R*UZ*`uy zSm~DObZ5L_Q@3F`Wy+-sR8TfiQ0Sgr^b0V411v1XQmUwgB>#0KpQu5B?~S^RKbv6d zXeu@2&e$}UrLWL|t-CY<*=<_7a*b_n)%{h{wF-w{bF)FuZXQW{Ynk-%Ps_a=*_4yo z`Csxf{4^gE?71JEe_m8J@_RStfq1MZINvX>6pC&DNI<v0k*q{MPH|Nr_uw9+*SZ{t z;^zhp_glC)D51bt!D7R#ktk}{N1?RWHnY|doSuoXzQ^<}S$C}+)a?WV@0NB5csbRv zI5eT?`wB%Al}&qaO5@s6Ro@<>i;n&Y`cDb#Sm^vAEPru_C&2(Cz#>G^!jqDB91?Xv zH0GeT$9O}hQfdHRZZKrA#lrE!qQQ<_5nr~%f+&domltDB<wUDS4e0hAM9F9p%a_f! zoSof2-x!$pOII>9K?=+5?s;pmwU)nQTA2AsF=Vm8-MPGyr$3j&>ZALwoDZ8k=L{3l zCtnlb*bvw9;<B?U0j4_|E|UV5@+)=78oxz?K6rU6JuG~S@#;6aYsLqNGiMBSB=829 zz=goUh4QgsuX0X#EKsv+om2x>0;<##;j>`IN@QJVxqd{5=wmZUMB{1Vl%6Gg2(K`t zxuvgCD9qQ}@$HL>ln2(t&X55hx+r(3gH^kC^cmSZ1_p%~V2}D~R-<Aa`ceaI0BYAF zbcP-{3{nh`xZZsJJ<(T6gDU&N0m~Rbz_PC;@nvmAVtp!gz<h@d9jR1Whu>O7T0{JG z$FwssZx@54;N;3!#5H|*p2IAkt7F^jQmi-jCJiZICV6r2eN4ZeA<_BC(9HDE0J7Yb z&Z*NucyqHs;UkLr`(`<SZxwb4{04G_P^&^MEwZlMtWr>Cs=@Mz-Sd;IzbtcoS<{nU zySaxc{3h_9=9`M!W+<pzX;e@SBYSvV^OgfjFiJ(OYGEqHUTsuvUGfGqO(3~G3Gz?~ z(R#EqYnES$(07^aEJ{OjVwULha(bvyrbExH09v!XH3wp+X&k+7ej&b|+2{Vp=L^xF z`(s#%inTAaa~}Ea@IU(=pjEJ4)l-xTsNgEc?-?IR2X$QsSsF<FcuZAb*nRm3j$n1q zzLOvN$r&2ZZcPfQ63-I<$7ioO)yK;2hma}{eBLN^W8UeS<8f=Aq}M9TSIQ9_D{Tmm z^w?%wQZR=OdXxUicCa^dpj--K19QJf9nS5qrKZ{l)#Cdfj{HJWjve{zVI?<z8~|>+ zqzN1TMj5%0RQIMwXLS>*aKEPwSJ7vC)}!boj?PiGoYBl7soDG<d<+Ursesq$hJj2j z<dd6)OnsxO4E7$0EZJ%)_yr<^^QYjm<4v2cqJf=KFaBa#R@Y(LilB8mx6wpY&>NWF zK*{r((D~f-1Fsx8&>0(q<J5MR(Q-A-ks6_O!QCXi#n0LAJ*c?Ry)*_sw>ykkIv|*2 znx-KvS`bfQvl8Nc^tJkov(m^zg`0r9;uh_X9sR+_>LH!{;T>(y|K1G>B^A$ikk$M7 zI0uX)C$mOmS~>MCB#oAT)3##s)`j;b&FWK<;#y{=htDf3nK3Qclh`afnaD!9L*ED$ zJLZq!4*tQsFzczD1^|{-HTuutH;(ego69e9235N;kFJhqT*z(T&`{Ym?u!SNgP`?A z67@58d*^(e(qQD5-{XHBK6mP;R!Rv#@t-?oG%MAF#JC<O&ow4Ce?#55Wbk=q+#6?i z3?<RGjUjwA8un#SAf14#IW&!R029Li-v-nq<3aXK-niYqXlrl2(F%b;fQS+S`7pd^ zC{y-B4PlRp=f)SgdLM~Suv?)(x}-%pERW)=pm4Hb*pv^fSRO-(Uk>Vwp>v05Y@!nF zweg+zx34k=aKM-^F{JYt3j}=M<HthSJHQ5eWAG9-<^DSR63m_9FY#tf@eiW(W_ZU% z1(`7$3TUHgdh~agLa%wYyGmom+bMyaW#Y#2>i0r<f%?I;n*wYrQ&U<jBIc0*0?nWM zr^v6E)_+#Zpb)R9OdA>R`f#2yoo_d%`VU}T&`_J5?&89z$ya|8ZfL9Cf=;R-$xK&8 zI1_CSwZBcySb})QY^vVkn`C0s^8q;v0Bbnm%xF%T<L@{fr$8}NT@ui#p+b1jcFuSm zZ_tVzPqpN3#_%u5w#|BwXxg9vD<B!)K#`Yl#1FZ5(1EGrhfgtj_JqH9CD%mYFY{rw zs4-cmSS&s#S#bS6rNj0OgrSE2*4;2v+z9Rm-{LmcznhzfoXAU{X+df%429rCwtZK8 zkMa<aRaDcXHKjX4qH7D?l@H0lCFoq{CZ&E2ceRnEaB-x6${}VoB}xF&zrC^mn}r5e zprSrag28KnVbwgFlJqDqJQH4!Y>DQhinA3=53VB_UZVEiV3oH?2AQ9zyLX8bi`?G! zLV8YEXw^XPvCERC6&AZ~8NBBaAcn(t6hLjggp|rM!3z>;pE?k20jSu9L@&q-r&&`O zAkCrJY{M=ZGk;E0SHm*KoWWM9NS!3w(;rW_S|Y+0^v&H40YPCZ?Eld>1RP}#Ek-4E z8?c-v0N^&&(GiCtXD0EPQ4+oI&&>49t~4)_NHt(`Tw09kXtdh7mnH4Gg^Zx+)4(A$ z@yYDVfB67%0zW_?oKkf{>L5^k@&~R{HYZxzJy^9K7UWxj%U+~+gvwB2oy~7z0jM~= zhj9h0d9FE)8enLhQUPv2pEfh3tTp>744=8<M1$L9>NKlwmR6+wvH`1R-XQ=qXxy8S zal`!P=cN)AmUXMV-sS8l%t+gK?^>FV^3OF|w#Z@@hGBJX<8C_A^HCKHzH4%x+cEr` zsAr{kBrFwkb)0$pWLQ=B!J1umUmvI~)vaG4D$`Fp5qy70bNUR^C4k?JPgEM&+lH5m z9`(Uw{&+!8s_K3|_QP={Y$;;*z|3Ft&FM>euSs>(eJbV|pYtwVk|%E7tvxpW0R5~l zF=~8SsrKby!4M!lXh~QcDjP*;`@8vc(&vX@t7LJP+l3c;AQE3OnLI*++gqiXbF^cY zILJDjf9K_igT1K$5Z_}rjuuAn1;*A<GHXqq8@&TwmMxhz8vcXC36xxBG1v-gYN_;? zUMfw>I5Fk9q1As&RS7eLAYfmO6OPm4N(37frC9F?(y;^WSTbkkVv|Uez9~Nu34KW@ zc*8@(?U7o4IThjriQrSfP!OarU_IJ%#NuSt+!3t)*w9jz83Ve-WAR}Zpo@>Szv6M6 zUG}NKL(h6`9U?MOm=o4#h+evbA-F9pMVM&^K?`5U0E-D7@uJ{p#+e|Ye8g8Ep9LnN zIJ#lWP#QfK{mhKOc#ReS7uG2~u+I_SeV{YW%RI3N&fdRc#(#!tR3{8qE=zD`K}U4C z$o8RAH~cf)8iDd)y|TT@;1a*`Tn-HKLnOgEa;k=@S2O^ITT=7#UVA6>j-t9FE9iES z78BIz9uPD=>=~#xeq1bh`Gi&@j>9HY37<GJ{Ibd|6)avSZRqXFj`>{)>0N2S*6PHv zkZvZq3b27ZR(B1Ny_q;ccR4MKgqvjDF=^~lpNZtX%o@G|R}RF(f6Lxwc<PG1gs^7B zq}~Ub+U6)Km92&zIPb6FFc@3nwnZFfN_+%acwG*U2{eD@WV|opbD?wK)*1NT#w{3z z0E2E)$z-`(kRMhyWPE9_GMV6c^x5$|`|(`l46plX1bSiHK>KBzrXr4+;RVtC_8?N^ zdOI{PLgFZ&8VZIl0LSH5v!R!pH3hJf%sCw4{%%TL7?WYPsZ)~M(26Lwt6iOmi`5m? zl=_|CNG`M<M&@qx1QO8mGH_N8?qI)Uj{eeO_KBf7E|&iOr|OUHR203+U}3)pV$o89 zrj<%q(uFJKOUUD!`Pj14I5<bA2=NoZNW-P<PFbVj&M3j)xc@-JncA0OYSo9S71aRr zX)o!3RrfErn*!uThH^??$z5?)f<DR4PMH%lR*u9`*6_Az&p;;O<~815B!Ru=A{mV| zEC@OF@Q>6!14crccKhxkF;z<`XeK(tGiQNJ^Q~CAAK+zJ;{h~((6e$cik8uUC`8*m z@Kr`t*LQ(Zjq5)HM8aJHR#mB6=Z$Fg(a{a!?eagQX}Oo9_$2zPc>Q$(*F@&0ujMeo zdfOxU(i5&VRtrDDd!>I#)dq7?<z@0vNaR91oYVQRNGUo@silg-u3A-^x)|fC^SJ;d zEj>)fO;|bDmr~b`hQn#3sK2tfGaYL@=zjBLd=tl%=WDS0BLN-sXM~~8XLomhDY(L2 z6w;wo_TjGLa6TLLo6XWzy4nWyJvIDLb_YDnEzZ=&1I^10UD;7ubg!<-yCRnF_VMjY zp}Uofo73>esaKCpm(+6g@xUp(Vm_j@Udf=`C&&U(=ZVhu&th#;Ri;CW|InjEIg+g& zyBaEGXi%5pP5C$lcNjU%qayB_@uTE%#0lK{#pAvLxXt%xMWl|v*XZbMvE9xP8H-5p z^_&|2mJOyz*#!~vJgR?35M8!GshGW5vp@y=UVhj)2G_PY&@}LvvGiQ6@GrYO+Lxy+ z%=J&Ck_O#R5<5ysg5G0-$`TF*C#5aoR@94t2ReQ?oP}emHXQI3f_P_Qjygp1K{cAO z_gP(#)a+wrf9Sn{zLx=BsthrbV0NT$G#YId8D8vF*E$VAk3zW&Ganq_f7!LPXJtGN z+fnZ9EJ5)!-eOM?{AbnD>7}NlqdQ7-PEN}EY@)&-K#&G*`;~=r4UiG6uwJ1<cR4|B zgQLonnDT_@!bgDIVb?qISmCyX&97YY34g~e-7YYs$bZ8oo_>C*GlY$nInb@-9@Gjz zR@0ajtVaWa9YOP0<8n54&=h2{Q~+V6;cr19s_icjSp(QOO*C4}<iQMY&Dr&F18@;i z<>P1dZ)MP+BYW*zESpx$uWJj4`Gv2~cKyqjXYea^^PoNTue#+R^gPf2;n6WYLqC@~ ztddg=h+z7xaQ^_TACC6r$I7`D*YcG-_cohjkC7prX-PR09Px`rQ|q%himeFUePSq! zw3IwLbTOoPxA}wULBCiy+xkh35HdV>Cq^`Pi2p5iWF>tckDHzMy)2pqe8h2e8~#dD z$8>2ZEQ!~iQtWdk?^KvUipjUSaITSL!d#5#4bR{b7v41v_jHvL?~XQ+Nc0S-91%DR z>UhTg*6-yz0b?pac*WL8mOgiGionR3i|F=lZ?`5BLr{)|@pUhw_$}m^E<$kyT`;vr z;D<=urO}+hGt=@Za|d3qr%wtoR1v}=p1mfKCT@`LBLc%pYiyz|>&jz62U;B{E0rl^ zRU$HdM#W3c)P!P-*gsvnmUOVZMLU;PokbW~fKlpywWv?;1(=RI!?oTVPHq+`YK*qu zM{UzSdoQHias><ZY`reLLs}VJ@1J=wy9I|8lCoYQaY0<9Gm7`)=uU@Xzmoc!_XHi< zQZYM}Qf9;7!vnfVimg#MKGe;P8!ZsSHq4N_0RyGGV2`jujk{~1gi=ahWI$UR#>K{` zfYF9}ro;>^Yd{rS#*-d}uPC~DAS*C^zM<W&uXFx1CR=l-UG(`b|7yV~q$hVPWAS9K zXXPa-n;yvS;HawkG%`bp|G4yfK*sCwer7Y{^0<C^6epLH(6EYSQb9jbYtM0eV$zuk z;&J8{PjBfF>ZQ-3IqM<ry@st8%5)~?VaOx|EIO<He^XoO)<x(uezOoU@XogG*nVbE zO-}St61v>q;qcp`Bx&@hwP#+wUyq3O$>;ES)(nI6HH3?++fMt@mv<Fj*Qe0{iPvHn zw{2k5=Q~52E`fo6su8DU@nuAEcz0)@Qt8EswI+#)FJJ>dSs9@nHu@TaKWIJ@7xV2! z5l`FUDKyyP9l@NW+~{{&F7{`5(o74x-BZ+8V@1NUM=*-^M`OGcJ0dlY?(GKpT88iD zbVI!*sE#-aQ3@;M60<(NV3c?mxrWAy8+I3b5AdL&b?MkVI@wT>DIM)IzyHZBJcc$- zDQpwnqI*LIX?7zmMQ?~}iW_HPZ8Cn^=}V?SOdjDHLy6lkM%dFhF)Q9_apYGh8Vj}M z<GyYek4?iW3mCoI<WY8kTibfxoxu}Opqf4eGs>QZa}>jOChzV@lzD^v{duD_WOl$v zWU+tM*)jAaaM4;U3&XxbK~memlz;eBWh3Ulie~lEAo|wZoH%CTkni}3EM?d-*H2Qm zaO$=)LAiXB4z#|`z95A{^DY6oB0msEtYJx4Em~NB)+J>79gIgZAo|d~{FQrz1e;Hc zN~|g&HgTw9lqj88BhW9HI?WE_BxfWa1cJisjbPW|o%CSs+(P!rKeLS>0Muxi-HZ{f zUgjS1Y@m_6jF)q310|vbj<`&S!7W-z=Os?xP?1tagP`rP?<m(sv+kX|OOno{yw;=d zw1&sb4Hn)0(O10|g&kZw{8S5t^>iI5AtM=Hn31ic8gN@j4;1%nk(}l$2?BOIjWr`b z3@tc8&`Cb!)HTx>?avZ~=*g9cj7HF5zYi<xgUUcSW4nfx%8kSY{F;+|Fln%^RVqw6 z)0Dm5aRyP!Ir#6yb&R8g7Qp-3)A{%mppYL5t%f8*o6&Lju%!f=K!}L}V{Gker}aVf zz{o4Tx|E&V_f1bL@#MYm53ti8h(#Kq!Y;i4I1@pE8-Lw!DxXfjkVRulc(LBnOq}W1 zw&N7TH2V{PMOB6T$u9d8JnrfP>Oe#zztf+C?@Q=Dkl3h_mM22(V}1KRkq<<p8+amS ziDR#)*g+jBO*an(^dZyiHp@OjYS;BDJ}p*unMurQ{MS9kbyDtJi`;sG<l^_2-!vnG zEd-KQnR$grFw}`k7BrECaJ4slBp-`?JpAj^yE=*v)9K4%5kjt>)3~Qt{zR(gH*;II zI|pf>SFgeZG*RBetcwo{{wv?1@4|hy05tEZKN{-Y;?A!<uKb)5)QO{fC4<G>g3$a# zHLsXRF3z-vbDhzU;WWc>bhu1FmBL^JFrRG(kLzwE?6*<DIWwEVD4Yu^K{5wqi#Lw+ zIJ7p|D0wyn{Q)W7Gp4)6fowIkRC6U3-@#7w{o)=Z6E|LcdT7pkCh#fuv6{r2b7Hk2 z>7)J90!3N$e!fQAu}FM%GSpEsyi?cPO)dXzGvIx%uYN&opL=+GU8ea#4#3&wwEfBe z)iV=#<8{5X3KIzkaZ}0g&sAn&c`L^Az#k@vRa5-kyzRPH;!tPYDnX#p<pQ!3G?Y4? zj<(M@lU`F^S{P=>H*Hu9rFQ1)wfav25@}}lNFls6T||wjO4EYeBU>UtCe>&E(8jZV zDhFiF48kvYo>4ZYF%FDCKcA<4v`O}27erNGH4-v_z!|VPOErPbLfMi;y09H1X~4;| zr_JyyrY0y%ZgBoK!bH5$75GG-HOdPm$0@8>S0CVWNOxjO^_VN9#Y_U(cSwQ@TXJFy z%Z*0+5FxrRjr2US=<_ZKcz*`pB=@Y;x4uI<!F)VTFauD_5dIs^3Tibn%J#%EeDtuF zhHDD{SD)U->3d;O$B#)6@xhB9s5M)^cuDPly0g^7zwNu+Fz}VRd|xJU?mosX8sKea zDBFmKm{!T)0SXB@o*AY>qRVU+A2cr=Ic-=v;4NHTM*zqA>iyi)#yuMT&WrWU_afo4 zEVUv8=k}s{#-R#pKr!ol@Hkf+6@3v-Z*ugobvW{W68jcNOL&Y8DnoT@7^_bExqC?W zgs|2-$q~bI1I{1Zk-?ba%)L~<b6z5n{TJ0|$$Z_zX<Df6X7OHh2G0~!q!eUlZ~rOK z)A)bD7zWe^MLag4giSo}-;bmU{~~sz&x<6?)ia#+?oJ>Sy&QO#UU1UbM+skFoq%4A zYB!LINXKhK7Vv9^IuLOrHA@R%tnKGY47oZr`F6<t4(yq!>R^<;LNiIQ-1GiLpcT4+ z!4AV1TY8en6RdDCDnTSP04YGY{35Qhe)7_o_O`_(OJr;=D$ei80NTK<)qadnmc(<M zAcFer@7!y9@MSmIW!b^90}?UnFc+^?<42!>#JR|Vuh_wRrNk;%$vl(SdV12lzm5?f z)WjMN)@8U(5f7o3SeQT&U2a^(jRHwu{sz-2U?+K@j!Ledg{TmHPI6+^>Xm~WC3cge zVI8G{dD4bbMp8+hCpSU`x>w_b&ll9Gk6#1y_1JqH&@QxLsN?3FmkA6hr^C3jE-IEu z7cnuuC(WuW;GD1^J@De50Gbki#i9>~ihXNSu*CfjgPQ^P=Q$Z>`E2xs2BrPpo@RS> zXX|L1>L8_OLn-yOd;vD;=DNVm2boY6|0=^6WC%ICR)jqlifG2a$-_yen{ippM>c$M zl389&eY}xFVoi@dFZn=#DsibAaN3LfqlsN|+$+6oSHsgRfwsDb*DnuzstEOAVd2<D z)wkS0%PU}f`^b`C{#mNqhArp8?5cyjtPT*WP$j<I-tcWXwm@SyjJ_)6Y7O0C^iVnu z0vG52xl7kB{@ucSS1!j9Pms(n)7MChK#M-X0uqgMbXdrdS(}qv0iCv()58-$u)3rn z%w`RA9{4^Ub}PE8yzba@9vAptgAj;1UOvYeU;*J8t3$m}T!>_nOsj+eNy+){N?56W z>kmTWZM!ZNi7{=#4-neQt0O|qS1C36&>&w<?XNNTWtBhODXyEL08LWq_a1;2S^XJz zK@YTp3rz^NX(59iHBx+xNeuKw0=TO`Px}Cl!I?w3mMb!isRSl8phBan3&d_%w5Q4# zVwKB#0}0EIGaA;S9}Hi$KM8}T&{=bUEw7(ME`Xsq={0^`flTcI<{_hBMa@~^UIOvx zz#oAHWS6Xm=V8cwU=!CWu;jlros6FAuB{aOuCj&)O7lvRHeyi>k8dDeu}}a6>NQBa zgDX`F2#TV|Z<`NZz~F9SxLFtXX|q}XcJ$Vvx_PUbAg<E<1XUE)!^Nm5_j#g{Iyu0} z;l}7#h*V6)ur27uvcH}?h*t6Xsj|@f)9ES}_Ile6>YOE(S=9!IR|4Muh?33s8`8lU zYdGdf2oD=@dg#!>@CVM6?*325pHVNgg5A_`G{&F>JJO3<X$1?e8FYMNjDnv~`WPxa z&U^AZBo?W-gI+p&%y5Wbb%p2=P$U=pXn2K0rWn@xZC=}?c(%fTCtm~<t?CnC5Ql9v zk&`~8-}af|t3h;54n7AcbzO1$S0A>a8*J)yB*7PwIv7lZ&p0@jl8??(n~dxY*+-d! zrJm2F@}A9(jGMgQ-cIt)R>4+M;Kj!J>L7o*?My%8=Py2EkX$CcP2*0_9<e`v_*W(~ zbK?*}kci1G*b0u{74Wuo0HLeRKQ{Czv=HLiVK%=TP1@CEUaJ~TgMJ3bUgCNba(-JZ zCqNJ#oBBafiOL~M3E_vKnB4>XCkcHSih$LIJ001jLhsX+()ML!jVEy&&U<ulDK2cG z|J%s94?shZaE;<Pc@+e3F}e;^cr_3s_$~9=BVM=||2)fEAgVOi+wXu!dDzmMk8^%k zLQDhQ_2GX@ISi<{$&jx<p=xW5&G<3ECb1_%K};#JcS9EYGs*W!kd%OA?d-^c{Me<8 z1_Uq6y+8&=a$1n5#QXmR4?!Tn`w5Z=;6pXrU~d{d04_#=Rsdc$kn{%(n_$tCog5f% zW{qSi1=(Y<yRb>%+@<F{kRKvUyvayBQ#o=XhN@Od_cD%8B==m{m}!HSM0&HWO%*>- zV<$SVlD3vRmYU*E25R*nWSRUd^9K)!L%K4mTqZ$YFiA(5VwRdiJHyK%V|3Q<wk<6W zao!uT@nZXGtU%1sl)fJLCOGlkgBj1T&6>xio4ucu;o?2Q1bk#k9JQb5q?#6s$l<_H zq@@0~X(Cuf=}Pjo$WU?_seIkj929n~4Nzarklw6QXKc_fkE`AuMUT$%gh0AAkYPtR zHt(K(u_x2x(d0^|N?`CqbYqecV4V?fnHuN5)AC)y&L97g*M%sY$vE^z7!Xf1t*0(M zNtc5jNOqh(7O;2=CQ9DN1in|)R%s}SR~}D2v_@cDp#T$RgtXe6kl+Xh16BgH^ax(S zqtJ1+F5#@rPORana*<f-K8+0lVPG<o+BKgBkn;Y-e1USSbpJTdv5F|cq{t6}D<)_d zDfdHFSn_#LN<f$KT0_7WQ>&qW4%#X&udYUT#0-}FQRqPCrmUnxQ0k;RQCu_{V>p&# zm$JYAC)p$P>xuo#Zw$F(3kjyO)~0HoL*JD%GK(lKIa6F~>ks%q+H_Vb((X18fIu27 z&2a0_2c=yHlN;Cz`G|kaO-1gmS4Y|$6MHcuCA9jX{nd|V-B>r{%vXubSlmLgZtb|% z;G_{FZ;~c%N$3WEJ>;r@K`RUS`qx}P2qcGWqeBW1N<=F$=3sI?xagaq;n9!w(Vu(W zGZPuf%QF$A<G=u-#S}g~p~=Gp$x~P?PSOfj(fK|jO8jsfvF-+8VPu0LN|wst#GOze z3~7;c`&-(a)4G`o7l9=L_Nc~k4o$Ofl{#8$6zw^UQ5s456Cq@S)!q61&0|BePekr2 zgU_9XC3q^BbHfbbnV)2A^wmAE1FL3!p??VQ3p~jspnFj?Mk(=<K51Q>eZ|E)w03M8 zZZIlgjs6%}O>g6|d`hsSY#`5^9F<Rh^3onPhG~{abKp&Epe`9I^2KeGQ##-utWT^* z0zD~WoSx%GUIRVLge4+cQmf`+3Kgf*wa?e8zG)$eow9Bp96W8ZxgaZ1%j5&|5El0% z?X7Ft^sVsyY{&<5a_d(YEss$#FJ+0GOcBWfMa+`8pf#A(-gSFm$K11`Kt@KIHimKG zP6>($T|a1mWW&(Kxza>J+#YzWI}`-VZM#zILL4=~Bq!N=sb&pG{C)yhNYCrYG-~Y# zo7!xx^fe)UDl&JqBb94QGM>Yd%qVd^qT?!dx2GG3DT#ztK{JOHJ#*0A2qs%%IXsKV zf1sFKpQH1u)AKG4WzJvd>wk7K4W={*<z}ViPzZy<B#gfGD&~C(h|;TA(U=6hr(d4O zS;UqWCivhfTe0vrRp{%jt(wG#=B+{-o_)M@K5dES${WoOisX4f$DGy5rwl?<jNRzb zYint_84y$IPScKVC-4h6N{{>yk<ouUDEw}~3g}z>;)?b1K`-)eaa55AB3n8sTJ(A{ zkY8S!>Aw>HVwu#eiic%f5f4@?=!G}{c&@n>(+S?|^F;j?L#VIa#v|Tg@9$O1eK8bi zE#{WUNQv?)wfx`}vx3JJjua6sm3WlV4bCg%Y_VjD4Rz1y)@ENP8`48Tq}^MgB@_6c z+moK7ku4Nn<_$jCbyr1fATxlY(jFXA5IRYJPyJmbit5pwdp8ju#CiNKZ<R1gDMY`n z8!BWP=F=Kn1Gw$39$tqo+DG=UZgE@4Q{3iJ7yXpU1VO(x0i6U`wo$GvV-8r)QNLbZ zf#sq}q;^RGrrWY=!-J6Yyvv&cCytG!*XDT{^DQ`M6_Zy?(n&R7L2drS&8wiPto}*; zLs{=;5)uLtHCId_1IB-d6=HBOI1ZPAwIO$@S!NR(I{+qmFUXXU@Kk-zyyU<ah1Wy* z?uGw&O<30}d!#BD=LdUG1>+PL1sgurii44=2l+4WDgToP%!FzXjDV!JRI8vR5M)`Y zpaDLvr|S(ik!ABivh2A(C*78%c~l0g?$L>Hs{F)d(}gDC_iRvUK5ULj3=#2eA&u2; zI`kSAE#mc*IA@a*{2&=4wB{?bR-9<oVJMPTi~Tt#K5jH=>xyQ?@+I<LmV;lcKL<5G z+0ZrYT!-4}dkrMe$%o!z-lUo8H7Tt%9T~_$|Ey!L0GMS)TZA})wmN5{Jml4V0Vi)a z2W~v5FFWIVywbIVlCnJb@Z^%tmwnoXwC6G5ql=nNZgRUQY3KlVDUuVR{ZDd3V*k+> z@ubY85KG|A@(Yr%ps6<mdPF-R5ci5g4oAC0G4o6^CQ|WceyF#W(Nx?Ei|N`DxWBP@ zv`q40dg5NWb!B82-4wioJ(QNZ72atcLlHPheZm-+0&d^$UqiDEUKkjY^(cB(v@9+g zdtfcl5(x&1pW+un@$@n7e6tHa?m^@O>xgnFV{OWLamyy-qn=UJaN`$JRIk~$!Et2c zPEL^${&!A{vvFnUk?pjAMiy}3CM?Jl!6we_Hh&`I2eao45(f>Ju=qkk1Y&jhDpQNM z@O#n81#^_5(1+YD-mReN$Y2QIzk}Gd74q^;rdc>81&}I7Y!`2<NqmoPdnt3kKHbWJ zOw&E;>0aTm1K3|->k3Y|-bx+`#%IcCtEwd*aQZvWM|d)mMM2DKUK)S1&hawHEz92O zDdEb(q$60s)$JUk_M3E_mm=yHpN`G3G5IZTvgx0t%QXDs;X^#e>nHult(JQIu?-UP z)GLbhLyfN*-UXxVHIPQ<_g+<q?v88YW`oP?l?3!TST@LZ*C88{9O#8ahRnN<qe6h- zszv^@<R;_p(BAa-;P@cFYrPzYq2CNU1(<_DP1#F^a=ou++ZEalf+R;ALrPEQbY#3m zDh`o>hr2J(Ki_<^J9_8MULzVA=)OgC=*ty}q+ZoLNkLj8Onz=X+P;S=n<to5eHwwP z%59)2D&EeI9ScQBh4il7gvBZ^arA}rt=X3+)}`0JH7U>29+vAu8yPlluBhbE^hG>) zWSTYeS5;cvgJxUMkQ)5%%ncahHA^F(*7KwOz2VxJd)K9xrgLO9aP)huCiu%-C)G<w z#v_)oCT<WNNMje@<dj4MLXKn5;?a8vB@Ci}A^dJ}-0FdW{*XTNaBN%COo~I_)|V(^ z|C6)_GZY2tVY2fg#iTP<NjUqlzRZ5%rt*))x6uTnE<>5`{wV1QAM92ABZlhNIT1k) zhuN?FiBD0#<4YA^S@#yG3n6u!(vD(61SZjg#Za8dVhA;8dtBfR%zm?K;McJcmcbAG zBAPx~cAFZ{!A33P>om=lomuBqVK@woiL-!Ko)vyx&F^2X6pZwYU#ce*o}-_e8rW00 zg`1mXBdICrD!{y}gC=L|Jnt$Yf+wQXh+9Vu?!t-O6tvc*92$>`jejNr*x@bj0-EQ+ zs<e1cnb`b~IBOxUyG*+E0n7!WD7`7y26e1nmGIE6i6fw#g7dvW?HA#0wM&kgi;R2% z{CSgU_#qMrKk)_8D1UU&5;y4!#X9f!3P}U+qT4!hTtp$Ujh@_7?|}4oTbjoN^XZ~D zV$H;oXx93u>FPOJ;Va_vp==3AVAGhnstOyp#Ckm(rb(3{mvYLc;8lr3_x{?LQ{kh@ z;fznIS>is+IC9rcfM<vrJ2#|yFLG&z_G?O8PvVK?v9Hd3kl<7qFEa^JSsV?BROXn! z00?^!Z>^=R2u`hJ(5reL?M-D)X-jKOR4yZ`y6hHWe7AK*cs9d-Q4DvXG&mJ%y%(*@ z3;3GO#KKX)!%knB2l2@CoWD+-Ow#G(#h!snLp8M**MvAXWp<Ng1s?$jWXW#dzP(Vz zR0SK<U-8do?t-{A)I1j2Lt98&HPa7%U!sQxC6DJq)ghlzv0dg=DSwx443X-bQ}bvK zA!p&nDeq{*WvgHIQRv3IV@;#+k%&6>gSlAqD=NN;+Kf8TXXb;r%zB}E!_@5^=x{k5 z4XSfS_e$h6?6^9Wbhz*?578a7w$E~M_6sz)Ney6lf7f;CQ=OM<D*?R<^_S!Bb>1R& z_oiKvhfE^R{1Z7<_C--9-LI9Y13hCzM`nyL`{2>(iO;bAI)dYCSqNHb8~r;MzYWya z4nu-L?Y-%L+zw<q3U;d|QGH6Pz<-}R+W0E;ygK|hyb6V$N?v^<+HpC`x~ni@`hVbE z)W!KN#AYDm_%$5!=MPssyK%Ak;}-pgAYF&I(L)v_hB+rK!7qFrA}?J}TbyWt_jl+< zdvDPUGF@&O=_HGCxa#p<V6_ugVrYQ|IWJJ2oIX82#DVvAaXtQO*&)l+-UJMPp2=E( zeUGE9nqEyvMv)ZzIS;i)Q0D~_D#k2mDkojdu&+voHQ3cyvC#hS6T@+9+f|<VOe}JU zRPU9Hj(ZQ$Ye)o-^I!{Nq@@ms+>+=MaU%Of@Xu;M#Z_LplS5{o0IgS|;|fHnQX9y_ z{mJa}E+cAtvMC?Y3^hv_e2TDnpRuc~6HIELUx@CDTLi|_x*f=WgX%qzygdX091c%& zw`b7XQN)?j9m9$eKM9gpKG%me#LqQ7+xYSCKMkjE>H?oLGqd%h1^$yB+}|q|II-uL z$w9Si+#TbfmZ!-O<&<9LlNj5lA~&$J=8!-I<F>#)=G`le<$o5_Sto6|bX%}l+=A0_ zHL;eOt_jv2$(RGN7SB16tve0)&4XtACzOa?L@BIbbY+`?qeEMbxY8=En@Qqf{%>(< z0C^`DVIRggD$9wy%h-$Jf~vuWA0Ay<hzW3OHXS6jW(rp_C!NGbZIvNF&zDJ#W8Kj2 z!#GdT_?S^qc{5Q_w;kzQAnOw1%oKi5$0A{(#0hJ>w<M85i~b(=&bH<FA*{L&ukd^y zr+7sU>zSu{qauUM)COo2z|Sk$2CCNd$5_*Kh+lOB|6UVm#SuAbjU>PEZBD&pE~YRF zsU`S+0PW~n+c3tlgH`8~Q%7Jr?76I-`{t&Ma0Hzq<_M8f6s8-4(Q~buB>Hlf4;*MB z-1;(G>#pWC9%^Fq$P^a{YI#F!7(x#a%D(`JCP<mh?OMP$!)sD-pW(P7))i=1dC|+U zliXcm9u#EyB!(v&{VcZE55(wtC(o^E-1&+M08i&gyrG@zMwLvzxGT@TU{%=udQKs^ z=~gm^>Q(eJU`&&4LQ=ZCWAJy?ZK=xMvpy>!pe(`JnD&x??|~i{9Nv^7?w!^GDYwZK zM$7HS_Kd<W_h$_@CCXTEgdxC4%-xApd1s@D78FOx+%oJEETC4|m3tcC>8CUnzB<_& z<qtjF{-e)Sf-imWRB3BLRn#P?`)CeFeb-Wx`Uz{1JoFNXn`8hhz+V#*`0R;2u7Y)i z*_c|VFiT!>QlRCyD+*y?sKQNZvX#TXcu<UE<)e>^yK~;63u<XFsu&v`>Fx0#*Oe+x zs~wX&8qW&u3*b;-=3g-dP7T1);nz9opgJ!wFm>!2_3oc2gq0P@va7y&5&36JzrdTv z3o5fxe-hYYtHm)H_C3J0cTO@tC<ZGlibhO*U?giJgo!YW&%LSqtnGHUSz$?Qio}-V zk*Pyiy2CV%{nQw19Km!{UaHFM?*7TcUCP|vrgs|QZgwUFR!%JFM+8H-1}{Bzt$xsU zgu>{CiHMDY)Rw4c#k263zOPh%VC{d2lG3HslbNAp0o{s0^3h<;@U0h5$VZB^(lpB@ z{10lEX*A{}b7)eRT*u4CBNT$UU^RrB0cQiT+?n<qGR@DjK_B{X;E4WKRIuxV^MfwJ z@i>5bUN-kJ#o*3e7Hj==+haDj@VIWLbp2+Sp9R>{42Ud0wp9XhetDoM<(vXnPG;pH zk0I@Mtm_^}f~~K!3kXwovm^8$c)Fo;>yO>%+Q)z4pU}~3;aBVyysC+Q8jAi)|9yA3 zGv@5b?i9B@7wf@(`e#*QpSgQth+ufXl4Rb8^Ri@Yee3^+Li^5G$*^$UY%Gw{O5QnQ zCS9HT&_we9w=@n%;!S{Uhs)X1ElH3HSi(eWJRlWcL~;K9l+m+Sf?MdgA_C~Yk%;8% z=lHyM3!MS9a=17kDk}}Q#Jp2Qu>4UtS?N$u`Pmk$;&je2(XvQ2qnW9wN3Zm_)H$J= z<(Chpa`?|`O=6YoFdt5zhYCk>ydNCEm6Bb6n<)^hKIj9@^Dz$9p`Z+o!KX&BGfFkS zdalQF6NIpLOcZCXq~976t=t5+EZx%XhPY2Fg12Grs#_clAr$x+;u;;Kyl3FjUo{dT zSJ_f%p;G9P#BUWZ$Ope`y8xK!Vvg38^E@u_b7WQK=d(9RUqvxay-hJ)t3`Z8rB|Fp z4s_Z}iVj_{|3`_!(arC5vqjd<YL`6!LnOFIO;4U35&!zv+c=-p$GH3hJOeIal#<`Z zlGo`zPD90>SJ)Q|GXwR9BK@G5{FjoC{c2TYc`6e()nZ}c;gJp=Ma?<26B&PCLX+_N z&%tP!;%HU_Q_A!|o;gIT+ggsT&fTID*HSm}IrEI|B)~|@IDb>c4qnoP1s^9J@~(&z zPS|KHlIw@<gzq4egeePuKb8S+JM~huYCsg+vbW~23)^wAKGBg*U0kOP^lj+A0xY@~ z*N3Ovn`A4O1@saS-HvZ2QdS>}D$=e`NWmFR4RD&KupR|<ss=CDGD&OGzmrPn=P+K= zXJ;0SK8LabFciOZ{>PD?eObdd=1_s1an#<g`WMlG?B#J+XEGjW()5g|PuI(q99FTY z7&5ow4Rukd{%)JlKdoIz=X;>afg$OE({Yzv-rSb>OBN_e7U7BG+mB+tYdUaNv_Lxc zr)WKxfu&@8Y9I;B(YkZV_|7jW(O$4qV*e%g`j|2cQCjq*FztM30p^-!2yLP`Y)Zo) z04;t)Oh|ARb$V&r(Ct*3`I3c~y>We6vAqyA@lQUGqkti<VBT4uC>&C6`W%=am#kM~ zgL1b3?+Nc_h5{{w?iE#(S4R2G!#h*suYjdjN&dwkaEjY~<72D0Y)k|hFngeh_Rl#f zOxue<r8H(N4QdJ31E*@*py5O5f@#I#z<0%u0PtCK+}@jUP8{i!Bxd;SQJA|L6xO^K zHJvD_<Z|t=Gd{P!=i`CG_qyuQ$h&(Ora-H|Lz8l>K(uU62hDkq_sb*4hm^y8)!Adl z?!2dup!qW&3^#r#_TVIBRD{84=klYXM+#9DW5COKB`*|gv@)!T6y2gXWy@O}b@F+F zz@gO00}rQ%(!SIVf3C9_ERrFD1=gagT8Uyi(ZrcH$~Mh5a6m+a?9O|uPF$(3d4m+; z{wHB%sQSaIg@@m7x|TP!w-2~eUII^TA-)-1J(^gGw?%CloG9bcFd{>b8Nd&fHMMCX zZP^Mz-UQSys8#sU-)Ok5!Cp)ih=Svxn1c#Glhn&`GwpCZScGEa&v_~rLgNJARqA5N zyyJ*uZEGgoFY;tl9F@!%dn^=-?)><Tf+8_R_RuDt(0*VHR}#<D`j@OHFs+Zc{2}`< z8@dvI#0LAi6vIyV!22&Mgp784f{_OTezz^NI8PAwJ#i~B8orA+$ZMwi`*u-(x-vra zy^uM^N^$ZG6rj@Dp}6okz@u6(AcBAtVe;YE=kVYk=C#z?8FrsKO7S`PwuP|9%Y*pk zF7DGP?Xi^HvSH3-EDf2Lx~VKC-ZqxP`t#Fpc{f`fFmV+l4YNxcEGkUz-Y;PnvE73& z%l!?nV53L@(*PkOB{j5j72elGGr;bB;mqX}kTLFn9@utWUy5UY?{=;DvR-B)jalN6 z)Me_3+kR^d_66;(et`j)1uiM%)#KFbxH`C&;LVHpJ*PMM+13o#rTvG%6MU=XdNMSa zQ4AMTxiF;Vb^qm(x}XtDx%4b|)1o{_@PHLK=)v<?*D<?`_z>KMG-W&@d7P9@DQW5M zg|Z$AEQ|px_sc2Ap~H?GV@2Nt2`r^45@Y^oAQI9j3CEgE>)$E5c%Gg>wdMM)N&x=1 zi5q3YeX@ye8}3Za)l3~WjdompkO`1^d&q2#q7X)Vp|^{Qm`dL6K#GM+L35c?kvUc< z<4+Hb+8NW2@9c+E_d3u|dyP<VnxI$Az>lC)?Fz0|fN7>ifxE%);Dj|>+8Av~4QcAA zJp%JE0Gd|7ONb}mWeY5EUb<<>zd(1dAQMGkrYnFePn7EBC?mlwu@=GY1hhLL+vEKp z!|&LS8J`u$QaXSOL$%cJ=J>NpNy<|I1;zWGjYpH>qg%kbSlToxD0X$7TdlWmX7Mv^ zYa$27%1~tEFLm$}j#*W9`c%F=+lFpI*nU(U_MoHK(*^fg?t#nVKQ>5RH>|Vjeu_Ng z+e-o=Gq$MR?p5iiKt@&f+U`uXM+~WWOf23TZ$lsFIOok(VlR6gfM2}CRMfGjS>62I zw@b@!ME`|C%_UN<B4dC$GDPQgjFBx_#1r>{HzQ$4a&PzuZet9SXmM1teG6eJ-LdN- z5m~U20JvJ(cY$X@pU9sIl;x<M;wr#A0{4#5N@@oMh1F!z1c-8&+R&Pj!Jh6YaYP?Y zTIlFgp`BcrtPmEqv1Dze2{+t94|#Op(PVn#%<Ii+Dw?Tj9LI;wC9#j%N9X{RA17Yp zU-yss4S7d&_&ruVFA50Yn^P{y#jX@dDusWg25R6jT0Tj3It;r!-FoVPDkb<mSl;NU zK#SoBZ$$N2QQ|hvESaW3H46f2mH+6FZA_P+S>{+{7pT!Q{@kk2XQ@5_d8p8Vnbk-z zR&_;M8gFGAn^=w?fJ9*mFsV_h4~<-8Yh>&zc|aP>O511*sf$9wUCw*@^2U`SoOWRJ zQA|vGR{Z^jOJq6r8J7cnoyE+^9BrpACyZZz>17zPP6D3$3=RKG3|CyuD{CmjeMGG^ z{VFTN<ft3DQG^sMn4pDcm22;XpBQXHJhrR^H&wb4URi~amS>xz9F2O<z;H|)T|^^# z`{Nq2+poYnkcf4^e%U_tp&bHVW<nUaRmJ_M=VwDF1WM{-Qek0yyuu1*8$663$b*RC zxO&`@2QZV^{kgbfg!S*;;2$|cB}P9n`S16H_>x|{X*79E_q(wM=$1-gvlSim8vtXH zTA47-3eNp9Vz#Wv11zZ_5F&pz<xt;wr&}|>9DfFSRrs1AE8T<($IBA5IaJGjReZ{& z>j_)TXB)#y-C%AL<RiAGGumUzfV-fdacQ!Xcl2<4T*SM*-m48~41nW+zXlu<Y-yG! zuWQ_CYzsVtwf%!NqrGd?k5L{D20)R)abY?eG{<v#rnKzxaTd3<SfOf)9Cz<kd%VLN z72;B85CSV{AG&M2lN%8r+6g9OrrWDPq!_gl`)dDaxWVQIh(-$M7XC^-ShoeXDa~LD zv_bJ&)}pR#YfIKL(2iTp-i7q9$3N0eGTj$#R~~e`XQ-$d)|KPl;=(*EJrubQ5mq}X zEKi$!2PXEc2M}GwWU<;gFA`j7f!iGn80)&bMkSwZ#S~c7T?UEuMf7>jzzdjizP#JS zW8k7^ub{QqcKXFL9b~|_C1h&(VyVa!Hq8N03^K7mLoMfQ%KUo+8m5m-*CennR(YR0 zoc;8@9WT$7Qk<`~5tf95!UNg4S!qFGeR|^YiF0!5vtNu*s*!Olh6W7KXX#)C13a*| zq9dywn-saCHiUD9DN*(cCR+9}#H=V{nz$80uPn~c{=VB*3YAaTeVM*$CZXc)3imAJ z$7JafQuYN$AbZD`e?u~SRZXzP1guP+w5kNNr9a^lI8j55D_w@P{L)bT-u9LnYc_pv z><1yZ;-__hGlr%U#aXc}pEK~UB-r^b7;x;FW&*<;kuIRPqVhk)2u&>LBkIHm-Jb(x z;sno-HpyPFv|80b3(t?H%@JahDJMo$v>H!kGQ%^Rv1uk*VA-9sRUicq-FB28=(5ma zl@7X+-HKg2++4~Zt6&OtfN*V_UpnvHoXXx(63kpEl5}y7fiFv4`gr^yQIowq@`=W| zK$lXjIuHy;ULYC+$A7`g5YYm!1(NkY8iRCCyK7n-Y>rEm7$(ov)tTIosbLEp-A?($ zOaJTY<l^-3HeGvIH*s6{K|Jbp=tem1tip!swaAbUcQJ=SI}fVy+I3^B+EvWXAkJ#5 z5YV_IF4?1AQZ`zk$U^cfPpx@!6pZ6MwaC%Ws$YKh%MN;UvEtXd&*B*Pt_POUeL~O< zjM||Ha5OlDhRODZbNm*O39IsVhj{Tl@`Q>7b<q4K*_mw}hMER){k=Vta4Q};B`(+F z{rMkh4Jfa~^iLUt%e7uj^;$dR>Yh<qhtMV0FA|;PV6@kHHM@d1qpW>>kFZgIrbyZo zF8iNy363poD?uD=VV~XnGC?p+b@HO8rmco7yWgbcIP|4;M4jaEoD)3yu(1cWcw;6B zLlH>kV2Y<)qyNK>8LVbC2;9TjzUZFj`$G)<`GL&<LRdpWL=|%YKKPqU!oDAKh{Q;+ zCH8$VXCq%f=X>QmOND-W(Guc#Ta85IBQ8~Z{IZ7TK^c;xG*j8X#l#Mm%}=Efd59}Z z`m;dhjyIm*5vZ=KH)RyFAV+Ys-k7**;Rhj6iKki4ivG`UNSrn0=W!3xdlB=>4nR3L zlwh?vL+H|9nV^bc`thcf72an#>tz^KFS0j)OR#Y$E#9a)3~DIVa7ICitSb?^Kq|i> zhSKPpjn#R~KY}nX{R#}Vrn(hSQqnxyl<Uae9vdE08V*?vZI-pxaEk8q1G}S8ger%S z0MNhTq&Tt14Asw?ORw98Dq2eho=cp=cz$$e<LnoWa#`|o$#h%)0y>AqS-8U}oS~@^ zDKSPhu@O(%pWb7n3A$wzea*2Zd#_<EqmXXufyGEtk`c>!r?6pv)&FzP2w{MJf?d&K z9QepIJgI{$Ea*lTeRzL2FzUseRP?H!z81o_XYdpf#aL1m60j#`E{I{v3@H=M^}RZN zBaWE)w0&;E?YimrwAGz2B?4uyA;7fjrT@kav$?Hp@Q~u&MZG|_>3$4NK7*>m>2L65 z_iY_E!WMec{Z$Ar)X!eRA&{lZncJ!r62CL)`6$7kxcJ|TEs9;5NGAXEW1ZvWX%&ZV zJuWk><edFadg-n)KnC_23E)xXlHphuR|QoaSrEwNMgD4J;v#%oJqt({!1q-yR?{^? z8YOl+KTqGGWEbq`4$zr(;!xtFLHdA&7p7iWOIZizj%%p;^e{YT_pganUExfVjPB3X zVd>h_+84QSTR_oM6vRE)&>U)5V0I_@-hNc2n%P6&72gLrIS+cTUOr}@9rU&b@PA89 zFC3pbtevkzjrjaQ1^raQ05ocO0J5t#Ksa$=sRRJJTi5hk=w)zu!;7>L(+j5TG#P_I zUvb#Bf9RPW2bY0&U@`nvEL9|SFusNjZHI(4C}EDfu&fx|B%<`-r?0}TVi_qpvh*YF z7-3s;wSXQGX|Zg;`J^DF;*R*7q=IfbByCc~@#PvU01iO|4`lKc^1_1`ImN9J1eRm0 z<|b$3-$vqRno(U{src7PA7oWE9i_26de?S1LR)P=@kvOAsqfhvPOhsK+AtHQ6pTGf zVN-8gMo^hBYFRaoUk$993@cJZ%`+$}{$~4IHZ1o^fl(v&KT{KqtHj*T&FfLB(M62s zUIkhT>nOu))leRP^e~n|WhHnZ-QSjaorwos8TN;AOWq8}d<%oY@iCdM#?E8UrGNe} zQ{U7288>QZ4|`-;_-_=Y7J@I&@P#P3K25HM!dV4T{~of^oqvpAg<@2JgfCRahF+=d z<9b{-V?*Vst-R)-eZrR!D`tjKt;6H$?I3E|7$-u6oi_`cC1w3bkdPSmT_EyDKA~wM z+CZmhyIOrpUmuS^_b_hy-L&>NcyBI>ZjgmkS=(B~)<mx+Wx`s%E=(aE_3+NgWwGmU zUMPtZuLG|aK^-Z{G$#CfUC8GFIl;5Jp!W+#zW1vl7qnT(QlCd~l-Vl$j{P&<2o2T& zXLS+RX#W=TY2<U<;`w24?RkU&F{$%~?sB&4laK~!2z?6<lH@d@(VvFf<vP>$tvpMd zT+-%^bnFw2pIh>XrpXH~MsjN^s}$+&o$%TX02|;F;|E}sUVn3$NkA#S@KnuOpT902 zF6t?^03llNL}%WvooiEvkyp7!@q*%O<FE?OE38u5!Iru>9+3+c;Zbn1p7qqb%CU(M zK8^e1(@UcLv9b{WUFHU->u&Iz@z@`Nv0O*wqf0<G#M=9_WXk0#xSY!Cq{X{!@0HKg zShI+tn32+9HS>zyPSD<2;Z90{T$&-P?uG6QGzZxsA7Cro3jcPSwtDmxx=S^sY6V-c zP$&bM;7pe~06sv$za+PRTEFeGt!&&`q0Lu=igWJL!V&fYZcyWIs>*%PyRTFms}~{3 zpJqrt8w13l>nwIeoWiRCiP$EfW8tNtn!dDVFB&cVB26rEZhh5aeZ9*Ly@+l=FKCY{ za}V(%EaM%P{DLwgK*FjrbhUk26jO4`&wj?<%ZTEVidCto;QxJ;y@71rQcxHVrR)=l zz5}|CaJ9D$)oNEvEdiq%?&D;^pWNyRpf#yStd;oL=8~h!WLugD8^4dBnb2@yZy+xB zc@WG>ggXKK?=lmbXw_s!X4=dVERpb<02PNUqwkE^ZuDGerLtl&;Syci6w4-S$QX=% z0wXGAvZtlTI?hEWR*S}|VNN;NcN+mZZ1lfebYyxcQZ|3~mg|Rk_9>`irFAq@dK>v_ z_kANkbD{}GJ8@M;-_Vk2%C`X#Y;v7z7y3E8<ACvMyE(ALah=7KAz9<6VvT6C-eSZ= z?xR?@JV#{75B^(6R|IKMEfM<CD_&QXlVi$=a|tr@GgN4z{wt${7SQ%9tPUW-2Zpxe z->!1FImKX{5xf7l-fwFJh`q6L5&X@GO+$l@Fb5zo^|WMyZdo_Q);g8Pr{%RhzP{^_ z>G#j$uzM&%r4%A3aYfLvZyG8UH`7vFlyT4;e>aL(O_WbVx|_ERs}jQRxxlZP^=E7S z01X1V-&u=B@RaRHWy6&+deK?xdQ~eaVyHRHm$&Bn@AfD`lGWe`X@r$h-`c%{KMTnU zRhbr{Y+%Qyxweztx)V3g$w0180D^aTNS=$cUdUoob6JtL)hn@F1um2!C9}qP&5KD| zQ8|eC@#N#tX+;H1RyXf+w~!}fiKP@77A=yA)J$R^H*jQa{|b4^KeUa&BCUwd^d6fR z^pfkpS?z&X{NGrL?$awW?`UwOFoL#JL~0E{;_I?!NI{W<NXHKYc=z>pFQsqbME6H` zM?xP8_P!C~TF!oj>-}VF>)<!>`x#by9;GK)f3OM%YYa@GrW311qcdg7XGd7snBE-N zI(&<f<$=-)92a(r%c=SHYLZ{};`_=KcoLNazEWW19dLlE?GxixLMsUmXMLRi^6z`k zHd3)uzs@n8y_E*3CzQ-d1&|_jN3072L@@?a2V>;Pw(X7hK6Vn&!IlsVI?Sc~63o^& z%wuUhW4GWj=jkJ}FS(4h*m%X(2k}|DhAr(y$;~05o_gTNF!+_c)Il>;&n&x@eJpUc zth5wE^0Set7z4l?)Q1h=`F=&R7~nugsFxArCEmnC2;dv%GDpa3(6m*;&|)J`o!PLO zb3U#YZcz<PVLRxYJB4zPF!;~JGmn)=Ni^Ws4MaS59Mz2&&xB%*EWPSVaebLEl%0Vb zIxg1-$@Pw3(AjT#>8NhIGSQ9&82cQ39(WZ|acd|9)VE{KU$9m-do{VpKOH-MqoTWc zFA6&zgnQ7iIPJx&EutT4J}g64&Pd&mfm(uf)y{*+nQufj2j2NX+Z!P?pb!D%1a^$# z>8xRBHIlrvfRL?>M}+9G!_IF?MZb1AmmDbxEbP!J%JRM}EkY?*?-$Ux;F<EPTrskD zxvVy$2#!6OhOgjBu;=z*fb;@#TT*grVEl6IH4f8gPI^Wbl%JJ-7*USB)Ou-#a=}iS z@8EL$DKRGi9IZh<;k)9X#C5QrbBP@I!N_>g3r|MQrJGPN(Al4DL>o@cYt5tpo<S=5 z%I>D#v2k|oFscgg`DQ!=+xCw^g0dP#y26|`ZrFcInp|U(pd2r%ozFjgEMzGA%OY2E zn)bqTCLu*)%M%r({tcT8(I>kl$3FlbfW9H3Z-GFqr4*0TDi@j~e#Vb1-_i!^n)$_V z0Q*y{m`m9HMi2jT$bODT{=7>?y#AvAL6_Dp7Ts%v&_Bg(!s_YO$)ubG=5=oycOa}- zfcJ)(xNR$&wsfE$f}rc_5D?A*hV*GEXc;L>-1G}!W0?2phN=cwRwf2;qQ8U(uB(M7 ze0}(^dl?HB`TSS^xlmg2(}p-5y8)qLs>M~90@ICg$B%R>NDsCOwIIA@fR3x??@M*C zM6tEH6zApuPPyLBQ5DMxsggzsATf}K)JW-|*xX%NE4A%_KXqe|QO~wCt~T_tcSWnr z@0&w2or?Qi%ow06JxhA(M)DVHohchlXMuB|rz7T&Dt8n~CzxmNx!pJ`GNCdFf^LVr ziy)|AMpYgvO7`Q!prYI@n=9Lt_4Agi0fy9B7||F*2W@9!)ybMinE#8v5g(u+l8vcS zpBV$pg=ylUryYuB(|uIgC+Gd3z<-PXnZTM#fv@sBvmZS&nMnlL(Inw!1SFq<*5AJL z8j6tsmPPkiV@Rm`A|infEvlX@loA2VnV=J3av=I@BlsBkXb69=!yrJxseO8y&xYWX z1-}&G`NWgA+J%Eh@%>iqA$9enJU-X!5#k!G7EgeB=;Oxro{z+>+0XF)tj3?mVE%S} zJUB1uv(YaB|Lr*B>Vr-!!OAw9tVM6<5cD_PcTh`8ZmjY%kHs5ylQL&xyL2D=EJ>52 zB%VG3?HZMG1A^S!t?zmL-^BZ5!-B!|@P}Rym0nUDbBtp=6gCOOV8@OMT0E<5J?Q9J zV@wXe25lzm$b$>@{OR1&Hf&g)w2BZlVYkf#HbxO#ZWlQPz!QlyaQ+%`*xdMsRccND zs=t`p_hrRH|E-9omZfloS?XS6*foK^81ic6EGzF3J;`S@OS{#sY1zuITN&dIB(o@b z8^zu++6{s@3KGkLZ{JKE2wRc_a@ZA)851m9RBE4e>;-`hRcWH3HnNlOCwq%oP)w8n z!$8T+_S)f@yr?6Jy}19i3>2UwlsAh(8A)$MJ%}PX(HdwcJnSdg@;gZ9CYJDLjRvmR zD3B-JfHbqAl6*&${xHHvXa24_so=t1O@Ch)*o5i)B-o_8*&VjB(?jTEC9kXiX<7{I zA0u}}tFv|(_<Pdaq0!f+$p<wZ2z9E*Ny9jU+KLO%5oaT_*i-?%p|q<Uc!86)bk9kz z6xlQJEuNIGiaBS4+6zNErvzaJO<YbYcNoBBtH9JjV!;gJ9zj4(I9gHjr1~YK-{c0U z?@vL^_DhHE$*vz-g0q7_gmpo65uQI*jMKDVkp74xe0Y1`Bu~w5WU(b8u+5KqhBIs& z{;kXMiSn~8FMLL<?|sFdXbJQ^AU?6ZeLH;lTeQSk<K4TT4Sad-wIH$sENtIefw~4| ze2teYG<D?{&=)RuzUfXKI?b|1lYRSIZ`nk4xPEkOxqtOQRnhyRWrKO5&AGI)=irsY z*&Jr9QdK*3GZDeok}mcl7xh~)_)Nk0*vB4^V48E$L5u*nd;Xb#Y4pHriTwpNHD*e< zgv9}V6@Y2E+7uCV9dDEYS-a@IXYd&=WmQikLvwQwdHt^|BdCBnTog_IILf}~7uH+^ z_p0hELc15vrpn*Y@{0q`2jBgjV~0Clq+!ePoFEt$GnpoQea#Q*T%l{Du4mtaDz!m& zxzOu?&SuI#{Q0Y{k`9$7AGos+g2g2TSe%pFQUUO<g_7C*>NX_eh(4>lBQb|Wgc}p- zvhHmhKG6*38N@H4WSI?Uy7^)A_IWk1xCz)GG{gVy=~ACICvCts*xv$1j>$=W0%je< zXVfAAcxSun2%^A*43;d?g?4toTX{+iJ3+^~Biyw`Ye4KPHzrUf*J`5H*@ql_TW`7o zVKw4QXWs?HGb~R1;eact`P{Tb&Q1Ox&5y_FWf32ugf^wkr)M?6DW}Ee9ABN%nwfRV z)A_ak`cV0~Vu~vbu{s?QR1wmTC%eyoI$2Z@AW9H-9n1Pj((^%&Rfy+*1lo)Z@N)2V zsA{iAC<OFfPv}VU(ENBvv7#$rCjSl4Oke2s1^WzC43Do%BxAlm5=1}BWG_C&HyKut z7d)7jqU^g)vi}(N#Bm>eOoSdD?Xh_N0kBxtbzn!@YIe1TsyO5EuoY+oERq*_-)_b7 z%Xx7YubwA#juJsI0wtQ=>XDF}g%p3<Com651(hDUcN*hqEAC{N_x~CSn9-QzKMUcJ z4QMe$%a7P0SEXy&J-c<#ZnxbZ;*yJDSXW?JH6H84S=0Tsc8!UnGN!>-)BL4(MNC*v z_4=W>9`K<H0}A}IxrA4$ovkpnpZ577q3&L`0_akl`W$9(JRFQ$wTgch1X`8w`j6r- zgu-Rbj|``tfJmM8$c4l5#8}u02r2HN>zqU4qi%|NLR<EYYY<U&uVrMrC6`N*U)PdW zC!e6Lh1?Dd(EiS8V8o5@$+w~QF6q`_&$0ouh&~9~S$HgrUt-DOao-0)Amx1B{X!qG znwAi^FBlq6$@qo%rV>e!4)&BCB}TH^dD&;h12W~okYH{!)0|Ak4n$bK(^L?`W)E(j zmNgihz-1D}6*dlg=mp7)Ez%=+Y6@6iHd$kfpOJpa$(o{X(sM>ArX-J9u`1aXBSPyI z4?y}vjJNRi{em@<M#YoFl?1}(lvD8d4TMV%m2b*{ZNI4l(lgi*S^a*u9hE{^p*Wb8 z_*m`9f1WhsUH`xy6tdC3wQSpnqUG|X8XWm$zFr4c|93=p`*-<7s(Dstq2}^k*oz>$ ze=Mb({LEl|Qw!fs894(EW5A4DJuw(FmKy)gu2k0Kta@aN1<1lG?!Ini*uaorn<HaB z>KFk2c=aQjB)dZ@WjL_123>w5O&wks8{BI-CK>Gf3a`&*MY&6n-08N33JL|AjGA`| zWR96Tr_mpf#aJ$U>n6xAK0IU;2JPyEqp6#)?fa5Ac7^CVi5Wqf_>>Ew9Xp1lYQqmA ze0_Y4u#+hJ6+FK(?TJPrdH(LM423=V*>-Z*jc7arlY4zpb&z&H@S{6F+YmDM>S5!< z?<1zo0f0MVJRK3c^ue0@ZKTqa=um$9pbjoJcMn;#*!8pj9FI;nY+O9{CoKl?(O+eX zN0&WkSWnw#-u$P>u!ZQXuF`!Jloyve3A*@TWF0mnb5FecvIMT78#}_MD>#<gscm|% zcyw3p&CvxIDtWeTsMt*BW`!pV)J}A~QI%uDfwJ*RIr?2BAeHES^&wVjjwImTi!#ZQ zA?EGTD6_n>+9WqxhWHcg%pD@y+bUOR8A%#TxQH%&k4$HOI207`ur`}?W7F{e;9t#~ zbQbm<wjKnvdL<yuTl{DswZZn)Z~F$XSF`(E=QZ}nqB7|PYgc8Dp+4>cFTpVJ+|@|D zHgeDjnA)7E?DYpO{VVW-w;|V1dbvbd|Cu%6dB1n4#=VOvA8JAx54q?XUJ~G1`9_Th zOP~oDC4W_;oEn;$YON>s1RcO1l#dK6pPeih^3@_SeA`Xy<mRj;hR=?q2b4KBB5>2f zWA}28g?;$Wbs2Q#4O4r3#^Da5N3AH+dF>@rixm!wL58xJ`K*6@-+~TKf;hKZpjnDG z%x|jeXC8+~NC8G*LwkN|5};?Qy@1=fhTVDOpG6!%VpmK;%b~RWLW4Q?%Fq#akwDGG zxE*y4+T2=NpS0_6w@FjkQLRH}@WA9x95-XIz?@H#M&<X3X`Xb|r%>(|Z?pBjk2paH zVxV-<;m@Wg$gk#6Qm%G|VMuZ#|69CG6RBgan>2^lzcY-#{r3f2ky32Zfli87L4B*2 zy}5qFVUbPvMG^W{Suq%;XF-Pmjgcmt{Z|%lOz9Db^g7C#DW8=kU=<Ms5fsLO2;#T3 zLA<WRsXm*<Gd0qj<gST5FLr*W%7!5GwoU!ez8;I)#<Zr3)i4*HX|>|7nDms3>hnpU z?a_Tp*#J;7Yry9^!0g<GvaYm2EFS0RRi6<)TRQGi<*nG-8uU~~s7Es@g}11)JQpe3 zaxAYjvi$9(L+agTsOL2;bqsZ}o3dsmVYq68ns{-ZLX!B+2LN2_j(DC^4ztiLfEvFC zoyU+Bm3-bKv@bxEan?GTq<8?d(<kvxmhrZj0tRlvl1ZPm-4dnWltk*vX1mZfFB`t{ zL84yuPJ*6GaGI8<;%RwNiHvj{vp!%UXfvi$on&_HURcsf>;UoA!xb23Y_E*?R)gJD zD+;5PJgZq4pnUt(?pG;p=xj9pm#41B1dda-UEssE>#xc$5<&j@dCZl@2$UKCk4w_N z{YsvmvW+ZL1$yl6xC+4T$36{FiA*)Z^;GuxwR2OCH=~jes`BfY?Y*Z{yPJt%QZDxx zrr;usszZK!z9Q%BllE4Cf5y1Ytv*0XE)*sLDXs(?!UiRu{5p}Js{gAsIp_=mrg3w` zaHoP&96If@L$|z4UNYLp)f@Vn(lM5=sD#5%U{=APZ!#jVF7_AH49~4X*j;&KQJ324 z(CDnUYsnH!_Tsf8KF0(CH+x}MBp`DUfZ5-oqROVY5Z*#y1eHKQ@;_a+-3^}*96M`J ziuLp;TQ7nq04%+*AfK*`h3@_qF5hY(q7sMo#&Dj2Xtk;cdQ)aEJ$5DAIWWoJ2rR^v zpD1NfE79DD<lYYi6E*&oQM?Aisctly!q0g=PERVfZv?ApHj}T)P2BOV=ZAN-iOE~s z=!D2aRU3CR0&UJYl5f~N?}{>R#Ljr2@hXtb<jPSHNK$Aot@EDrL&QtiVkU~|c|HCV zU1poJr$Fy<qoPb4>S$oVLS#(;W1{WaijIuELl{sAtDD@4;D-<UW8FBCZ;(16G#Wk2 z=?lK``Ke15oO4K`jhk<7hi-qh!83Di14}Q1hvPop92E^qskM*UBCxA;H_FIeSZ<y3 zV!E)w5x~1!ekr);uP0MA0Vg-FD0_jS$8BIIprxV=yBl?LS*HIxwc?hIxl%tZxJUVW zjH%OIZ_KxuqvRgO0dFoD41?l_P++qNPwIGUOl><pnE`tMjx=k#`+RIaKS)mn=6Mbu zPEwG~hf7<V%`n|1>#9N?)ktGP%&O(Xk*(N$#B9*2RmVXogHRd@nQ2HB1~X^?v-@{M z!P$;;nGxfC4@}c3+o(AwgMdk>*=XQD^Bl@?f^=ZSzSAg++a$}p{}0S7>@gR0o!<3) zLE_KndzDUUhOMVhRcbfHVa1OQMXK|+v8dV7^rP_y@5}=c9-uRQF-~e%RjCWvY?#ad zGBhanFQI{`(^zCR>CDhsOC2XQB1AJNLfz8}Pi)-NeLszt?vTb8bwR2z>){kZJp-u^ z$QKJw3SiEiZ!&Oj++)R6b5Vz}bKkAmX~b!N09*v<0o+HgV8!P`3tQD=N0wRDz?nX+ z=j<<ZYPlztxgs2>GDO05e8{lyPK<RjKn1BKwAk@{ZRH1=d7FcoXzbKdw4aowxtjia zerhdP9$Q{V9qy;<f95jO7ZqI`tuXnRy=BODm2lZ8Ec>yJz`tyLSs%}6?C0pyLO&2g zGJdJZHVRW${P4l`l?*w!W?W10oGrB?y0{YGh~A-}?9=I6DiTy13AW8{_(bNT+KV4( zWB@f-1Zrp4FChtzzvx$ar0zgpmDtY1a+R!os)^XJ*+eRch-~ey<HBxh_O|cA78mFD z`tJCGmRrrQ!OYTP4|fJ}5j`?RBD+R6yzmFyLcyyIqPG#Nm8DMkJ&%~|mFPBhNI(>X ztVF<NSE{mT1KaMV)r09F9ag^HUiwzK3?i2|C>mQLgDr+-LUe(cTq0q~zc>o{lK832 z%5K>v+fhPyY)%7|8*V+9ieQJ@$(SsgClt~?OszAIR0}weNIcxah^nI8CR&2fT@;+= z+?@3oDP!Un6yump+7px6LG`=XD*jrYBj|dhLxP#p2dj-U+b^-mhqXm2X<t82<5$yS zxBq1dMQ;6z6sFx16QFslX(h`P9(O<(cE~%6P2?Oc5h_0u@-BO2j?iCvA=lYd*>0&X zLF4NgW;RJC0v=mi45Zf|QvH<~)+krQD!BvIKUq9s_8)8de<e!*D1sW(?zhY2@&{Ep z@H9FTYY8nLh#o?fz~dY#s0i<CO=`+SWVs;zr=d}bZzjYwPyvU~flUem``#$kSU@a} zG#U4)uBNwIr(Q^j3B+7i%fOT?Xs8g|JaDBJKlHSTRx<aw7qa8hh?U4;lyJu0lob}l zr?a@lz+Pu|h{^hi=`QLhK$u@YRcP37$)ZqH5C!gXM!u_wv}4|Vb`c%sKu|NTT8foA zNE@>vq!l;(gvN(nzHME|yxfv#-O{<HN|I}Vdd$wPLhNzg|KA#yNs=xxIXrXy;v=~6 z5pIUFqsZKm1YomLdoQIystwos@lFG=>QKxz#$2dNUmB77213QU8#SFCUk!GrUxzpb zO8Al;yn|pkCqbAEzC8wjO&<&$3c{#+d!U>3mRUh&68V*xKk<(5OJV1s9*SQ5z?0Bb zVEryb#sd?2U8NRhS5!$Nhm<8!rF<T`5GNpfNHo9c_$jAut_-8RT?+}~u$q%sW<~26 zYuoSz#Z*cWbIwOvoJ;Y0SwyCFC2!ts7Qfoqb#vP-`b{0N?gdNe#8S{m*_OFH)E1P% z#aO}{ETO1&gSJVf_N5Aqm);O_r^w|)Q*5(yX8R7TS~ZdOK_e>m8ez|Fo2cVYE8np< zpR5M#B0yzF0>Y~vk5}cwO?8UvsPwEDA;-_bhkn@MFwHa*ZsDhSu4^cy%{AQsEhGuA z*y+7q0u>_*@e}v>n;>#S&2KZ)ccn?S<R`b4H^Cc|FDI47dS&<bEt<kiPShz#B*`7Y zQ5C6#m}7(+UzHEalj<qUIj5%DxRsjLEGteQ`-hRP`>h$m=;NG^3B>5YAX~}13ucMW z^6Ds#PXFv;-l+ETIvleQ&l6@bZi3CM8>2rp#fL2NNT3&}`7p~Dv_L5SxmXhFi|H%U zf+v~!zi+e2rnwbdaRn4wSKwl*E$5|grt_Bkf8N9*>;ZqPk2}sx-JWgZNK>G*P4z3< zD=^-;wS;`9YF~R>`g5ZF34%W&a6mFt;qeWap1E*)<;X~;(OyE`>r9X=6j&MWk_EjM zrIB#Iu9sM=A@gIGuxBEBz{FovH}l+cO3Xb3;KF?A_%!~0>Q>GhD2<{|?6_E&kW?Up zogHdg1d|*m*W;TcuN>W~ak$W=6?4A}ucri@=K)#o6?1O`w6T#gLqgO%r|IO~1FScu zo?we=|A#J&msRjmT=qS;x(X!q-GWf)Q@D-Ms_}1-t5>wU;PWvjdHM!j%RRYbJT>d4 zaicCbH0gbFXDd>Df{E{POx1r47mrJY3KAchpb>2jyPr92+J1k_aAq0}(@V5rN(wSN zfOJi@bVzndERV6zf-4@TBIbb!cm1Hy8_0sk+V<(9!mzBV+?OGdD5&2ZHS|c2>H7tm zn?}#gKcd)ONiR?@5fd7y4BJUr_)hV<D^*dj^!uYMefaDr$Z}CDzTHN#8wWmLXiSyN z@X<!Vk4ka#QM27@?|8ddI7-a;qUbz2oFoW%Kf?V!J=$CAkC2S1e-w*J`yZ0P9&g(_ zbBCi$*teEKz8kgLcK&v9GWIEQD(n=+&V8-7;b{tf^$UTw!emx0XQ2$+$Wh9}IQE5B z8yJxiSu<W*63zQ`SAm$>QariR;cW4sWVL&b-|o{&7|1sieL%SU4Z@!kp7ZfHelj#R z19C4rSHp}I-^p4spq0)vL4C*%7Go|t>|^#i*rWvz`=c=wUvZP}e4E+an!R$9_8C*U zh84MtE?Q?uq4PjSw*QiI;`|A1FDF<#ZH&*9L&Pj5U-~tNS$8HMJ4?1yQPjEr$)VK_ zI{`(K(O`d%liIu4DF%JS!t4}DeSaBbouI+mrfsCm8eEeaua@;QED|SLJ^#Oqd&>hR zJckRS%wjRk_H}I~gs~pc^x}c$iG<nF!s<4lNd<LT1XE;!@8@&_+Xhs1trTF+il-2j z&VcqA?UgG$f8CmUhpIw`{+rlK{{~SSRk&<@jRmz%RfM$u!#N1{0E{BVzai*z--`Gd zZe+7BDLSD^w6JDfHQUMwgbs8x{XMr)8A#O})dT=&$pKi-7|uI)mvdUz5P_GV`dZiX z9@latlx4k2CRQzSyqS!R=LIma6J{QzI1J$Y_~0r_xVtJ4z{2zY50~z`jIglD@I-|i z&{PS`zOeas%z|yhI<x|bN-0W<JxP1oo_>z{t&fEMJN*S1#b=@z0)R?lRE?nm>o?r7 zduE(sRHu_jW8`cfu$+7cVY}P>^~N^dBZ39SW>pR=*C+6ma+w-6_t9T$D2`F_p|e_% z4N@lN@pd^&L@yMz0Ci*gm96QwilZtdV>3;feF*fO$H?R=O{JyWfWT~qaxi70=a#x^ z-WnS<Pnz5hX!2{?1fD)od>p0#iT)}0-N6)=Y+u{|Vi!pt5GnXr>8z9(;(3e(XTA?n zl9&;dQ_Hf6>U6ZSA)g1Jnr$hV)S-qHST*un^O_`gsBzDaVjAU;4sm?M)kW=Z*}4Dz z3Rj5iYJCn4v$aDNQlhN+YVi7~tosriO#~BL5h`7cSJ~?cHO`1o0-p5V*T-Yqq5f(k zsl3)poM>YNdmR9*=-ndgBHtOn?_??pM@_NBgdD{O9L#(j$$kpbv6-iJ0+t4U;Uu3Z zTv?i(`R*uNA<s9h*=|I-)kLd9Xrh|h?kYua0aGqBCyO-cu~<kd0|5FqIh9^(z$Owg zLLn_cgjkF!-1<o?=RwcSl0K0>=evNBJht>G>gG|F;`azb%E;QmO0*^4=deKI{(dO5 z5k3`v3zXQhiPe*)Tbtg_fKVkSJmCD2Q|v@Q1Ej|?v9#zKo;BunYyNj>4Hm|@dtM8` z7kIFcsQ6icB=TCHT6{yufA8cY=7NzW$H(?^WkYQxDb9%KmGuyqZfo8Z$yeT(C5Yh| zQFe;dCi7gN^np99C&XyI?Lb|c{kgmKA2~os>dGd;E)8P?MO|}&8Yr30!J&?o7JIRq z5PPO>lU>rdzhHx7vqtq!+=b^lqp=MkQTuX+W*Ge>;*J1|j3+c|@fg!q*7|gviLN|4 z9bIzR27s~A8(5YfxhYTm3!4uJIA&gb7-3v1DUS=dcL9);RwtZL%zuh%UjmvamBWP; zPI*@IWsOl`L?*DbR7QfDuHY5OY7~93S#&oETGLshcLCEZUyQGF16GO(TUh1-M&iM% z@BM;NTVjbEZ7h`Z^S<3v^Ex$&l^-ZJk+3->W8h4P^mtDRW(1d}`2U#R-TuK=lL<p8 z!AK_5RkI~z4gaVq5!H=5_WcrZWgG<6CZ@tQM0Pe@wfsF7+_cOB>5&xmnfwF#>_aVe zN}V*59W)ZDyhX&M0Ac2=q)~Ll&)T}fFi|)k?svZ2TE_&#P3sgNenfL)vC)e)V}0zM zG$=ok)nbH?_ca=y?dk~nlPDqEM0I?I<%9FeGRbALg&$rGK@~PMk#I&-3LeQQ-mFF{ z-E_AML@0zTP=h~dEhW#PEjAU?jJ8Ngmf^?)+Hc1CE|->EI||E$h|m@>H9Fp*)FLCI z_FCnbS8rO>YEQUse8#Kdi{I+)w-A6tQ$vT`zeP>JP%XR~_^c?bLB;$L0KJ5&tm+mI z$O8aG<vAxq?0(H$iz4%MlG4gkK(lxHtSu^a3L`jiOM^cP%3<q1aXpsy@rUH{26wX# z%TqA0)B`8>Zn@GoL3Jxw${$#gIVnZ3JVcbW%Ify6k+fItu8&Yjx_pyM{xWsch(o54 zS3@j2mMVWiP<7TlDY~ZT6-%>EsSqxNd=uTenmxMdGzYaiTk4?pqw%mMRhjeB!@EF< zhJmgmi*QEnvln6;j?a&l+?;yu(=z4hV>_2q*~rcUJXfp0UPmYOX^G#-2aw))ScYK? zBniphF*;T=5*Q~D#R~*uSS`T#lCA|qk0b*Bz|*LqoKz=fb;thmNZ#|?B3J^E5o}+z zD8c2fp$#JH6<AYZ6vnN3dzS&%c>*+J4j<l-gAnGYG1ut|+42qfO-85a=6QucnQ&4` zUtpq$G&I_tYSMQH;ZMRqBM5{;<vqOjUy90D9H<S{^(fQYiDvkv-_S!^(QJGN@f*Pb z4BTM9$AsPFI7MOI+z^>hucy_<EK#F3^|a2tgI#|syq9AmUmeL=kPCi(R_;;8n>&>j zcCAA(Tv!ILnM)!Kq>JQtVzp#10OfF=iG=qB9!?k%rWK^sz+)0mcjJGJf<SA%UkL(5 zZ`nzYesWkr&b2Mg13Prw^m~5hV~^yD_gOHs?~n9~SzNAzj(8VEwJ7M?Vt%JNxrijb zai@fn<n}sR^{Fs|cAW$_6ObdVtRgs~nd`nLS6Tla;El~4j5bk%@itfof5De;TB_N( zmGbOcSp<^KSsJK)`@^C1{cpS=;2UPUr$%62H9S!Kco-&e*!{j{hheA_*Y;9POizXS z-^s?W-uKA*657S*2C}1XMHBWWl6_RKm3rHW9)-Vs`&1wAuO=-3-)Ni2vStRh-L5K! z4osm#BqtCcPlA?l!lR6Y8FhEuj^*q0&uC!gr9tT8DvCP{+7EPlH5@?57F;RjK!D1& zjXL~fA*ISo<mEs@{9y5i;GUjpMZ5ds<?mVXIIb?F9mS^fCp~K{u=tc(BIzcX|J$GT znoG>-#%k$*%{3DvS4}|hF1DJACo)6qJ*@t@_V?&Rw3m`b18y$rrSdmT)!e@iq5c%8 z<u90qZt#%!SF4+?MCZAu=w67$Zmm@M>|Re??=982S5OYAq4FN(+yrAimJPbE&DDc> zA99b$iZlOf)6D6!_*u(56C-sqgQXUP|J1orlIF&X)C2!I<oW9}R^l|6Ycrgp`j3QK zz8hJdB`Z-X%go#%@e6JhPMIxkpm-@OZfZ3#K{%s`<@5@fYEXRQ&(n{!70Z~mxQt~@ zC+PCxqw+5G#TM?U5l*&ir9>+?i=--KV>CJdV3zZL>Om`))kaDOSPf>Oc!>`ir2HXr z!st3W8ls<|MU#18`xlHN{GJ#Mi2};lN-&^6pOI&Pdy@n@hyLo2wZp*9SkVLpK589g zwKBj+12(Jg8`&muF;tlW=u^CaPG+A;YUc5BmgSb)KRlH=#Kz;+G6zXz6yT*ja}Jq_ z@kq`(Oi;wMD$6!&w9>5hSYwC58zS?Le??TujD3Z|_a*}}_1D7mpzWd>J2+@LGKPYk zK*gk5RGf}_t?4|wA01g;It*mI2`-S~4v=Z2$rNgrKBpBH<j{rJ<gIHu8yk@ni$@pN zOXl<%W8CAKr-UJ@IKYh=oFWtfZf+7~o9DrHxmX84ps$itb9vVz6IJz#U?!AI<BbKf zC51awPeUp=Mq_bU8tCtzr@YiWou*HPcReKA(BbXCJqYTdNhRZ@kE1=?cASmjkTeqM zDuT~5ZrLm7yzjKH_K-R$H<sA<t_f;XTgI-m%`AoKd25}$vT|QAn8kufBHrEis&T+M z#k3@yVA~6ZL38gbmf`wbxD3!#jBnM!l<UDj18&1^kCmA<|60y3kg@L~Z2R@TzxkAI zlZ8h&g~VgdGZ3@ZOG}=n1yl1_k;<Dq)Ucp=i)|<9Q#=wo>ENE)2v<<T+osX%;QNRB z8}?YEbPjFN6?2B3+D#GreoRBCG`<NO+UR+tCW?vW$b~A&^ykY8Q4)K%`y2!<d1uJ~ zLd|~0V1)*BXIiGg>@6%qeJDa7>;0x-%UmMfu-XDMI1Wq}VrO{WhQuhmcw-0%ps(5K z5>i`ecz&z;cGmRWo0s}En^#*G6%x8pl$UOckq4~WAV*QBO7&DLq)-n<rWm>^n4xCG zw;HMSK6l1Mbzx07|CQuly4c>;yZbwX@JX@u>U!d>dxn!qnR?p<{Iltqk`S^X!46Nv zqSfOi+X$G68EDQ%TIn0C7eb)&-b9mEd1tk=9ev4N64Cl%9396rDtHnV#tz{s7~;#@ zryce~hX$kahn-@4-OV(2$cjKCfX+6nW1OH}h<?t2j0=h*gAOf70nH>)x()qLqJ2ty zFpCL9m1edWiFw9Swef!JB#Of<&~WP*k>+Tv&W9uan)VLad=`R?6N6^Lgqd$A7rlih zjf(+LNEI|n%YCM_*Zolg<+#cSzk<-xoIbvM@?kmN^pZ>~Db81r)&`|LWu=6g4pv|w zizr~r?$ZA<A}5hlFlZsYM>>plbFOo)II>meD!&*R2kH?L-v4xVeT0z=2EBlk8)mVi zf!9)P()N$WKlTVYk>?yoE0V}J5#hoR&hUE~PUtj2dNKxJKw59GI>I2WBJ`Qf#fl~t zb(%1%xE&gXAj0Cp9X7w$^%PXA`@ToBV9Ex@t;RyrDwRIo!j&wvMk&qFW5$K*bEy5t z<Ls+4By~p$PF0LyJ7yRF_)B$uZ8(y^e@t+P(Z)>}+N&4VeRV6__65`GywF216<Pgu zUzCc{qP`YxSITTf_~hTvUdR&yP2I19p`HnJ+i}@(A)1KF3jG&iT>VIkzkFIt&z7v{ zJ|(XK%plFUK7+)1y4Oz4(5R87KgUEoIqe+qfH%lRf`9o{bWkHApNHTh6sX&Qh1jfh zzz|Of%-ePAIjad&lx}i9WCeL(<rl2hicQSs_q5<VYCa}6n>IwW!n;XR$i_n1-AzX^ z?yzakQo#=s^wum8=(?ZLq5yb(c`77MQd#unqc{01YA3x@MA?ULAnt}%9^>QY=uc1q zD_E#+knTgyf{v65ybX^*rhA=$GI1d-|IR$wx#UJv-Q+<wMi0B`RpmJNR!pOSy`r!b zgN4rjHZ9G6_m>HhwWdfy76QUqIDgFeJZy#ClxRj2^}wD<iIdxZqlKx$!ByX*fi>pX z;MWRi(UbtFl{VjMTuOZUdC6fJ0q`p5s9jS{<rlGHM+_T4YWIas#}0Ln?P8p@vHZ=G z8$HYeSWMDuoyadCx-PhO<S!T76-whw^EZMD@A7Q~Vg6z`20{x42*vin_G>+4n7rqs zK>A>s130Xprc5)|F7E&y(9?w|GMck$a1QZi)tP7GeK#b(3~}*Gm!vO$LW;Rz9MX<P z|D}m&G1JrRm~iMbf~JlwKs-=t7XQ$!ao|7+*<|KxC4O9&;*kV2BVJxtCNgx!sT<S~ zP{ej$kuUMwhhukH7@pC!4)m|nSp<e@yH5Q;#3nV_{5Kq#LRy;v;GM?13niDs@Nj*m zVt-p3vkAv|#ugB7rD-+^dKhfZ+hZ*zvMerM)Y!qKJ0Nv!YPUI}E{bc^A%ts3Ikf^; z&15thaG4<87piSFlPx>Em>fGRK6{Zd@))if4{pOzOOt(7be;jrPiEm0b~&}fE@IOu z!)tP|z&(^>sfa3+4`E+r6f~c_#n(l$7JIidCRXQ9qTAgI@0L+(>B&DL121#&*3!tM zkPXG}9%Ky@Q3M=558Mv|culFf??L=!3_|+t92H`6Z!RH1-E$YbYGbT<LdfkSe8DjB zndL9}c1!{SXo6&A128jre)p4RI}4B}9f@B-vzp{y-nmEe(Vv!9m<$qrmD)n!d)`3) zF!Aki&O!u)wiQk<jo9<P{;?sQux?-VgAMfcZ@Zal;UqpKw>~LrG1^EOGb0xIKDZv- zBQg<h4CfC;L!gTL6;w9@$7F?v!pv<b*Rk9;0i~_sMvz+tllgEPitvtl^ya3bCL{R6 z$giH8H%udhT(9KCe+HDy_n!6N$t{ZdUWmwD!tbsIlR&{-oayA5ju`_tlI#-)B_6>r zmDECe(-=Z4<wxuyolx2z){+D&o7h+0qx(sHJ@IK+yG<REA-0nBAG8=X3eFTZcTrsj zoAP6{c6_u`+vyXSh}kc@KI`$gKgI6B;7=GgF*>3SfI-G?KBX2ft*s|<M#e;j>vGmS z?k(v3y0ihSDtA|Nw;kXCVao%ME?hO6rkS1QNlWo0l8;XP$>j^_;?LK|!9Qfa9VfTn z2(LiRDlSa$9V#?AjsA#nYunl2&p4^}RO4z<WBhoRBC(S<;KPxCz8b}(`eGw6v%u|5 zF?yMwd~oyvbmxMGBd$|Yeu?ew$6`v!kg}HL0ib)dVEV@h>X8IXhpBoTD3%ORS@^=c zmHyH4V{tyMRbnKW|MwO;$m5YKRC9$F8~(VISbiy}x`=s%(WIL^fPlpHLMk1ILCO{? zO6ZBmTKBpd@M96m<23Y`0#gTLKn?M5#Rl8pQ0yd9?I&o;U^Ng|KO#rNW0-3*(RL*} z>ZLNf?rSD3FAr)r`X~1G#`m`y=!lK`7B6j^xNtR~GsduPcr^p@F>WPemp-bqiVvq7 z7vd$;0HP9d5{9kaDjX!05qZPE?7V=QpYGqDl^R_TS6MiKn5^CAY(Xm`DAmnSRqEcU z06=?YV#SLkwIa_u2A47XN4dcz*1Z7fu{^Nrl@idDJQih;Z~JeGJtMS*6;#~RLUb21 ziy4!|m?`SvIA{L(D|4}^{&R{*F=sEr(=jeVeM+l|CNvHSm|<bfYlCj>yq97E3fAjG z8oxoSlEoI~-u_3>D2Iqf+bOqK#zIe9vKF((yu^{Q&{Kw-Q?T)R-|5?z@&oH7$-VqW zT%|-Qcmai}ot?b+FCb`4AK`&)(`)Tiu3n(W%2B8M=D;Q|#7I1cn6Y01*-dUU(wWeJ zTSplBxlMlB(=jY9>0^SoE^oCmL2XPA|EnVF3J`|q{b&RnKGxYzi6&dE#YUSub9Zz9 zd@S(ZJ*Bk-`spL~OZlyZ6ekQVxG-Y4b2;x3maK8~|Ckhy!jIpJ6Eww>X{MbDgW;UH zH3JOF@vG)K!VQO!`;+sbQ|I6sq*{MIJnpTLzR;;C+F$T^zJ0E3?uhbQ;Cl*5d|UA| z+uBFcmzdAYb`uxR7Of?>xb_|$zo>-Q6KPzZPk?w5O;^<&ZZ>x0Ab#q$WUpu;b0u9% zZp<SuZ)N*<A-2D$x>3T4Tt5QPNCK(KEdZH-c=~3!j@l4?xowFUD>JFQCed72gCo)$ zGZsebPKzSvbcErip;Jx8XO6EGpqLgmZ>*iX%g_VGS5a35VW0r77{P{#f|KN_dKsxe z$y#2T<_vu&ppmogHRGiE`|qm2S98`17o1);Wx!CCq$7ERgswpBR60w0&-@Ptaa=9H zhq>1F*5(_-oe=>CmB;8=Qq-qJ36#%9yWA|REvqRlX%?83T)j#!&`ijEk5F_OJ_iI? zL|p5llKipSgoW}Ly%O<1dd9gNuHnXt*$r(W+7&-58-~WR6jWw_Lr9J{Yz!RRV=njB z2xC})3`~0**S765DQCUVR-S}#&7qp^{I?GBNXRSa7m|*s#Ok=>;lUi=pob2sSsjIM zqiy)emAdoJSJS5ni!5nYm+lIA0OJH04y$zp-kFdx4OE>)X}zh%6{z$$dBu!S#+PEs zY4?O69zy{gCv5n$(AfiYcb-iod}7c`R(`CoxHz6aQ+}MZOJIrRokX}>&{aHiIB}a$ z8`Zhh33Q>(`Q$sbz$a;X>mxuRZ$o|4`j?R6qZXb2(V+rZ7|Ynfb$Mj$y7yr(0Z>Td zBy9%{JjUWJ57JH@EGej;7P!imm}w=kdiW=KoVEd$6-55Rz%#&No|9W)qZPk?SO`zU znebaM!0+N>=G-=O9op@a@SG5#YtLQ{H{=FNNiliC2Qiy?@V}W)nI;pexwO@xBbmG9 zxncG@@KpJ?wY{Z!H^bv|^Ie`rcaY3`F_Y=u=nJMn0S8@x^Dq$;O$POB^^lop;=qY8 zVT2+tqWwlMu4S#^^fT8-mcPmBhMq4}=07?<1rlXM9aNeLmpU{`+hJ=MU!@_bS%+J^ z{G3@rsj*i~sQ$G>-W5L0HWhw;CDOiWlvN+!)O9@ba6t$OGiMzSeR~5SQQ(bP*LBeF z!q_igwfZU%%jpk!sF0n>`8G?tEqv$%gBUl2PN$$H2phk_o+UU37EXjVnBFl8UNu0> z5;ys4^8J<<jW)KvFYl7I1v~;al2upBcC2S`u6YVv(EfhLMUL45ZcTw1b&H4#Hz9|< zyGl?QnTl`;&>IT^lzzc$;zmhDK-^;4MOX@XNj|3xjn=x*6D0vBG8<aM$M2chc9>JZ z2r=ww=vj}}+v$7MEYGYL<z)#YF*duH3WmqkLL*6L`;g+WPt<O<qm!=;;%KT}K}C~< zi_9{v6@;hqRJGpwB=1~1d>{f#8z`MF{g!&jeyG0Mu;E>Q7@M)5Sk8lv?aFIAJQ5|3 zdGm2O2#{Bg9ejQ1J;k*)wlK}}A=8ERi}@*O)j6xUFUg&cTVLQaI6J`E#iUBUHJDG; zNtb_>e0A<&xh*p|N>soMBY|B$L&|*JKHUm8njyIte}2+l3DjIxo`RLNnBILkLrBHy zoS-`qkw3UVH%$IZ&gP+M)SZ<lM>QuoW@s>NFSr?3Njy?V>cK<h<wb5qN!Vgd(ayFA z5Ss)Js&0TDz8{X$Sz<p`nNJ)tm^*N%FEW0h)^#&7_~v4V!ZP&?oAlb-$k5ck#pPrP z@!|bKjPeYTPOreS2#p>Oow>O95J?t;(V*hORuh?I-{3CHf(|WAe!^%$6_*3@U|W}8 zoJsIOO~D9u!Cm^tW|)9ex9V~NA|^_5tH6eqqO>0eN-YP-r}!=G*rlw<i@o!8D_QTN z-b`SgCT)Dg<b;GKuA95LPdqTTYE(q<sLDw$Ol!mvePWhz4Osj_b}m_e`VJ$VFi1H} zXL7dm%QLKnH^0tI^v@<9_g~-DxG}!_VDmS~gDaSge3(!@6uCHh^p?!G>;*?gDsjo6 z-mO;(JqZ9zfPs*DhT)(&j*R<Is5(%|h!jKT2%B?7VN^SqxOZHhP+LOLA6l^m*5_h8 z{d&1NknkoHX~-@f*5)Y~NKAfg%p-;ne}%BbYwX?Odh%svHeBN9kqXJ`wUKdlL<!2% zuxT$rP?Qc;AgM+{Jf80nq*HI(>UZnIWpcw3Rh-e4&38K7?xmJaxYb={gG3KBljd*2 z8*`Nm98Z1cz6P~{X@(t}Ee-&-DQFUXWTpF-7VvVguP$fm%41>2{_4*IXiumc^HLlm z!xvr|l94+F0;2(1>ZEK=W(2DdDm;|4k#>R;qaa&EpQE@xoO@aAuc>T!aA3QcZ*dKE zk6cL-s9>P2Rb}9?V9IRO9ta0~=9b131XiJvx<vzr6RngD96;vU8VE2<Uh3^@ttK|Z z*P$anTbY4A8sjZl4dCKDZTZ)!DeJ2dre_{SR{xh#_w{1D_-!z_`eyEZXJLF>;4bdV z0ts(fIoy89-`egwRxk^f!=^t{s5eR7#_FxbF^4Wpz>*n>|Bjl0Kj&hm8<fTvl@+kP zDG!Jq;PzdOmrE<RG7TzUCZWLoHRng!mfEFu+OSE}1#p=-h~a2B51*w|_17~huENnF zYm@WvXiDaAd}mm%<Fw!ZKjK~ZSUFbm9?mH!^mFO)qXEmLDA+W$M{fWP7CisqtCE2~ zyW<p8jt555Dvik^f$jmmkXCOZ#)HP0MiBBW+dAvkSVOo0P&jc?6aLG6t6*k}=Ic7z zLXH&dM(}a0Ve2=|%zL8>`g<qQhrZIRCA0Gtzy<}mU--qiL-XSxmhxTxo?oGUfekKH z%qHeaC_bC~T+hhb8!^IxmQwgs=0-3_C^+gsr`o|wlaopxWu-7OMX31;c(6Bb=PoOh z>=hKgRA-$3OgXxVjEEP4Gg|`6j7-XS_V+-*i|Wg%wxYcw4-fr_RobaJp&bCZu<kwX zaqn5(Pd-_d4t4UNp<&gY=j1ySfjq}x7%`1Ue58`qITK?wMs54tj8(YkfV6;joDJ_4 zu)TeLaEqqyFZxeNg$_LB<&aJqHhd7=fSNr5iMzR#K@ro-t41{Zr<<z16-ti1KXeY; zrGH5waJ_x&0cmaD<AUIbs72g8L|uA`v<zK7@W`MZ$@xAecX*1|eW93OSp?Gr|6izl zXL}q5KxLYF^hpz6{JH^|GEoJs2;RQ7g#C01fU2un#t2izl_6d(1bp+QmDUe9IZi=4 z--5lOjU+2k)D_;-;)4J!)h9#^usS71D^|@#EL-~u%sRSzlrNba3Nc3vzJ;DTmJhix zFw@#6=ROG^ay=K^PUxB8E^s|pIalY*B514TK-+N4S45<_e(v(m4}U;nh#pDu#AVF| zvJDA9_UEZBO8%;VT^gF&y{Qov|1VFDdG?@cvEB(AHp8N_so*f?>;IkxptP^6v7lu? zVFbFb`?l?0r_}m5Ov@vwMN(I4ZUmYO3$7YItB-y0Rm{~28d-n;*}4h}Q6f8LSHiWU zECty&Qka{S#+3Zxnn>)!@q|Rx2an2RP-IZMNyHI3t!m0UIt;*ha9oXx4V0xZ>W*cC z1nT!cT5oOj*dnsm$3H0q;;5O-5kep9kFvai&gcqc)g=ehFzarV2m+rDb(;!`3_0fO z4tebDsh%nM5%i;JJC(GsLL91Zuz+aIXAkZ*vX}{3&O*F!%==OB+=2PG&&rsPtunfi z1m~=*#eQp^uy!BIblVtcovWo)VBAo_&EaB|D#2O~hI_$|c)YDaGh&eKimy5NDko(C zlzNfN!a;&S&$hoYq7G%zY9(SX{7D5=?52%JK2s;Lc#$qK*;v5wN{7?hXG!G^UrZ53 z?_So}l;cEGLx+1w*Jmp=;$Z+MxFF2JaE6KIm{5Yj(dySLPjR6<q}}FArB~_vZ2L45 zZ`rXrr2}m<=KsT}3C)2$knh?qL(7xTAaYVUDP%Eg;c#L#3h=grT&kKw1V}83Ou@r5 zW$$Hm9|K_rvR4`Slii+mB*;iGmG~j9ym&BVAB^k~_L<1?;KwLO)t+ySkLnlF%m@GM zozykwD^z<+e0i68MCy1;1@Yx|YA0&YfVbkTn^&dBqXTJX9Dw_-qah%(PO)QrO}25` zW%`FSA)1rfr#=knZnV@ArcmQn1-c~>`?3%F;L4W^$k~@50)PGLxEMPRjy*Af>J8t= ze`!93g^MN>C$oWT3a#jY4DEpFpNYS8_y?|&_{Ed!ED<wT^#TZj#~tQ>nLBZFwinw} zcu_>W9}`5JVA1nm_6cYM1Z8j!5{(AP(CH9l!dHZNf99k*Ac*zWHu=L}K(DJj#x;^z zb!s@9YSBL;cIq=_2zL$wDL|nnY;!VAf@Pqon2=>*ak964Gr!K7sB6B9Z>L0xKHy7p z@`<(SB+?BYsash_*%DWF00PmA>JzMs_>-OXDK!dYa$O?~6ZCu;wa{4&-&a~Udd@-G z3z{wBOh#*^?vL>K?5VYBb`E1P>ZR^QewGW4dIuGTdQRbtV6DJ^4p0HXQym=@b!9tS znwc>EZB?gZbwb%;M65*JN`DoR1IS2Ty?0~SQ%$ZdW#F5%9s#cohjK)cM4T=M9Gt~< zUjN(5AbWl5_;SL1gi1ZRu)IfE0xn3LfftZSkj!O^)t>`f^tozKsG>J;sgC7$CJ~_& zg#Z#_!71j)-Zi^Z-Cx;VikT+1I=3AT!2mDKgux5gPM~{;&VUl?NYLK~ce6^Bp!RD_ zuDUOQmxlNyw4(wbcMPQHMynB9Wx<|l!iL#Gx);-0sJ3<DcJPX;eTkx|>iiGC!~%RW zOs)>d0=23n&DD1TAcu~e+0YWbT@1aVVG%I!_7F-C4edY}MW47O(Ay&4(N_A3w9mH; z|J7w!g2X(@X@t8eosuxaWJPrl-&@gNW!AXV;5~e(`wKc?FrWseCSU9sTM50}a_r{z z>1P<5PUHqo`Sw0w<)jN0ne>@udxBLzSTy4-;x`%+l+sfepAKY-z!EkpWR}G1Vd@VS z_UmBq5AgOqAc%`%Y)$|&y+eK~hVU*vkz+t^M1Ii}7nas;@f&DW;gAt}c(8D!EXaf= zvpQWzfEKM&wM|Pr88qDz|MiJo?0W#7O(?SfO+d20c(LvJ{Qhip#%HgC93e_CI>qXX zhG@%vPKIOvC=z7qj3Z5sz;$d7+?tfBnD$v1w27Hi9d3+JiEn@u8KnI|;(}~dt;v7S z>$r-*2ae?z$Hr>vY=F>|5|2{mpfuwe*=uX^RmSa712vm<+~Ce#n}%JSY91eHvn)CT zo=BxdJJg`^F;dz)FMyEy=C}59VI=BRN$F65F|mz&tN;5|c-x2{hTvJb%4IrFE{kYk z?Ob_>wv-Ya)9eI+G^0__X7eC9F7O#+sbIdZzZK1Wm`Eg6EC!46W7Z&<4KDlh5&3oD zn-j^}7Ev9)%3{jytaG;BA2Dkng$wWu-u(mT$I@jK$ETZkdY-}RVzYC6RhVByB%`0i z5x?u;PR@znUB_qk5vcsa7=?G1CW?T`Ct;W&R5;(vFFJ4)-6y%r$5i_cOW~-bi6#p* zpAn1nNdO%}=LQp7I;<z^uq6c!)bA^RFA9&0N9tVjy_gLA#ZFHk8M#Lk4D_<f*zYSN zlttKmt76Pd{*))b{-10nhe)_s3VH@uS=h9@AXXiQ2$Ow*4xXugiD|<hj`Q_MhV=mB zP-K5xHfb9mXEylYuET<;)#}0-VuNFy+NlA@amBA76i$QsO!aB35Mn3JH#;mY@im5s z?QI<qu5zmtO3YsarD`FmrlZ8Mih(z>LQX7H9}jjl46UcMDn-FjCilr4UwqPy+al3b z4?^QbN({{z{D3t;<7s#CQYx%J8HLOq#dc3h4n18!^GeH1a8hz%fn2kzI}-NiJ%n!# z5WmvW0+LLs8kA#<H2isLvb=D#fDMN=d#;_l>%X8N45E-m27VMB|7rR1mXNFOaO?K* zV3R(V=}e$2t6C@$BqaM6=#V2!o3L8@7aQ_CP|fZdcdwIVG=iqu#~iiNLVNQ)LX}+6 z_ETG5+aK!Ww8oK*J#8ej%c~yuR-ORYeHl0|6Mv;HH_ng>oHaLuxveUr<L;cAch&_% z?}d2InR-vLEblTGoFfNO&r%1>Rx@!aCg(|U=0Z|n^#bbRh>EfVY`tKT?-~}rn@j6^ zSw=c`LPXY)7jK*?A>IKu3YHE<P#D{~Qs*D#_L)^A-gDGDpVjT(lS+<|UboN8@^)K` z>}5^b$EnJS$RXp%?(mn0oIOt!Y4wj4)J>|c@Id4ngjAP0Jol1#hcseh>z2WaSUMwz z)(8@pY2wja%MLQ0r+C<$Pe)$^b<r3qvhNCToTk|WU0=zLp^oZ^3_hp)P2a<h-eNaJ z(Hk0W{w8~2N6m@<zfyv)CytMTTVWB85@}LUPHz43#t1@BXi*v{=xKP7#Ola~VwU?@ zDyogEdea-HL838p1yi{kJp{^DMV+3={Ha9`Yhc4)D#=h;P$wJj&&+*v0Dg|BaHIzS z@xSnRkUYh8x#q{D`S9xX?yvV!WU-JBjTH@Dpc}P)4iaHKmh^#m5|zaUo3e)Pqi|!T z9jw^B+G}Rve*JO<X((*h7C#nLU*)dbLM9B^m`w^xK?A37i9tEoGF|7v!P;U;B{~1- zQnEtUaBe5mM@K}i%C=+aV9zx-GyV%W5&+}_142m*TIE8jn~{Q?{87Q&)Ajb1c;jWr z59*qdCX8xf$RV0)>Reh#6Yi>%2P5y7v$LhBcNi+3)eJ}KJ5?j>C}ncALk~x?X~?n> zb-5Vr7R%Sni&Z<ag7bJI`h#3R7atKnqVp;%UqeKh*!BpeXy4b1+qm9Ctb>D6e#J<R zz-9tWU*JFW;~cMj$lxU}%ZD*6)GkffCFhpj!r{&xGeNi<59c~3ek<Jo5Itx{@#Bez z=8b7B4>9|~gOW;e%S6c6ZjVN@fCd+-`I%-~5m_azLNvj9$xaP%fg?Ybr0ikI-w6yc zk~v>@4!-9CHDP9fwjQ-3hPcMcGMtqw>z|$Qb<hSNiPr2;88QoAdYaCOH|R0Zn_>1= zNK4-e4bx6VX~Y|Jv#o8-82mM&n*<cUY`%|HWU5jfS_J>V!5Ev~!SX@Xv1~ai;|HCq z-ZD^Px90paTQa)IYp)2rjrnq^!oK9Okk1CkJNpq-9RX(_y8}*eWv4|Svj)axNt@T~ zrgNg9)6)5E)G2a=L)?kWBDzSebO``_>7r38lAk476?gnk_~B=Vk;L5y`~exfE5C9V zQ70->V2Ppg1k;yyliTwNHtfV|@=CD5cUHJG%WLeqdG^INnzb6|YvJk^lMm{(UifDi z%O3ZV^Fhe^iQ+MsiwT3&%dv1O!X@PKQ&3}@(c|+fpJ*EuU<W8;(NV9;KLy!QM0&E& zo1cM88bg<#0j=am1l$VQDkk?)g`6&P9w|;pk!Dm`bCo^NSpjwjz`$}8uw5mg@gDGC zt*uXmOiH@LPo35i2!JU!k2NUmCINR@a*nkA&IPP8SmX5#N5b7SogTF$&7)gvV+HOm zoM--4Ut_YVYtDqU@<>aMyGX_DiOQ){K965K$lJb0%Y}Cx51t(uCwyx!9OBv&q4UY$ zDZ=XTnRWTaY<x0<E-m2?=s?0iNE#g|m8*qL$OA%&Q*@e=-1sui_;#=ev6SG2m$WW) zR5wYC*hYiy8g9;Nq&TREg#5h7P_X-EQ}!Lw4^Xo@C}qKOxEh{C*V~q(RHAbYW+D<C z_6}{mJ0>K&g8a`w(V=RAx4aj%!_Nj2AoEMDB#5|Gf%CdNgf>c1R|*kak!^XJ0ukk* z+L|hx$=|VbAN_0Y7i@yG>NhCx2(yR^8AiFrf;TSbR2xju)Q7V*4mANNB&8I@p1a~Z zCR-*tnLT1)U0dwm*Ec&KamuXhs+zYe4Y%6GO50`z7E5iH7sHlVy08M9=4F;Uzigqr zJOEv-ltVgVcP1%;5O-1lz~BV4eLD|J$j)o;iKKz9_7yjGyA}q@@Y&F+?DC+y6iAd+ z_gYO>^%8uUgPJ8e_sC^hlikD`@5F>m^%jU_p@Jb=bYV%YQR7L}bq26waV&B)r~}{R zyn{I+E^3I<?|*}WLflI<vS&&y>^pRiS$$mx!3~gZuCmeNMab<4$wL7(hO&^^-W5+E z04r7{;DUM^59ug2DF%AC3AqPGjPLgv|2LOPujXAWpCirhe6Tz*OkVG-ROPw^7(h^| zLT<8(pprYPZLNxX!RZe;lryxL5SwSl%(C4NlrwBDrvv;xP3f!>k6|FpWflmC{3Dja zxs^)1mz}pM9`5ix5wWaaV_>e)rkk-MRbM`|$LERj3zasis1O&bc_IoRd^q?&X<zQ< zWv(L%fl5<5BDq3ytLq`R4J`%5umHY8m&hFtMTxgh<1j{TLGn%%v6KYsJj_~J&fdcp zm)}U({%^fd#iJ@VtH&CbE#so-LfbO&o&){sTexj!zwvJZCac4jjJL(ob#0wuZ?WAT zg&T_5Ga6N@36pfT2OvX~=Pj{10>>e(c8ZBC-T0i$T8F)>mYnS*DFaQdgxVCNAa%}- zBIzsJ!T>)e3p#TY1e&75RZ`*d^fQSUKa@49+Nga-Ckq&0u!3B77eAz_hx@2us@?}o zb{902s1UbhchY?DgVtEa2s6kX`VLcxU2yF!U@bAZ5)K%E2$a$deV|iT-ln^(BWqA1 z@unifBUpJ>jcD5+=T{n2<0{HM`c@w~LR_J+xmAj(DC0`?<pRDP^)cR~z}vC7#&$m$ zNuvbdFr@X5O^^gUV;`WWL&>UvA9*ha8B5kjLz0)h(Q$fR7%dO#`XtRJTYR80bL0IV z#aGMQ4Xc{($H}LBUDh27L)<W*Thy6<Avx1q$QrrM(JHgp8VY=@A{-ER4!+X9+1Lb{ zYysz0KSYH&r4A)=QAXQ^lY8&Yaqu14;}Yl$i8SCRQF7WjV@xh59Qt01H3qT?1Br;t zfOZ|T3HG;Xy#vkq<xFCMmFm)JzIBJljrEhxu=z<Wi)jf98Y;jXfEHRc$mlKtK5W|v zX_kaJ7eIJXH$h6K%GH2ko}E6EM9*<uv_Z)vuGmJNHI+Fy`iFtxXINsTlJWL-sg6#C z!*)IhU(}H&bQm6Fo|xNuCaUZIP*YxQc>y4x;yFP32KR@JR-5hcVXre4F>*l713#;5 zO<*45s2mKj;BGBn{+}ydmUQ7;i479>DuE&=UR+E8LdEr;*o^UBU=n!Y^YuTAzJhzV zf=|WTO87lcCU;<Auu&O;oK)>n>JAAO7FEE2Ler)+=N6<kzJ#h+%=>$|FNg<{x1<6D za*>|+0(?I9;Q-z<v|jx8uNo7@GTz`iZ>vBBxq?byqG>|CxcJq=e<?`N^n$hVI<nOq z<nbev0;Yf|n^A{n7O>G+G8`LH#DwqaEr?9q!3i+d^!OebYi>qj@Tdq&2GUR`me=Q# zrbdZzMW(uAM7hwLGA@W95hb)T!?)aokBDFyQhzH-0pimvL&JKqRYqYG90LD#-7x^n zFri&@%^%Sbc>d#hUHloJWMgDBLVVR4_WR^foE!lfpx&3OfOTO<(o}1T8-OX4#;Y(T zccdcDflY){gOn8I`Y&)b-+wV$i7*M4BP$a6Uu<D>O*bQ8W^Yj@r)_^V@I6|mbW>MB zA$KO-Lxhk`6OT`FQ#zvdXyH9i>A;P7^;_tS(t`{g*geAGPUjy4ADmKtQTn{gs<C%) z4GIcTLd4s0cOJyLIA*?Logk_1Rc{8ugJQl5v3Oz;>4U${@>Z6k1PX&vilav9Mx%Fw z0LYE0tnZ$fb(y-VTDiHCk6fe+j_w7C8FfJ>+<if*eY)Z!&ik<v=QqRgtNksyS{i~~ z=O651g_ttI*cTs<)x&;=Zk{(d6DHJFu!(qZGSa@;0Jb6k{_0o}&a3=cbp8cxTmt9K zh5~URYA1KdG)=dAN~Tl__uSdRnhs9F`Tm!NBd^uwTbm{iDi~zpc`Nx^r03#T?K8RM zHL-8CbUM3Pp-;7rF#2srz+-JYKz<<$+^Zv;HI05jmzXkCC?iJp=s7F@;WxaH99cS7 zsq=$6?nz$EuAL?dc6-giBuN+$p`=>RlTP;mg#kIbCU~q)!eU8iOt4SY*~zc9V45&^ zF`=CWuFsa73aH)#^C+-REcVCK!0~4*smwHPix*Mv;t<)I%<{*jG22-_fYD_fvs*B+ zy&h8FH#+%bNY9rRX90RivspGG0U`+9x*x2eRY%^!1i=;Gh(rLtD|mQ8sPAWb$YOPE zcP5Rt`*~)v=;vca=;p`$%!g_AEw&W-ej&W)cl4s;PpmZPbZAV=>(8?>riz{z;gN*R zzEw-kv>?o@ObIT>H)BAfmj#5zK$hSE=$NWHa}NZ)2&)wQ?gU<>kFcw0^L7ux0~R>b zUSJuZsjgr%FY#;pW3kdW&y1Ft$cpdx_dE4y8d!$L{3lbNK4GUzkMGuHM;@jv>P{S_ zq|y~P?k`B}b3g5zYA%qJXcoCbt@G;Co6h@!a9bmWE8zE0F2VCIT=@FKvmojI?|ua= zrD&$QR3fR8ooL{c!Uwr37rs%{h>OSuIOHt~Hh_XjZH$Goa8Y`M*1O5Sj<c?LbuoLZ z12BNQhc{ol(-FnNzT#L7+nT+zn>wbap<JTe9A<Mn@a;W206kFqT@aUKr)rRhMcs#Q zLn6KGg>#vBWiu%*lO28DBpk3WydeKnhp<c5yG)W7HX+6wIqu_A{0!t&4mMVka`9VC zGykwKn|N!tM?GNP(y*mi3Hr%5&h2(VNY%e4m~Y^!S9k368Z^!kGv)t64K<DvSDSe{ z!2Ue2`6U^*uf<XV15BtR`KL0;feYFOuX{!}VWIX_;JrvAi^T%L{I0#Mt!9grZO<G| zNR-?uQM6kDfstBJ0bwxd^H8zObQs*3m(_hFGBe5`0X18+lwm}Jex5><8u;W>x0;s= zJxFGOIjlGZklxT-S0}T_(==<1skEY7`W!P@`gK(*;j(IT-NCal2>;?XjNS53GfwuK zmm**k4ZAL^#Z6*#EMjgM^Lk!|%$G?Lgq+9g;4MVdLM!630vwKpcgycTHJ}@Fj=UMz zx)!N=IX`?{DGKa;oNg$yg8cD(%Z;R`8vQ`p$9|j_1%I5dgFWj|o^H=!pE^FcJunq( zllYeZV>a{DVh3L1i4{)qgy8)nXzJY`W14?$0S3CI+7#<iAyQ`TyM@UHbPhb@rsjW< zl#Hiqz=Y6^CRElj=O!Q(?C`WTm?vF>a_7Ryyg}<1TiDU(4J{#gd{7&V$><JaYgaUG zxaZ4o3$1CpPzblc`|5_P2zj+x3iM}#ua23ga|pBb)TP3M6Tijxk*waLiohq3*$7lX zl^$4UD9Rt}cu+wR-50>xq1J%EslF^FgDln4a0`vAisnALmNQ7NV&mGHY3n|Y7LxOB z@%=F~NoH^D<>8?>jhjMGR&wvcNbY^1J|BptCalptxqxv@-tyll=Wg|>`i>5Lr-u5X zju@e-2x7@KD^J9Tj2q43!1?%tEhM9PXezi8(YR*)C_;2Ax<5^VK21gW?#$xZIalbH zM}R1l-rwA28j1xAX}!uP;w4ykYF2sb$Fzx4X95Xe{J_}7e+f=ZfpLd^(_DVAyRFXK zHRw+Q^PF;uP+R`v&M?IKLhI!{QwIVJ&jfH|$uEb?sX-bh-?i9oV#X(cXtkT2fF&cn z0x$wuUHcTN!7jvi2n@kY&Z12p1=LFQ{uQb#`?IYu3XaCs@t?D-G{5hOGQk+?n*eeT zRj~hXxImeVk(=^Z4CF3;&<0QzjnV?g_lkD4chAvN+YQ6jL3=BQy%n)$7aa;*9*}|* z&x1>#P4_k8+c<2lxEvv~%)&`D(F9s&POZ1ai?k`aXX~@rL23De41}w;BrGq>poI#W zO>0+6V#vMBzsSFJ_C5I8<>OwcJ#eN^zlFD1KhpT86B4DTEy7^N`8JgM?NaJyXTazg z><ufW`Dm0~Xd$%?57xy3TAD(^Iz4ji*%}b`)fNB5F0SW2Mn62?;VX;~kA*YVM4CR~ z08xk;hSK!2tR^g}hOCb!0J;{1dF0Ahnj_l4y^yy=eD_DYM#|fNH=aBjM<nWzsK<c9 zph<e_-zK^RmP(kP-^_ua8#(1J?RT8M3;w5T_I}s~&Q2!7l(~S#Efkjic4?YU_vDa* zXe{At?HJ*PMe`&W6`NK@=OeZjqEn%VEpB$nY1o)fzqgRH*p~axcQY@zkYvF=xELmj zIA~%ZyzZyEa2yX%!GspIGSxZrG)M71$Hp~=$Go)OEqwTU>0f$0Nyf%QB0x{?pk6GE ztM&rcSh*(AQ389@(aMhZ_HjS}r#;DtnXChw=DSHO{Nek4`BK*cyDrg4s<y&kWvaoM zn-E-1LH4!ruA6dAgA9$R4Z535mdo5g+vUL(Vw*EgEX5mGjyZU)8G7dNs&GPP?gAxH zwR8@TR0I!gyGQKBz@JzvaB}`zGVFsU1Z&6vgtS4*D}Rqszi=0Ns~M8GK{ZJ4=D@5V z6$8Wk?LZ>X)o>#Kb%=ZVm4<m(oK#~1ZA**N_AJ7eX$FI<7c(FoXOh+{%%X$(gAot- z*yV=a$o&Yrmz0#u%i{^|#rkp!nmmKoHi|UPKui;b&h5n9RtA+6A#;or?DGAkuzr%n zyG2<A!d5hwla)k5-HuwookUJYtrcVy`qk|6=uv*!#l*)QCZ1mL2%Wbm6@ZS3Gy=(A z->0E*&w#>jKx`OhQ5#t_X>Nljv5Qb2f^CxXB*&KzSDx#6kPng3Fs6$TnoaWnH=~H3 zo*!f-uI4RLKe1jc%yY!O{e@+fd<w@b))k#gVAVv;`k&iP&YPM7r7OrEe3f$*n!hUl za?J?kFI-Dn5U5O73L0zcCv!C*TajL0rE+uJQnyQVkK&Kz1K-E+M&az~{HC6=;LetW zH)ms__Ra$Cz9)Iu=HbMBUQTv~wU0Dpw_&K7;zLg}0OcFV5AZTVxglbooCkPK^+vFe zMIj8P7uW`8{uvWAB^+^cm#;P3Wb~T{sBuNoE;y*zE4p3lN7sr-9a<>2m9u{m_eetv zK43XDn^qZrBzki4$w9LQP+Enx6ch==fdyKy{G&w$jArX8xL;Ic2mF#cJqP^2h=N9t zKcbodbPr6;)Pc^n$PaJ^4eN;bp47)2;w?WO`@L}T*)m3?fVw-@tnmeP2IgD}?e3QE z(QXND6*^476wjKavN&S}>}2Tuqq+}+qj3fjXq77LzAS0rS*v2efY&v~?4AQ`k3VJD zz{o8S*tCFgyAdz0;-NwTN`9w2G;?T{H9Rsg6j8a5$G9R8p$RaMF?bG&%LwM-Vrv0J zw(Je}5T)oFa|8Vt0(p~cGAEanuhGPrAAO-xGoabE6z((!b~zxmpGJ&Lec^kauf%FR zffT>k3>s9*%@e;axoBf#i?{W7b65$_Z@yJTL|BNSBd}Tp&L4FOb`n=_$m|x_tS96O zi<P8c6;VLqUoAzwqZd4v_~mMq-7=!=re*3{a-s}nW;dp$Sl|@4i))}D%9cSQxm;Hk zIfEgn(<jPu65JY=I1Q`6MYzg&x_@^d(gih<{|)xsIZpVUmF4NIER3iiR&l0A&X3}s z#5{^DwXY%|19=+#SMcYzm3gR~7HFbf!zZ$0y#u`yWDqILda)&RUeNx&=DkYVGLx^j zIzG#xHCZtVB|{MC?{;;*%!X~E&I~hnELE~~9!owy+23j-W+y^Xcii+8A^_6{i(W8% zH7)^r>SYszx*!14b$q`Fa&TR!S}l<92`_n{DO01xUr2_$pN8ivKmlyqok(&y>)LeP zmZC|6;1~U7=DVw5;c`S_sjIQCaCmpQzuAMGP<}Id#DDZjf#}GtH~7hy;vY&TYdo$z zeH0X($6M8ULk3%r+$C`8YIW=Y6wGi&N_26&;VM*TQImWuf;BTD{@9M7llrQshNH>c z_${Ih?dXHKFR{CcOAFcBUZ_IIsi5O6FP_;lY@a!{l$Za;0!y9!>;OSx^pQW4g4Dc8 zudfz@eI@O}L{JUa2~YHWgim_+Liw^Ms}Fb0$-D?QC0d~Sn`lL7LRvr+mQmf58dlx4 z*{i69;sD_T-Z8I=JW<o3w|X@OsPDo~#?|Ky()VYbc^&`xDrdF<RCoVNi?8`*grEzF zk=@M&xC&n?7so1xX{VnTiry0Qs|hyyT9Adt*cOhV-@J389$&^OW&9jJCE423NRFSd zh#Nh^;AYno`lfs4<uZP7Os?JMFbyBi*t73fC`ysOp5egcG*GfyB*dJi@{9yH;)Wgw zoi-&Pio`|$nm~hAy>h9ETo~|h@DT1im5=CV>#6F#-jx6L6LG;VbQUugiMd<n-sugc zMe}251e%D=RX4Al);hyR_!ELa;-yAb;Tw?6u5(WDh_q#=vd+=@C^$;L>qhY~=(YuR z&9X0OiqwK%t+oMcohp@>K}xa>61R^;MZm$97N)IgjSukY*CoX@z>9rEFZ(fTnP`3* zo(YA(<O$E?Y)zycF#Q|mWALodk5pWQe&{oBrMU?OEVdnofUyzGUGg?=+FCmcj6g>+ zDI*M-HX=xkF15I%d_iMQ&aK-l!r@`xO>#cFP)VODx0ypT0+dNx$vAGP1<;|kp=Y;a zQTf@q=|2yE=IU-28x#ARJbtZDIBu%!1U2m1VtL!xz|?hj)7Y%v#?^K9A8ugLK*W1X z1L$C$^RZfoF?>rsT|TJccLy7+!4L0MDchm%)}!jlI$LAt6sCiahNftz(|2U}vfLy7 zpP7+((^0Bm#w@4|fR2fD`6A1~Zc=X9ukH$J9}n%c3@qY`mhuZhfpT;SG3`;Uq5<%V z#;&7~U%qjB!YLVl4=E(?3!(2l18AW{>cHpOX^!B=_v|H@znQg^<lpe7x+G_Au4`4S z&2<C9-RhZtPHNJ&NX&iNS<)j^OAz^wV<Mn^u@J@X_ESVOyF%E;Vh(C_4~is1X6MHq zshcQ3!-L0-TMyTu<QvF=$8#-b6zONEdyUf|dSG`s{)z95e%av5DOEjH>LuN3(k<KT zy;)>~TPgxv>LV{-6=`~CpmoiXOo28d8DrpN7M#3HkPeTXwn_PSAs?;L<W-YXvLgX3 zUE<=K>)<Uc3u<fy1A2BapJc^_2_TDjX&XCGgN+_^>LeW1A)>_!s2VemQbo0g_-VVz zkQvS>@B81X7b6pS_XoE9rTb`0hFhWu`Y;1zTnkmBUQDEZ-wRU*`98ZAtD3ItFi0|h ziIiV}a`dr3x)ffVS$SrE%sNH+?>yIDsJrpM&j-Jc%C&hr>);qrB6E_Ca7qXF-Pu@f z-g9h-#kf@kJmfeR3z67#lBx0%_S6xqxMlTuBv*?PqcL`ez)<RT+aekNI6jaxAi1yO zKa(Qm{{4lcRauTLEugpWxX*3XSxY`3dL+TS%9~`skx)OhxV*17Ewm{*-!=O>hz20R z!v3N?HYR`oMbs2~(U4%M2>BnD%dr*1Bd%0+ob*7LiTe5}eHi@oPN8`q1eZ5%_}{HI z_6m!1la|yvx&_$9@NUt1s;5Lrf!B6RO)&}SJlrXfp%1`zSLRNIkY&Schyb)$B;^>B zU{%7kaQfqTHBZdFSdY<|DE%YH;*KFQKF(L}#!F@Y>n?Hu_czh5T=6M;gXD_Sfhl~i zSW3vSrVw`e)%Hi9`bt6#Yv^Zgr=Zh!XC{#Dl+LQjm9p)+m~poU8y)(^seKaRWThjR zQKl19H?4-5oDl>k?$_-END5%lh1n6@zT9_FQP_&c=50AwyBj7D$6DE9P#U|C+$EzA zPMhzpKkr*#gol<GQz^Qwa+n4_%o)K<@)rE$PIv>~p5M*@?2#9w#CI;O*W<RAec)*@ zus2zZar4X)Q49ozujZ}t6mD%h(Tsn|zzzv9pSVvq+T{YBkUhU+Qwr#}C*+ZBf}$FA zy0gAmC7{^15n}a(6|DRi!J3t{5x*^eE2*d2yR>cP<zluWIck;ERVwm+cw%$1qY&Q< zDT^_t!-C|dzP3-eY*d9f$J-h0`t4!7(A}xSl!Gd5)Y=Ne^&WC=Fl4Uy?N-jXf!1(+ zbqh6BqE5fyA_<ZkpT|nv<H@Q3xkE#f??C9a%9%3_*G}WBq*jI1-kS_&z`;8m6=lO+ z+zIBG>B+Dq0w2kj{bCm%1%zj_qf!_;*=Pg-SPDGIyOaY1mr;;b?wCc|a4%w=Eq_lX zCd$At@+up{-B6U~tG@54!#4)&U-(>-!@uiF5MKJz<&Jgql2?`m%~Ybe0)h!eNSoE; zgMW<W;(@NY(&hn_&c9W3Zrw=&*z1mkZcAhVjRvR9pAkHnW=tMyjY)XKa}E;|phcHQ z%?eOeHCmwXuna=dfu~+#KJdQWb<eABeW*}amqm%*ZXC4ZVh$afhjc5Ly6c!9Q+xh& zmIq17hba7t*jZJ$R>o74H*h~ZNs7p(3lMK|aTtlB4&Sw2&KCPo6|P=`LuhSr@nm9M zyZ$B<P^S`wtGv}s)lckU1Y2#SA-6G1v=B4n)pE?VxNx||>5y^`O2E6XK)1UN_2ru0 zeL(8VP#KLby8XOxy)lCAv$ZTI&ZQlFaGig9qXZ;lpB7G2yiQeNVr1J+22G}Hba))A z1Y{fK=ntXp0S#2b!!0y>J7E^V63n5I<DE4<E1va8{x9cy*5wB%Q0H$SrLydO_AxxJ zy-K}Sa0QDxb(Y|X5F>8bSZF`3!I|3#+*Yv$eHgQ<lx=xI2z)+hhOfy#tNQG8kuK!3 zLlsZuWLae~@!|iUp(@%}J_hO0kd^G#o0p0+6FSt?Go_wDU}YaixI+B`EWC)Hry?4o zr1oBy7kCc*QbSU7HDBKbhWRVM*H`0Ojel1xDhMrq%Jr~jR{xCsYFi-}o>YaZ8Qu6P zv3I`)yJN>XpO68Njys58)*0`63N@&A1Dh99o9y6AY5hNHZ=9*GTX>%_A=*=)UNp~Q zlWl>+l)W;ZUkB-1`0*YYmiRfs4Hjnv(!0sY-&ZENqhEQ^;_=}m@|~Rz#rxbKSnHpn zjFR)de}y!B1shbd4_1Yf)yYm)i}>D`kUZqEi<hwlzp|+%0K+6{tX57GTAgB}+?eu5 z-nZ59z)`ov4ONfDU$*83fp2E}7GIt)&!m^wAyVaX`ZF>wa%O>0pbJZuTpZeT!;JBG zPJsA=pQRpYa6>fy6Yugd14M0FBJwZ>X^@taGAV^q$b6rDv<|18`S1*K7~wfie>3Ij zn|~UfIOM8rxZsdTPV%1H&H~n8kLQE_$8U%I@jBM2M4vZRUz#kx?~q4gzER=&puN;n zb(~&nQkqE9GT@g><llje%V~lO#HF}<y!OTdZzmQ?*JVzdonAq73FoQ!S{%E76K<rX zvYEl<D+g_9{4>ulNvP&p{X35pt}(oB^AtJ@cx8vY75eU=>Gn#HN4GjTO{B^UlSwem zki2`KT2G;+y<`vK-`byo(XT2I36pbw05jN1+W+JcJ4@SJV9tO4*omqExx7=7lbBH} zU0kOJD+eWJQ=#boQ3b?cZ4o!5TMy_Aiw$cSxUOz%l5Q0`aHkmppUMGgQOY@G7Ht=$ zNDbOf%rj{yphfJ>a=33+E+(_Q+@XJtzTxHZCY`EV^2HvX<^(K{$~^8Q!rz#%f9SUX z$*ZJ;H*^asU|aa+5_}^@yQvHXvHlqFVU67^AO{HZ((Jy`j}7lq5B~7>Cxn=-?1Y85 zq<Xs&Bghz}LIQ|>ovnLe`8c?>Fmz_l2PGda4u)X?Jq`kl-~N=hV)S0Y*8H%DJs`05 z>2tjl?Bk>ME<GLwDQFioX{6t%;bPW2&92_C$p|441P>7cf@AX3TJaExW-4F77oVMa z@O^<wKR|K>A(&uRx!znW3|!VE4qv7HSt72em+YD+bx>JGXX!{=KmAlEGn)H`=B+S` zqNH9}pXEYcH0q|_f&g&f6>a_6`vU_nb=OTx260*k53`U7+UD<Z7S%P{1FTnpZI8a% z{TXDp*z^)B?|LiyeVvpSL1dLKgo*z^1gng!tzpt!3d!FzcnukeSQlMj8iIFMIY1W! zMZgo5FM0u7+ruH~pjbSKfY1Z4o#21wm|PobudCHo{Q=cn`-OEk&Bzrwp^q=>ClW`p z1{Y8-W_8lB2B?=|Lhq!D=Y<cO-$zdqc*R<}#bN(+a-J`?1X9Q<ARD^cdCcQ^eF~uk z!oxUbJ-s-ACA-#ZcixU3<uPBeT9j)=ma?s!MNQ@wU>IR~iBy%u0wF61UVZwI4I*Ac z*}cIF?|%>Lg*Evku?d}Cfbp7#?ZsPj`P~S3i-6bOcs=Kk8Wdpu`_{IG_@C!ds@64i z_hz>j(;f$41tZT=*!-_;NU9C?7-#jdjR*-ZL%6#uK{qj}yK&KGcm6PNfkz!FN1iCz zr~g`YRa&c@w(%<3UZ`C)RA+NOq;?%%foL85PXaD=>$XlDo;m4*Dd+2;v(@8*#Fgrc zJ(s<(>7MlZB_M@<!~u5HnV?A3!Q_tO?8dARiN4@#vAWZE<-8|M_JNt#CX@cR8OT^z zBi@|Kuvplc6;UvQn%_NK4C}}6pqVST!`NO?weGky0nHF;%$V#oh5@gLU?1wZ9pje{ zy-pUI`L79>*CneauWbKcc7+=a$E;oUX8V-vC0@vNWN|TZB@b-+-_0eo2-Cz6b<^mc z$m4Dcxqa-Jf1fQva^`(ikl63;WwW^*YysFTM7Z#*7&1*vqhT9IpBWgwe~01p<mc|m zcXv;&(1)h2;eh5ZVNHkad|x@&m6LvDZpIt^cBY2!58lu>g>is88ZI$<nD^+oECkh6 zW;~ct)t^Ts8<q%62rTWm=Ntj|WKflqC{P;4js}xA;YI&iLU$RIrdH^?HPiB9xU+aA zL2fQ6%$n!K8yfn&_L%uu$=bf|oD?P8pt~YzX^b2LkD)PKY2l^ef9sQKj9%b@U!4Ya zN3&o>f~qJC%O%}lJ8i1=kR4V_{lTP*oZ1n(RhvffjGcLGy$-Pz=T@)YUw-XPLMJls z;&Yxw#EtOCwW&BXW*P9)6Xbkg>&MzkG_W{Ynd+ePgzrCLObAzZYZ9BPBuj9#f3cOR zt&PJ`KP4l)9r%tfuAvCF7(^hTA|O40orT)$N@(S$BBey+d;8gUZE^+;RO)mi5TOYw z+4Z1a!j;zkwm`%Y1!p2=0!JqWB!rLeXfgoiZ@4EmH<tf@R|^hDE_cxq^1C>S<}<^S z5s;TK+O_$*9((ab%-2aUJCqqA>tnEOzQ`7QOLs@NWbP<#b%5<sVfs_}6LHi(7F3)^ zhjUDw(Ry97_E>3tK)S8l9-c&-U7Q%vSifL$lfv-y$<G%%2MS(3j)Nv<zFL#tRM}xs zEv+li#}4U=Q7Q(3L2B&<W3Lz&fm${p*ca!q!46Bmylxn6K&Su=D&>TKiuC?$ZBKIb za+PIVRqg<a`USf1_XBxc6JT;8S74vR7=<R1zaXbC=JeQ=n!=*myQWd4La$#nb?24I z##9o=Y8u;QjZ)8gZl{ly?TD{v$CmkWzUD8pT|T843dlp%J(ld3>T}rAC<X|xwcJ64 zrLC9SW#@5RVX?)G3I_5FwH{|pTsN`6(;z=FiHv%ybj4**1vk*;%NVdef2>@stN%^| zGQ9O~Pr)~g-RbB@u?@Q*SqVddzwEmbkbBBIz+e9YhUR7`2(wc#-xI185ks+7y5$?( za^p)$qXyi7-!WIDh0+2#j9nrf*CU)<#;m?kZ6c3>j(npGjMe8uI%^Z6&m0&7O^+;; z*<QmKCk<HCccwhnm%<ii3sSIxd;5!jY>^=bXBGR>W}Q-`GaBeykN}uM=JY{=44j1F zng02rg@u`gkm`Q>g|Q`n+?eBwDl<;n3zZGNT`wy>rhBgveJtSKH}8_fLpd)Q55rkX z!jKX8O6OP2W+YRe#XaWArbb2IHRk{JwGt5eBV^bbZ$!JZ$8Q%B=0xIa;F4|E%UKUm z_Vl?1n*U8iFWGP7FFGB)z9ni`(f3ZSBTBKt_}3p<5i%^{vLVsiCW*k)TUZN)rORh^ zH7(zXwsr^D>47fBS*YPDvHf5l^tKzsi4r2-k>9(Ez#a5hVI<HfuJh-B+vTpf#pF}M zktOKf5JM+H4391eP18xhecb^>-VtId)5=22@kjt;sRvAouCewNnXU@^07k4sleX&H z+nMPnD7H)K^0C3oS&z7<^4!PePC#uHk_=-a#4TxtC5IwGbGeL|7O?}@SjWv)l|JyS z1B%PrB#j(`Lkxutr1GO!Y<?I`QQ===cOyLz9SLhTUZDsEvC^lWs1sax%V13Q%C^xU zCeH#cHh2~vVO*Haw9p2S!Lnntk1jH2!<NXJ0PHVd$Spzmw2aQZP8b7zIDY3{T3=zY z88I4}5fL_gLf6&@&`k@9zuI9IWyaN&x~ZXvV+GUHC0!mVo-0%~BFbx*Ed6#l;!@mc zbiT@{2tfs4JeGQGQ0fED10K)j?EFxk8WSld?%7rwna9F$?lmSrABp~QG2yUjXg(&l zjOHofd`yb>A_Jj}R4)A5MEJ}ZZ-pf`X<tL%-1=0(T3=a7t}60yr^~4_z07>g=VXj1 zs|DRN^#pilYDfpD)N&LBdL*`FWV3p|HT3I<-+w{gAzU;~<-8u>`jx8ypT8F=M!4No zf=S?pszL4Mtkz0HYnE<a!h+N4YMJ>GK9B5%1}4&%7A&Qs?s{CZM&k&~#4*46>Tl^i zomW_6)4iSSdr0<tp_Y1jM=6<cL@w>xf4%3weUe2M%3<go3onflk|@JVavL>z4-yxF zaty)-n80nOW5oB`4BbOVc^G@IBSu1#&<l%u97(*!C(SrmapmKH^yPcNF29M2q%{ud z7y9>I%sbS(O_5IdbrO_Xb-r|NAuuk0T&2}2E)mWFv1z42aPrmX+t8e;d-zms7<jxd z0Bn}p-<-78!J<=bxAi{z%~&Asbbyy&h)8PajrpDl&D|nTgfcbNS+tz7lBL4h-FBYI z40Er#;<_!_ISaosO)~A*BN*Ah6P!eaUvey{f4G`*r0@A(8w#0P8_V#6RK`&}KoVnD zi@{q^>-0zoh&bf$Ye0bL<)DCk`rhn7MV;YzKj^Mp##yl6(swOCUPZb_+A<oA>067C zbpjSoiA(1mgF^%pAA<J+f)l6U1)0UYW{65$iciZtthcynVClo<Ru?f>ocCph0)?U| z)i@hL1fszHgY=f}?ul6GF-?n;gfjMpzYzBp{<=tZ@y-L7UAufhPmEcC<us3pT>f8; z*xf{Tn9pILP&yYZtvHNRT{H-}_?R?ZJlPSK;2w=zU*vvmfH>xD6o8swzw=EQ@`UAx zb{*NM4lh*@yM%?!Rs_b4DiEQd!Qg$Rwku`o1dkK9q@>5aMx-s@k--j-9weyO|H{Yv z3_H{OX5$o^lM^<9z7WX!aDlm@tRLgV`ndPvd{;nbE^fS^p;D2qx+HxJ0QPFycS3=q zO<Ah-#@Dwtm?~sFC6rH&e-im{ur&w8%C4hI@QY`*Y|tLebqNB_HJ0?Tn&BK87n=Je zo}29b2`R#Y*Iq83qOJwL5fXJ%_tp*Tb^Mj@(W*v4S1NM(hua-JhkS4#k@$#+;MvK5 z$9B5(2#?`=*j-%!*i*0y^sAv&;rDr(rb}B<*s40!qkH$ckqkO7qU<=e7)|{?7?RQ; zI)hrKm?}<`qiOTI!{;S|N#`zZtGrQHz0*ZsD{Y+OfQvlAtj)&M(xNL1i7C#iOHq^k zh)O>Joa48his5>Q8Kd8&ACXHDv%(`$XveCHf+zIW8d6j8YruUb#%q21djpgD-te6_ zeAzDh9_2~B>2Nc#o&pOaI@s+M8(x8zbQ3I^V<s<7Bsd%~1ZM)G_Y_XG)!R}_cL^4y z07QQY750El^2$DGp9Wl>KQvbQS~7mWL8HmcF;l-8RdR6@EJkAcBF8aK6lJNwV`p4A zpr;%JJ8m8B$$m8sd#Bg<KIOL=O0<s+5Wt1JgyuU-C*9$P*a@`;sueDR`)e=V_S~$| zg|^<;Kyr~<@vH%G+8WwrtRH3s>A2JEm?Ol6fGLp1LBEZg$Y}f6+eP(#>PS{&TJqSG zFO^HJo_dh^lg~Smr>q7YUo!+<5Njd>T^S1)Mgaut5l&1lSRhuU(@w6YMzW=8;n$Z@ zcL#tPSY!XO_&E=>n|~UEQFAxv1~<1{ka>RvCD8D%6VC42*PROI77~n^UrA4j)4?W7 zZ+);su!Hcs{<lt)tR2u>d~ZOHCVYkuHoH`=2<;MdBq}wD<c63asx!|jq6O2z0!eRL z-^2N=SJ`1>>*8D<I8McNmth=~g|2lW3Np688NspBeqNsc4a;d<k!I?9o-ZA_o@yp& zZ3$Y3$KPQTmb>oa7!-OgOL&KJUuhea{9I|~{GhM5b{`osL_yiH?Ep+O?%8_g@F(7* zq}p{=dxUPFmqnu2Qg@j(ZSvnGg%dlY6iZo^>7x6`Muzg{ZJTIU-)qMsjw{)BCVLDf z$tl~;-G{Nhn^RXcPDM7FwaJ3!d8#($Ue2tV81-x{xxXk6Rs1tOH5a3nZE^pN3!^=m zrS5pL5MuLDiEMGWgEm#t(5FBgrDmL<?*eJQ(zhnGbt4edD_B)+gKU9Zcj19LpUQJ~ z^ULH6tHEW*MVzOACU2wyD7w<B=C|VpLHJZy1*AFF_VF60ztpCEXx6c=1mHCw>|22! z29|=<e|r1sQXVe*8eL1(48Muq`OH(=zhUs?qT4nl7Sp{_AqEN{U(EfjwKNX3mOQqk zBy}lq4e42cnXl4ZX+|2O@tIWYeCKtjmPB*sdlyjk6^I4(8xrenZ=sT&J<mtq>^`#w zdy+0W)FFcp2D(j8SuMUqXDfWdNb)j6V5~R7t<h13!6RH%xi2T)TKydcASES86OS2? z`iPe@l5n29T*-b5tV5}=X+P?ySi*VW2<}P%X+!icno40jfsyW_%;}*)MJ`w(>DNh7 zatdL@5wIo;SZk;4U<9|jWzTkLr3P_ilZARotD<@LI>vFr`qjPyPLdb^ba7pUW(aXy zPidLF6|`B1yG3!O-fU(RgG%}zOas6C2LVwS5F!sSzFQ?lL4kbi@@k+zAcifi>;?{Z zy({ETzM`USiXr76S0UQkezT$ny0v_4!xjSGyC$IBZ@hPZSy~SFbk`xK{ePC;_F@=G z*a+B6Bh=X}vHH<VV|N>h;;RS~P&aMI;&BSWUPzARKd8Jc)aC^8=nkX_!aC5=N~D)m zCM?HHPX7cwd>P-ybETg+61Ik{rrS5+p>;HVaDWxKx2na{#IRm3AjhQU#42+%)vswZ z@S$Ke*wNZmRuk-};BLJ=&yAxBI9{9{b+QRK0O)A_y&r7881aMtV_`LXAFxS0=1AP4 z6(C(9JEEn#K&Bco_oLEz{;JZK%<(;UCE)~>2wJ>i>(5cc5SeDKI*2y_zT0wjn*RG@ z9iL>GQEb?szvq1{Mgui3(m8svL85S_+;1ihwu=E+y$q5&T?*{?V$jE@ZuFghIW3!6 z3~=7{=49qKTT#5Ny%(>`D+|D1L{X0&MYoA#GHNriZiZR>ec5T?>yLR7AetEh>_k60 zen9+$_ldZZW<qK7Go1MV+Xy{%&wAb}!Zm+0O_m@2v71HFhOU4)t>xkF)5SDq$3uz& z7yMckTlxJTZuuK8OSN%G3`6Rc28<%A?uIJ6Xw|rwVM7pOEy$$ACVVe4TjNUz6FN0f zOJagMdoywe+vCY#GW6zj!JBGij%~{SdB1gIOtScRbq>fi3lrC7f3RN|Ra1-#`-z17 z@SaAfI^aab$Pi~F?15Ox)ClAQ@9X1tI2{>ukK1~0R+ya6HmLLR#e>Nt3iaFs7nDnF z-!v%SPEhM&dm@32l~&7HPuA-w1-C;4aYZaLfM@pG`U`T}Ahh64PW$VR<zPwllStGg zR<R_(YG0S#smmVT4@?folluP`X{*RD-|*Jq!qgEk){H4IHM!~5qd2*+Ix%*Sa}8AB zE~U$Od*k2z&WK#RU6rMk#v;LPD>pxnT!fL)DyD%SK+pH4t86+Q5Wo7oUQZ~ZN&|Jp z5(RSP+|Y=YH?*af@C<4t$^|m_Bffq~9}B9i&p8>tSR7pWde7+kf$l$r3Qxco?jbQY z2CawWYCMSiOsMVPf?d|l@CPshQ2IaveWeG4UQe{Pwh$4$LBMW_{R+=7^z43Y>SD8B zxl{a1xZ#9=nY1qBAfP3S%D*YC(eJoA1Wo~I?d-D>$#C$tD{bi<<5!epj(2dF$)xN( ztv$k$$+8EY)s%weeB?`(fMQDVvhx8hBHY91T8ir2LNL2`YF1n}&H18O3+#5e$x>qg z``##vFykS~TU}7h53g<=VJbk3OG?|Z<<nIp+LV>5boGaiePIGTo=0-g*iNJ;s-it- z%}Vzlpz9WiLY4BiYms|XAB$p;8MJ60To(F4nQs=;5pX5*dLm$bRS{A|`8YXMA7+D? zr!o*$69M&k^DU8571f1K+7NOMa?`k^^!xB+jD9xD{UMqzGVT5Wcm&z)S_nF3a5(J- zN}lk&Xe00L79{Kbi0rl;1tKzZ-IQnsLo9AK7T6t8<1Rb*cWRzi0H%jJu|CVmI^t@( za`9eY?dB)P>KK~EP@(Q?&dRq0+jOSo%+Ppm!;e$0K+r3QsiBdOMP}RrswM?HlCMHw zZ<96w$pSR?#acGDiiTd5Z*_ectKt*{)gTzlp%rdy6h7Y(MxYFFreThH{Et<LXy8pt zRPWLeFS%fZy9M-RB6*uB$cuhYD>u|Eo*0Mp1Zf(cxRwos43pH;-Ut_>f4y(1FF0DD zqh!(3XAHPaU;n}wHj7MjZSHj5G4E=?mDzQ#ZVy-;QV}L|MW!;DXp=i_7(8B;oe?6E zCsUy~CdNxR<4?I;0Q;ZM6Q=vCO>|cN6q5~v+&ac7>imDx$>}M(xQqMqE?c^d9@W%7 zY5D;18SBvJ_7E0+R+Pw`aI}dt!W;KhK0<*Zj>|hmucD4j11_SsPU}tIGq)sjp@7X0 zo>f*}nZ{8}8;_hlyGUEw6NwB(w_IpJfiX{t`XL=a_EOcQNBw~UxcsHxg}ST2jX_vQ zMetZ;i_$rE0q%??b8}wv!;A22-YzZRpo}SeOqeb+@4R?g+CgO=*>z%w#wHrinDxg` z;*&g+iIJ51i^Vj-p3>T}N*KfTULjf-ipUn<eOP^<(H5dv1Cn-hl0iP%uc2CEFiC*# zt({I~|JAM`=-r03!^=i+v(4{aP0@}pMe-bY$BsvrY&7phgv~JTqMM*$Xz_Pv1K)Vh zrMRgITTRfG4iw_VV^o)<zmclYgv4JZ<_992S<mHJIXyM2&s8QsJP16vjw-%~43XK2 zD%>|LD8*|A6N=9Qthu`gwp)M$G+4+fi@3MmS(O2KbEECTO55b30&xaqLOa32_Hw}a z$Mq`uorix*ruvJA$a>A8>4|@msIcDmXK(W))_Y(etq&u7qf~0g1lkJJKTwt&cml8_ zB6}>sOqv{ucOLt80YIzr-$Aa<0jQ=Z@{^myf;cn4ym0e(NL%$hx2o^a1JG|KFLXb( zl8IkBqFOg@_+_O~^8IwFVEs*V6Pti=tU&x@(UT7zOU?!_oQWze_DfW3(|MP%I-^@M zRw{QIz!a3zBzboC;1+B#Pq0wIR%;S&1DW9&?9Q0Lxl@iN>_P%`EF?n^>8e}f8rTd< zc!Wp$YJy+uBHc~1&AkSvUTsGmJtJ}Mf9h)6=Z$Ovu_DC!6u%v6E-$;+(#JNCxim~j ziMn^I5T*h<6?qu%y#8bvoTDg}NfQyBQ~Xx+1C#k5sNkOu9wUpnjQR#+XDjWN8mW&) zO#ti@eC~-OGnnJCl1sH8hD?@D>gQjIVFEKKj!n8x2|G8R7Tp+E_wvDHovCKlXB%7^ zRh85%erZ^b$!bXE?`^C?&(Qy5^3IM?V83^!wa*ik^8@L$=wrvB@+<@C-^rrbk8Lef zCa!i0^86~JzgqpA1#$WRPdBxR|K2B?n3bOIQ;zC-&<M~gUvdy+7{Q@jw@5%o%e_Tt z60ozs&-DM^`wZ!9K+3ykvrB5qYG6~v^f7!@6pAIb{1sAC@KVZH@r!w>hVh6vFVfr6 zpX_D;2MmdZ$y5}VL*YogkfDB>uNBicE;v}QNOjX@pHyeGAr~#eCdPduzzbSJnZl!5 zPRuok2;P2IL@)?mQnC22L@{7ny`UuGcw>TyCz^->8Za;|QC>>^SY@26wN!gTnm$AJ zgOcZ|si{#)s}by=-o}O+D3pg$r>q&^e8fX>3rMWA{*D#b%nTGjX^r*>5)ol4S!>#w zM4a@xF7Rj?kCKFM4F|SX_;o*UBeEeuJM9SxciWi#7vcAMM8Hp}yvnBg6Hz|<M=~lc zA|fJCqFl50O<6?G=64~*-oVw%Fy=X%gN$3Z0f3R!m3L>xhn0xt?Tf}Ks?>_HU1I)W zKFFsW<htTNXgGMzGtrOsPYKQQ3gO9*)<ye(9xBhQPuFQ_={q=Oi&`iHse8Df>)=|3 zX%eB=>@ZrNj6aiax=|`ORuCp+-eXfK5Aq6_ThV37xJkP{+z8nAq!~ulC00R|SjUlE zT5{7`ZUJK)e=q_YLTeE;je9RqYN4Qg(OQKh$3gt|O8o3Md>aTABAZoFJr#RgY+$v5 ziZ}B)fE1Xdi7~4cO5Nc_hN{Ipue)$wZV>?oxVbFyZE*$?eFM}OQEN!zDVdDtZaT#> z*Y2X}N*Wp|3jjPo!@uHnEOprI`9Q!#?GTv-K<O1F<v;EELUm)yudP@$+8m)q9(f!! zrgtee0-=EBS(!U+lPCT7#e_zEzyuZl1vh_QA{sYr5Juz`ZWpRLe1Y?;7RD?vsYUji zUs0l1w%VE4g!TMR{?#;Pv=zkOZcBFrKVcH7s1uz0wLt3mYU}4o>IqRm%l`W4L0LOB zEOvg^(F@Rh9DG+=k{^@DeW?$7h#Y-U*l~XpK!c^4woYk2<|8>KGI2Z%2UCe%3Yw5K zmcj-9<B8nfzL_-Uk*9JgqoB?ZtP$+7XfYA`fUT~Camjf+B&~|nIti`>cb~karQMEI zAwg}OvDDO0txR?j7ww=eRAVtH$^WPBsDBrN3Vu?WuJR!EArD-1+mPdCqhJam0cOAn zcTmKX<x$NGt{`P~KZb`<3W#^$zy)XOHWr8(dtPZ443AZxd92(VRBH<z$PtZ8358WG z(}f_4hJ-fAG@$O2$i=LQo2zMPJjciQ$tv<IE)ei9EX~)!Dh>q}N#=}e8D<8)wPH3M zJR2r>q3cBg@9(&Eq?!C_6ghyfxA&g)g?2DYeYOUc?IyUIu(Y@M@UU3l!^@ijy!qer zw>f^j=C*M9-@HPK{X~9fz46JBl!ZWr%pm7Nc8}6cWlbl0^vgxtC~oRbg`hWIE6w;i z0B`8Vh&V~?F%R10I{#%+1qT7V7ElhByqp^2X94@y!<D<?P!q55CCc{&3H9vsMYB~| zx<Bih8*q#w<MEP*j*wL11XjH|?P;BLU8Nu#c5*GkrQflN3@B&X?B8>|o){?ltbsl^ ztP%0vo;=(yH!7jwAjO4=YWHc36sp>&=Mx-33VcMrBBh36ubL^!f8M3w9gW(m*l~d^ z6McS=-JGIk$s(paw+V!#k2n>ZQilTK(bs^M_{i(l2wBycN$=wCaNJHTLtXHmQqV@d zVe*n~er`o*jd+p{FJ&FKu-g6;g*xXE&r_#Ah)2c3?I`QmT20(Nu8Vyt9;6%Ei#;~U zJi^5u26LrYW4tl_uxrYl@_QOZL@taUNJhkWV4Ib(J3m3}VzqBos2{TtrzJ0>9^>0P zhk4(G^lt4#4s)qpMD!sEs&yzc-PCMSubB(Yi-7p9#{P9X?<0zPE4zXYlICp7OF6ny zM%5f>3B(4CA?5@X0kB#;Y^j}4qFR^aWD#P?i4eGYXGBTMrZ$Od&C=hn)0y#O5%8kt zQIN4%_9Y}D5Sz_ZNDZm?xJ%Z~gBY;-6VtTpTH)<r>Zlvqi2EPyVhk86<ki8tC3?QU z-tYoTv?8R#xto$W9%NKr+Y_!?oT^8J?2+}Xh;nb@b1a=w4lsd>@iZWmvshxw1-=8K z+NSXmsWtia07O(CL`S6l)#2oJ$?jlx4Wa1cSqJ>Z!dF=vR);E~B}0t>RfE&fG6m-` zx~n|LH^#3x;#<qLMT5*N`}d`N37by)nN@dzE77c%tXvpoy-|ZNCe7$CN0=@M4Uoob zF1QZr5AV9T^~lDI=IcgCtB4u=@k6-(8`^FCWx|RV9EauML5D%tk3?2O1}@RxOU*I~ ziY4cBbLtmCxb|Z@xu+!bTyl5#NmUXv?S`={SV>>cSc6grL_L2CyMZCR3Rl6G(5owu z9<X#nQO%HkJkkyOI7Ms2ZPV0In?%NF$mER8Eu3vPMaAI8v=y#CF*kgS$};0s2TV$x z(y@MOf<wXyY(%7Yml`DNxfg{Q7<)BGk?2)-WYXd@a6fmCfL!iI;*+cIuX2b2eu9~J zjiLbqk-h)@-dcUSjoQVamBIUi(V#%1zAG?-jQ;fh79wEu<=YK@zYBQG)11j}J$e@Y z{hS8QS6jUcT(Rxh$~Xacpe)PYA}Qc=i$l3lg3*fd$h(%k>hof2t>1`8y<7;jB=keA zq{00o%eQ5JO>RrKHMX7@j#JoG>I=Ih=rB&cDc2@DrM&f#1cz~*_%kLIsDQ&Ty|M{| zifyLi*_;cXL0As|n%qnD2W)w6z7r=%NZkGnak*$@sq3(&j;wm8M9%s3kE>p&&p0*a zX0$V9K?&Tmxigmb(72lEqfs*8JSi)U*Ro`Q%gSD|Ol)-A59{>zqGg+DL;=IVv*iME zpKwpz64sa_ziQmE7Wl7iZ}(Cb2l_%2`6Ed8;EQ<?;R<lBl{U!T0uP`(B&S|NCX6k9 z(*+sV+BMsa`iWHGG|kmdv=DND-<!i5kd<DYdl>%tK82n8TCgf9K=Mw5CC=wx%&J@X znSr{>tZ1P{oh5&5tmlrC+66T&MD6s>{P9Aca6$t*Yeh<})$f19(NG#RIrpX%qN%^_ zMt9_?{pTH9V~U}Lm*n~AfbJ%HQzal%YD5^7W$KEV{MVhT{_JBwiH{o`{OK@02H<%j zDrIS4!5<x(&CUOc&}&GF{f61bsr&71Og`W{2bE{;D!81$g;11+<RDJ;%}&}3u2Eem zyDU=bbbcF=Eia}cor|lq-}(o>mARLOZCpIPjwuzV519?V1gzc9K&|e3ZGfdB1e>v{ zl)n;NG;Gf7lNy+5rZvgV3>p|Rf5m@Wf^vRLrn>e7vt_JG;;dMBVwL7?!Di}`^1poE zl(t?0wAdXI1jYD-SX#sXmf3?lLo2~Fc>7$m@QJLkgP*iu28ebGTj%Er8C=@!3pZfh ziR9=O6~77{nufG~z35eg!qmLIac5rZ$sv2ZR_*RioNuJj*Q4yX4Crfp{WwL-ZJItV z!*J!7v2Ou>&(;9WW20THfuF4_vv&kK`-@?56tLr`_A_@l)p$KZ3eWW4E#Stn@dQ)* zBBqCPoFF>NDQXXJR^UFKFKaI>e=CMtz!-Xz_CTzkuIUO}eP42(eitXD{iOXUOv<1E z&<?0U8;;;Xo`BsJF5x!ZYqND>YNt9<op{EGx`8?J99?~<bb$~*`fW#l+*z@pEAFu} zT7CFD{r=}lC7~xJg3haS^vlJErqfuX8BcS^4|{Q&8kQTT=NC*_Jx0QOgo3hQm^3(m z_2FFEF!rhTCi7EEJ(Q&~B{}R~>O9g0QG_QF%!GtkcQ~tWG*$f1nr&p<8OjNF!t%$L zt*)p9ufa2TIhfdX&Dk#2o8@4)*u~W~HqFi!r#T^E-;9=<mzBoh2=oQ&*hE0V&YJ}c zXu=eG6KW|A)F=i3Y(Uj3d|-lki%VJpl3j#7Aq_RRu8D~4bjx-ZqJ;)fPk@A8+!F$| zxRRv-1Ye{ojqVYU616T|eTn3F7amQBC7{9W;F(|KGe;z7!W8A`;DtQ0#WJ<hVl+14 z@Dxn(4nr89gNY)Oqt1xE2?vZ*$Aj#pYPTkX6F`<ng};}Rv)zKz1~SBgQV5R{=whsx z2DF(T%P8GmDRB@__%-J7#k-%`7J22&(@s&gKQgv;O4OQA&`Ow@Z$HavQbIP3$0z+I zg^Vw;H)erQnm`%Jfb!~T8L=#SfJ-ToJy|A9n64PSMoD#|+4&M<wpwl!7vl_`kKpQa zf=DM(L!V3Apz75oUWD3l#ZZ<w<wAPqa&UZ6#>3{X4@aQ5iearb?~B+UipPo;Jw@(v z2lam47Z?n;2%XETIc!ol;?0%feC61QgFphp#!T6O_Io_-QLSz2OjV=T{Aibu|0udy z%ap_E&hvL-k7D=?(%hwW6IBw0k?YJ(gtsG{o@6_ex3LTTUrjN!Mj-gFBIqbaYCe@> zl#NqG0eTDFh}NWeYb5VTQN*{qz{GiR&ApfJ6_-Or%wes~ptRHg#~)F1gniei#chJ1 zFa;v0(IyGuHFWHxct&3^I?c*MF|?zLGaUX9U6)H8nb)04z-ua4W62ELrv@cmU_;~x zSYXc4b5Dh?Oe|T*LvmHdHw=JH9t_Qh%(GJU>9W%B;gM^c1Z{F><G9I=A9T+1tE)=A zfKK2%nJ8LCSaBU9hU>cNKI%CvuGXMkt8z@z)X94irnc<yp?bCRoEe>g0B2oFfkhuf zEGPK_yJ{{GXCzF7JF8o;)q~_FOHY$mtO$tY&*vrpyf6MNzm3;77iYK@%6vFbhbc}P zeopc>y!G+M*+lqXTPwpMs58-3LkzMX-8K6cdL-;~HjOiqV)1#<*E&4mcMlf>se-bp z`b<J8A|O?PtfH{aswXO*yzibKOCZzQVZDwkj1-+HO750>jJ%S76zC}>j6z^NjE{lZ z2~@oOjoi|V0ODQk%oR>J(k9vF7Pn`wL8Ophg><+Q95LrV{V0%E$Y??#V%=vq2}J`A z#WMe_OI0j3Gu4L;G)}kN3Oq!$!|76>(eXp-c@B9R=!e2`0Np_aLqzu-VpWJs0()P_ z#nU<fWRfZl+15_y244yKOU_jql4Ln-4kJo)JmDTkNvIy1j}mY=)x17H)xEnWznw*N z9mF(2YvD*QPIje;U%;;Fw2{*9!lHrkru1ge<R7UF#H6d=k3XtfZvGN215&?3e{PQV zX}_rF#0HJO)J>y2E##UfJmT}ugpnTnE@H5<pzdc4E-vg2SF}DjuHDEJ8*(^cPgn}o zcNU`$y#7eKuG5D$S<Ghh1K+|-0CJ}#2gq+Vnfn6HY}G2(=XWAxu!fa;*=q<;PiIpR z)%(`$V|lysjBvYuON;XHKUmyT(G*a3Hi`Y$gk)TDC!NQ|U?GtW;!4|LA0IHdpSG6S zx(c8Dp^%zgma>cOrL~rq<uS^_4!DX9wZm=N&yD4OMjH~0q#IPyp*8}_aIQyun@ULg z4lCQfhAFtGa6&WK*c!s$=ToY^H0$7J5A`?0cv{&mATXeZZYLsad0z=D?j#SJww_L* zsT*b4gIeKF6sQz>v)g;U!rf?hx7%w_K|-n1oQ&%j+vj@(rNQ*qB&Jr-j%_k|b`y08 zr{Njdh$56_wFTi>>!Wgg01@U;etXK!SxQYUWAxW0#zq!->#s5L>Mu<M8QX&vW<A<U z#Pm$}DXCVR!EC!ETi?2f%;UwBIgok9F-<3n^v!0YfNMWC(C~JFZ6(}WNFyg-B8Z)t zD>Wwe{XdQR=kl^nOA;7Pfk`@fGBcn2Gqt{%{R&?Esu9zqr!2W%hb3p5Lp7XdFTq0) z{91%tKeh}d=b6*z4JO)k1^WsV*BYGATpi`p9^-%2JlPK>EdBk7|ME_m7g9iWL1_nG zYiBt1zh1BrPd?8an}Sc<wxmC<EWfO=-J6nbWIU53+S;BU0igcnf`!^G6<1KS@f~kK z@OGJDg{bK(Wm4x(Fg2r{)Fp^|ifnA88rP<bb5t~!CW5H1`fSqST?guc6LA~s|AwfI zP33GQO2BGtLsyaM-se_JaHN|<+w`=;vgDX1bOxd@U8vKABeRqR?ocY~PBFow)f*y0 z`p8^d*?+!MCIP<Pkfjvfqv7z|6}NF*)aFE1;JxGU`Tv?(U7jKT$^s24#dl;+>Rf)< zO#I|ifp;<PXyoM8SqHR8-PO3bGEq5XCA}#H%fGA$blJnsdX-qE3d&8ECk62dpO*(0 zQ=t=xV^5fOXq<DnMqT@E+}Cxzd!0obNn-5(I;%V)PEq5kfP6UfPuF=flNjWYA)ql( zaBE8lHu~w$p2RnUV?nC`{r{kD_sBB7h+Ca2cBb-V<K;5P&F$=^5fw(05!4WOp7r&t z(E4GIT#~i3ks(ri^mykc6~kX!Fmy3`2}fWaYg#f5kRW4>!nF{o-ac1Z0u5Rp1v5u_ z&1#f}Z&A@%&?j-bzUyzhz@3yCR)}t5^L*QzsSqUB+C)hV-Ey};3#S0TNcO6;IC3*V zI*9mRJa*@ol7ZgZtRKvh?#-v3a$m5G**{~L7mHAjRa-DUiGOH{0IB_}bPRL`OzH)R zdU|#lgH0wJKv09-zp)3-C$UI)mte=*+(}B@s4BL+?6dLsb^DwiP$o7fQ7l#Xl7Z_v z+9Ag&rylLEmL;uE*8A`)p1?UNhiJKIkaCO8ECF(L0yszWN`Sh!d9&7S-VOl;z{7mI z-J|*86ZDZy%-pklDHyXG2Xb7PE3t*M*HgxC%D>xZp%u`GX{O$;cCg0uq8eo#IdZH? zEWQ|H@;+ew{~qViZ-_+eI4vK{^}qDHkhY=&8&%%eVH0;{XFj$wFB(~)gsVTBeBMFY z#^PtI)wo0-Gr%%C!*?G3jHFM?R4x|6&l;>G2jTL`a~|3ifEEXi>L^;oKsDWA{?flE z*A>Spt0WBT#VO-OEC6rye)=2;+ocE9Oqa&mJ1;4_*Ov!AZ~}NuS|*e-!X*Q2IF%^I zm#TL>(L7zL<%%NgSkzpJGxa2cenvzk%9=jBUrszr3yGuJUnKeoiLka-oPz77sv`71 zJATf*4P<tif7A^spULQD##Al|B;kR=ZJT`1)c)ixAvQr4vu`&{TcUMkCAO>YYEOj8 zs8x2y9kpRH+Ur-OnH={bC-<@I6?hg+FKiSEh8R5K7Bl`1Va?80!6!-t>}$ed%X5;D zC~L|Ozhyh6wjsiPXNtodc$g%DWRCa@Y$cRb)R+0rROYF{QwBWssW@UkDQz;ukC|BV z=xfv(Ukcz2o!rUTaf!IO8b@`kzZY(Dd0QNHpQ0IHED&F%$S}Ie8m{!O{w|4*&wa`| z?tbZ9dp|nF7dL`eXd=one;I#iLDO+AE%;#3oSYCXvlstzXmn0>-0<}LMivvW1sAGX zE32>0*4tXtUnTq6kg*YEp6&CG@?6%f$>>>N2YD<YKCSC^w@qrPy;M4J-LNXiDCZiE zBS$_^Shp;`$8a}BG+V|Kw6ybsn`lINrXg8=?R*XZvVQtyooP4aPQ_?@mG>D`Z=w?! zC5Hs%^-PH6E8?#Ya*y`dhuVEn<$V-X-)7?rpYF+O*;V7aHui)W4gZrR9)q^f`BmSW zIL&S}V?gb6imDXK{{tUGPS<$OzjyZ*!*@*uNG9APw58tzQ~ir(hUM!5)~EXm2XKjQ zePVh5fk&%ia(O)E<AXe;o2<>O!A3EzpLTHex7zLbHf|vcy$Vf^tfquHo{g26G0{dA z0?1yY*1`Q7rH;cHMUW_p4I?t`XwBDQ=y;!Iq`1tPszbE<IOjIm1+apFx(78_nF+s0 z<=*a4&`jB(GsM3Rm%{x$g0%vDGfjQBjUy$Y&L)zY{#SV?WiVjlL==uM-PWaMLTuao zn4l|VlCWK5ZbbRS$mpLoqKmZ%I277ZXiENi6mRa<#xz)u8;c`bG%#bteIboW^3I@S z)wUCG-6iV^`M?t5=)WgtEfp%y^#1(}osNp@e<9UyE=NO7Q>HiE$6!EyfNdY;M+fvt z5>UiaZ!Q0IcDN>1u1PDPotS5+`_l1<H_v8b=qF3@c9bvhf;+%+)t?t1pQHYa&`8M% z^uCe5qqObe;!)3|9dD_I{FywcfQ<+M7envIA)w{FLup#;u0dy7#MJ;7Xgsy=xNp_( zdGO)6gn8o0ts=S^F3qI6M!tyNgQi*oEK!|Xtr~7hZ;U(PyX&rY4l)9Wu!{`D?EGzy zN-^V=8pnfHvDL?&kH}O`A*5Ib6b=)ys_x^2562QH@Pl&+u-c@Svr0FAY&rUql9hzJ zjUm82hFVgK4W=)}&Bjrop{K5O?PIfnxM&j^NZ{$!E?8?RH1B-ZjZm;&8h94KN6zAp zVak12c<bDR(6@V5Kd6!a{j!iL^QEEHs8o9{O~E}SN%G`5-#3`HLwrsIQ2~5FMkyNZ z9Ur9wZFg5<n6Z5zJz^qA)NU2>nHYVDc5y4q{O)Xl9o1hXg$2b0p&uz@6<ed8|I1BH zhcm>hr|nVfaFrc1kpqt#REJe2FzO?Tmveu+NytzbNgy(TMJ2iRuik*le)UuqGK}+y z;LC4~gq3bdkWjeVUk@Lar&KmcJG_7UAh{KG6UJ%)R^+Qz`GbE?@F(0*+8?}F`u45C ztt@l-N$}Qg0zKEky+G@(yurL*Wlm4QQaX-4O{pE_+DsPvO!Rm<suvAurcYi|8H$re zE9~G#Ujh^sA>}%FP&CL+!K+w`99qLr|C|$Vo%HJ3XDu#6Z{_}nL(M_T1)tl%$2}cn zWq}|&XYZ?{F1pFz+)0!XIxCoZJ1mFaGyD#KF*P07e(WhrX&WOYJr5}A%MBj0N{p}J z`j~3y>iNj|(NOKH!*!Kf15wj}rR5XDjwS1ERbL3!;MHoM?%*qS#@wp(NcY+j_ZoZ7 z3q$HLgodO_wWaRjCWH|;R?rvvsCot~a}se+7tR6cUIu%LBG3bA3@_i3CifuqSb24@ zL4wAu;m{i*4xenkpS5{eJ}!lD%`7WMjQb`6(upyFM~miQ(*7-*jMn)A!PJIL6Ea$F zgdViAS+J`aP#8FVlAI^t2iAq`5RfBit%qf3+|+NE7=G8nuD(EW;p;a%03Lq!9>ulM z95Zb+7lY>P6~#D>7xiEPMj!n;ojke7V=;FWNyCjj2Q4;SYUCllO0V8U0PxjY20<6Q zhPUEr4%7i4=XdtQP`!92Whej<5iz@cEyv!8$`>CGqyGXFf7H(<3<rUXb{v~c^P0A= zF;a5IMT;(XorMV9Qx#hJw0tmO&t28y&?4kfAZ9-A_^cTU_feX(xQ$Pob4+xwfJg!q z8TtssjX^SF#>c1=NnT)!>i1XB;giYh5hvll2EiqMot6W}kN%1mV0cutT#Hjdmp`wL zT_KOS>ZN#0YUy4tmF^-GLzNg)SGiFLL?(=s?SP`yEc385)Yr+5Yf8ClS+Vk`UT12V zf+?yA=`kEfNFu;jdcQx0F-b5DElEc_PDbSl#4$vY&v{-v?o%=1*b*nXp$3N4__$NX zs1MkE<?IHS;Eu1s&cJ)M-BgqCH5=yjZ^+|aE-st;$#gOn;2B>nGtGs3_BH2^kA&|P zM*ku|uxZ$rnV2yqaA0-{FaW@pT~r}3?9koA3tVQbM}nQ$UKfSRSdPrtT3?j;6%Mj| zNbBQpdo1-Ua0(yqV}omCs@1ckLGIJL9Z_yI0hSv*00xC4QFA3y##_y`j4D!~qahlW z=p(XuvTU&F`1Ec3#6|5I95dbTaNRI+p-a2BXDs@DDh}ILw8%eABBHnl#Yq-UXzq^E z^8cIDGwIz)gI2O{P?XN(oM9lX)h_KVKo2HQz$(4#df9lvSr^5n%UOzFlZN(9tSHqW zr0iK<DXVeJ&e;t;lOjj@#@zR&hf9-p6vt6HrE<G)5DnuD={M5Y?DXPns4fT81n;`$ zAiw*oKBaN5sDJI!dIVmP2M;G>G|Ps(C)|Ij!w|G{6roY}xkt9n0uh;-K{m{Hs46tB z{hGud9v`=6t~PdhMn8l)kW20Zp_5578!5ue%Ok892T~(ve5HriNWak{?{;y;NMW=3 z)WWJ88<kuPWQ2`}(ahf#N8Rc<|3-fp3QW|afA7@9ut-VM|7kOktd`x?_c}OBJQt(& zke#t|o#%&n9DZGM=R`BvwXcmT!kv022*)H&e^@m5ZN1W<-4X~`4*56xfj{l>sUI;3 z(t`nbaK*MrvxYpUd)Pu%M9;RWxL7_7k?)~)RhV{3Zr@+J)k%=mF})tdmWHR3^byrM zC&0lyzYqia4rFMUaiB>>ZIL-8-i*<$^mxf5q5jgk@n3v!Vb0uG*6SkwO)_rnRg5;+ zb9WrHyW_pabKmt(!jzsP-IO0+XtB}}XD@1WpXFvt^%sj~pX)L`;*6^Fenldi(4(|v zypl8D<--iuSQnd;S3Kccs9OHrU<=*4DIv9_He_SV_En|%;kbOPx+Ancz!vl|vJG|) zWO%ojpYhWW>n@JAuPK$U7b&kvdMbN$+8eYBIu|iBHwiyDY46=wt8m19cAK0t1vNaC zoRC&K%KhMo^DS8`86tc~M|=0bFEw#bceE*a=qqQD&aj01kk+-W7+N@#0ssQnL%WV{ znbUB>*?}<txu`zJCdTvFSFX5B8`Ee%5iP1|A10Px@jqKX8o7ZhyPD&o)gM50>83wX zWkCp)9MA$B%*B!ppQlJW*LR<%9(1ikRx7il>9Jn7pIm;evlzRkoowVuW{1Spea4d) z#_!OCSi>khzNx@elox*bDc!mz3x`p8b57n<)aDr_H1}>sC?$BvC9-k5NebqItJvE3 z2g<#mRbI~@<ed1Lk9^1Jmt*m;GneVf$6aBo14+1v+?*leK_h*DqENfP+C#;TJFK2v z3m%4czzTUggSqOL-o+eYQgjg;W}`}TCRBza2*%d{M_GN~h!8jLsSP!w$pPC)>Z0(Q zZGdF=v>J;lM4KpoLeS~YenDI`ugx%c`80?Dw!4)in_NWCJ~!JOMw#Ta`Z9g==R+Nt zX7H=48mtH4vvj-P`fei`KGRR7DEVS~|Dp)xdgzS^;mii&;>mEMH%TuGU9;<TN;EA_ z-I~+N1SXxb$70bx=7bso_92Y?MD`Xn)z;n;M{t}LQjsRq4)OOp7IzEZI3+A~e~x&T zk|SzNLJ<uf6Tc<=_3KsrMdVi!EZ<2GXhDQ69zRcyf-_NdTt>012say<PK_L}_O+K7 zZ&5m@S1V&U(N}?+vSD$<xL|LEqD!vnGszzzGQ?3<8fkyqJyjo!`H&&9S!9titec|8 z9`zChL{&ThSAAgF0m7P1%e;P2))3|<$jq<xd7vVjC!{kqa}tfF7A^Y}`(pm1W9V#R zhAxdIcrGS+8b@Bxz)Gcn+qoarv32w9(Looq+D#OUXH)SLv!XJYfoYiy;zF<O1MYp& zG}p~c<O-9GF3Q5P>vQv8JhN)N9vYC-0?v2id!f|<TyHa|)Jk5jwEsdK^<@6S$I<px zd>S~C#%rYb$cbbqW4RB8*(5MEw<3E0>F+C3T-I5wVqC*$!tD<N()jn~2nd8o$}GFy zck!(sx?Z9IpMFznDUW&&Eu&&aAuW5-nt_JTHQEo}Q>*|}$PFy5M{&GvZ4_JSoP-_C zkp3>rXI^nECiQ+}SCxbJM{+vP2Ct^!nfAw!J}bAS@g|8(6LMyyQxv=89}+^6Cgk!z zMoklRQBxFCJYQKsZLdcUg03)}UN2(5rt0M#!qPVvl{EZ{$x!xfrGOlm7-h_k%I7GA zX&>cRvIJB+?+Dd3f>*{$!5Bbs|JO7j?GwL%UWx+cwH#nHwZWeM>?Xs*1M=uEjc)Nw z4T5~a#!7sWvYtT2Fc~OVHkM1URjqDPx>E1AzOjns7Ppz#k>&aE&iPKr5s%#>pH5Y= z(P%pN2JPk?`AuS%BBVDCDZej9g<euDarD^c%G!~Q?PrK(3%J(BlP1*_mGJ}0qA<V3 zl8dUgLi~pk+k``AgEBPM&5mY)-QV6a9QX`$Ed9X^1*Pb9p+60p-_X#oz2|rD5h%|u zd1@3}!n&N_OtquewJc|!ngpL!X4A5sBm06!F70Hs(TEsP6&8S=f`fV32NI(nM5|sm zH=0Ec<ShiseuLz{(}FXMwSXpFCQ^k#x6(rcvm_l1kX-2=KJ=!ZmK#3zj3_>EO`e}o zL|6ENoaBc)G*)|tRA3_Ce{EE8S>~Pm9X!V}=?JcLGyV{6upBzkLXhI=@N{UF?v)E5 zHIFtANFIzlyfgv>V1@GE#2I4#P;c-%4Ng44t$#Lhj;)2@WaK<H6}=zrH?6w~g*ML= z3}E=?KBLnIgW-WyhhUUH+2EYVOd2u$8WsS?YfE~9Krr^NyYB1>x!uVMXQjg<dh6`Y zJhRR<iY{u0Rcivag+LR7qUDXh=zzMl@{tITMO_XJb@+8{$H^ctjLSvl6cY$KSg9zT z-sT+%H;n5L>XRkq@Z9w5LLq3tflZ61md+F3E2$FEbmhy$z}%d9Bur{&1g!_++pGwA zTtlJ{x5OdES%>86)8M8_A`8iE3eyAfo#UQDD|HAW>Ur{gEZCxg*0Y-_ic%mCriQYV z*gq?0$m0qIvlW7Ovge9To4M|As9s@b6!JIs{r-*zc4`h`@8sE7Jv`Ba5iDXqDg<>v z&Xb=>k8RJ@PLiawIfCog9IPnAb`!LwF$jjZp>Ns9R6!%=^g^&BOIl@i(l3L?nW<{f zrE{r<BL-5-2Oibd;*@9NiWkEJyMKd-mXHI_?&ws!FyRyla|MBwh56}-W#xGIR)>L# z`(g6$=&~o+AE4075!(Z~jqBj|E*n&T)m;gh0WjTv!}vo@vC~-i3*NbW;Q>eeTtDKT zzEy3^yqc|WAn%*%56o`!Z^k~e7npQD0fs4#HgjgEfu>%l$f<8dH;&v-W#l&_?59f^ zZaL))u2u$aP>w9-`Js}hI$v0<W9x@(Fb%XMY*Dq<xTGXDvPv*g+ZR22l~~C4VNx-r zPTGJubd&!G@QkY+g!X;}@$(ROg-qdqqdq8Xp(Zme&4_4orF@ivxu^#fZaD%Jug!;+ z%J!IKBsHV7^9)aL2VTN-&y8Z3d~z!0_0PLpf72!Y+y~^!h~v<VNky^hAT>>Z=dtNN zbl1jAN&2gUDdxLxUJWj3IwngHZDdsVaZ(5FV~_woqEHT>m`>iES)Je)w>IW^mT(1^ zW4!BoCf<9-%p$?^9T``mKs?In7}{*{eq%v!rsU-WQjf_N_Z88!!R<Hzv*jmlQr=J9 zs}INk`&|8Oc0p7};HNU=ehe6{h%t+jrTQoKa=Ib}AEl_wQ3oYPHOB&<Y&`2WtF9?I z1LkIEw}KeXU?(jYZFYx9vnR`rHo>mz7#zx^)A48GjxZASl(pPd?+ewomHnaWh#lH( zyo)g94dIW9n4#2NQ8$%wOLL^}q_EKhL-%C4-q;DJi$X|RP=-Wox}S|FP#U{(GI*K6 zNZc4kF&xs0`yD-ODd-lsuDQL$sIW@8;>VsFt<slM{NwG_=0Qgc@7O3Nk7rW+Fq=<# zyI})$YyxsUlf;1#;#mG!1U6U*8QO!H$dh8)+q#t5JVFz4+DGl16hyS8K3T)BUC;vm zTX&APeZA;<SVcp6{D%4&C@vtdIs>;j9i%fB`a>6c)FGyUmOITSB-~=n2^H<5rwsiX zC7HWiuCojKh%cekmDLy(nB!gKeH5;bAk=02kcqi_JzK`>;LTGi?-xq#a`T0OEDQeH zltoD&)#lv(kLC0Q{yWj}&Iaw?)Q9c%3U|%+%XlnY6P(@XGFz&4lWhIvES%9%txT4b zM&!Udik$VZE;Rns^D3FIE>o?sfUN^KT+<$1(Z=u<A6tV05Jpq|Dlu8-G&PgBK*mvi zxcgAQXLtw;MB5Cp0K}NL&S%8CCa^8tSIt@rjlcxtHU?#n$<?BbGH;!R5<-B76{8n_ zB(Y|`dvhM+u=R+p<grYtFm7|N6<{kk;KzZy_9ZyW^s4`+&^kr1E_+NJnZxU`20e9Y z%GUAMOas5Sgg1G_Lhm!AC?(;Lr$*euR#o^(&t@92v>1#LJ3MecZKE<e!3E_QuPq1) zt%zv3UK!M8q&Q^ghB+@;9r|SMksuuX$1m&7WA!FSg{>C2L=?clJejIh=*2Y|VEnX> z)B$-Y*LTSZr40T=KMHZBh9Pqdw^2c|KJPr{p+yQZ))dGOGA|~Z-oRGsfJ2kn);_3} z0Rk0@y#VwHZsZJEd%(!v_#+Ecz&gOdU`f4GHaCWj+1P2Xm(<Uf4xiMX4dPrv>%{C8 zbzPABj+dNNUG57JtA~_GfBU1JjYy=c7Km>7(qJ2|ny?!urTo2PC5dlAZ5b?t&?&9I zB*-y=X>WP?9Lkm`Cb(IL)M?}2!L5FJeK*T03YEkmr7I01py<3NS4V6-PZK(PR=rVD z#k1PjsO>nq{aXH5hPFCey4X#T1a(`CpB)}nztXlEqHfkvW7nBi_Xjv+{Nh7m+C*X{ zCP#OsrCWw1E{0K8MJtf)XwD^dLzVtLyC(<l`U}MfE1hz$T$;D_+0a;td2^Izz2hG5 zA5|2x^7RrzvCT$Grw34tffI>R=fg<Vx|@-;DenM7$uIizt%}C7m<Hy%D^au{cyvdF z<5&l_%4GGDjlz0)52T8uR#p4e;>2*XR)&=8(?V?Xs11xwB*UHY6|2{#wcM9Co*&}Q z?kFd^0Tm4~?{|GUM5_`j@7ovLqom=HKj9kCxq$bLGY9AAC=WU~S=4Ig;WYVaYqC@4 zqL?95|7`B7sohFQEFOalLm=$<o*R4a=2^<bWD>~ZlHg!6y+MPT18@gfKMo*V77MaV zPJ$U%y1ENI8M-ZVo7)+u&a?5&K9kCJ^v%dp)L___rJb8E5!}H;qBaj2ReH|p<Zt5M zg`dk{lQwcw4w9DEdWedM?s}xJ4tgB|tWfV)%o!=-K$3Ju#E+^ZVt@;%_QO{%HGPJm z97&)-Of}S!<hdQYr(JC2y~XWt@YTQ^10gIZyoI{6N)V^5$CxF@n^fkS0f@RPtg1o( zy~9nZbjjitOcCSw>ti&=50&-~>MyP*%3tj{yQ8)EW@w<9u(h7sqx#rxbuylF>O+~q zwU|kivMAmj5olA3u~o3XsmmOej<Wp#6@}wsYzMx8Wk_^KyEOR5mP}ref)f2g2vJGT z-p80v6HbBjIAFkIn~?ZJeqER!DpT}<-~3gQ={RjM{W@?1&;FViM|-DE_9bTIKRze5 zT61^1#&1<m%OMIsnD~+EZF=hoq^o+EFsT-P7h(kc2!icF%J%uMutuvqFi*Li>^U82 zP3l@uYWmQj;$z(qg1l@nM$Cs)cwFPaN>}bG3BVLOnU=fJ#sLud9KI#xGQkv#kij9< z+J4Q9kqg@JTdcI3k<U(nocf^0OruB(Rh%|dVq}J_SLqVg!mfO4@iR<n+FY8{?;xce z8srWh(y-MzY7Dn;tgqrf%>qP#E>ihbxGq6FHop)+fSy>*!cfWiSSHrAcogD}cOnzs z2XsudxGin=_wm7m+Fl?|F)PChJFLsXEdPBo`}=$L)pof$nxI)OjS;D_7lu$ME2(&! zH9}{SSCihJ#?<!h7i$j3m4)gfk-qB<i54~V+QA)<3G~(3yTUTo_VX}l+nu>z27de* zw|i6+jg%Lw+Sw0OUC47BMo7euTI?aQe1H^;{UzBMsD|ZLvhp+gv;dv*)93@0j%Rrl zU|*W!rq_spgMImU;wl8-HMqvTx)F*h#FPIwbD7{fJ|VNeYY;u^Ik{BL%XE^Qj{>F8 za{qlrE(uHMA*o9(@~Uv3%g3yb#hZ9;HQ3WI=dSy@$pnRE^bRkwm0Mh3!Q9OenU?r7 zd6QzeZ$O0_Lk}C5&Y+J*pISY}Imn}zS~zLb3ZXj1nm{g1U2eqAUCX6^u|Dl7C0}_B zm<W~Q{C=yG;5OApjbE)RT=VqCE@iM6o!q%0+>MgSJG{qaZ1x-`2Z9EgcDBHuX`(K7 z?{&y|F9q6LzIoTZhk#onyK|c`SW3UsE13eAw|OjbjahW4FGtljVcT4|88pRjGgjxv zv8H&0@uhp65LM*BqP{O~KSN?FY`qjdtT<cUNjJ~G_0^&hzpWc#gJV#b<=eY0RQlYN z82~5DXD^fdB?>zS;OG}^OXM`XF8>~uk#j9M(i>jsA05h71|4j5b~s67=sIq?z##Yj z;JO7O6}H9l;w%<xY%Axz$Qu|^<T5A(W7-QUn_vAhJ`S}LSaYm!IsqAShV4P}_q9Gm zCAj-+`-23ijcOsAb@Wn0l0+e8GBZl`5vBSo3V3QpM(j1Gx|i>^HqQapI7iTn<%RuI zh{A2eUV-cTFux~d8w7I4)R{7coc#BYr!wlumEMUrLqXOuM`R2%d|>KIpPoQwoV37N zR3EW4V({Z38D(rUZWN?4M=58{Wk7kFCvzKb8yP15D0Jv$3!l0RV4E5;zq*C=wOl0f zsb(AfdwUt65rZx(Oa(31Lh!fq`oIo99`LH7C?)LJl9UB${nRlT4Wi-s$*?>7cV}Ev z?dMj;4B3J?D))A*G{Dn?WP|-E!N&ZU)&<XeQM~Z9BguI~u11Uf`>_2|qB4eNzR!e@ z4RQvLNJN6H;SA1a&I1q$6GqeGeR>CWuEx6tM+ZdJMYk#L4x?-M9nYmPFJb<?+R|Wk zl<udj?Cs)&21nFlF3ii8mui33%;F)gYb&1kXgr8>5flc;yFw^l3fFnan@MFhgm16| zI{hz@;%F{L2u!~c?WkZI&Sz%!;!`DwqvL+XOp;R9IpP!@tYa-hd$khc1A^6lXW^gN zKcSnib7<~g(M>Q7&vyEj+WHT@BmFY9TD$V79Lfj8m5BtKx6KpXDN0Rg5C>3k)p*qk zqEyC*itb@}MoF{6j7d-t4`Z-s+V-{zx8%vwlXU>#Zj-JldGf;>u3(U;iAWqPnYvRm zGyLD%HLm^>loqD?!?5;8mB{8`OA<6Sa#IplX*0VigI3Sd8xk*NSaf?O!9~9BieNeJ z->}5#U3ZO#hJKEyi7@gPY-%&x;~{b~aV*WO*eaD2zQ<7}PE=DVfuUb?YDz(kth8Z6 z^y3sd9SA=h?!+uLAVb;H<Z^L7j^0B0&sTWaXTRPve+@T~nzGAX>wK~?Hq|LieTS{u zLuUEFa|u2*cPDzt)sheam^G6JwmK{B$7+Td)~1u7NzIPr1zGvA+3&gfxBMU@ckW+Y zCGB~v=;DEc;AY-z>&3HwYmIm-v(YB?SNUn_EXA3J%Ev#OP!~M8wiJ9=0@Hzo9U>t8 z1A-^pv>#kCH|G(`Hb;=xjbsX~&N;mEz;g^04uJ@8?(;{L_piRoLrIgI8lNy%KxAAA zyr4Ctw@~sR;ZI2fHj3T!Z-DO)jO%M-D+6~ixH4$lsIJwW7m41nAm4QQIdopsB&=bl zAmM8O#z`hRDT`MrX53?fnG5OqP$E0#GCvsyAwr#Q{`l>#E~T_dlVXk@X<kzf-vrFp z#S=^-nRrKwum2hb{O!LMkh>p1OaU*Q+3l9-j<=h_lO{n%T0hbRG(+_jY<th0`mono z5*8u(t{*394D&$>>|)-zQkAU{s3hZ0C(Srmhu4mE!!(7@Nt7T8EwxKIjd5vQ22r)D zn71~xeP$hm8Xbbzv_ey{>+(&2Z*7L&*&e@xOFF-G*VpZ7Agy0j+4$wa3@6c?zdyQu zX#23;Ae(;?iL-P}=A#?15UQjX)WvLM7Z7lSDR#Ycsf0w7A&w$ze+)VivHqM0n??zr z-t?CD4)1Oa6}lDN#1^shXH7`rDUdBq7mM(3aE*C$2N`?BuK&Wn_<$Z)vt0(KiK&6a zu6t1^hy%HYolg2H<llYc5t}_0l_A-sM??39cu&Rp`T6^vbEkL$=(l#C@(&}yM=%JG zonWis{mg_M7pI00zl5VT2XYl*pb(ssINwbk2y>O0r|(W=`@*^}S$XJ#qy`izNqk@c zdsKyusy__f@-|?hvw6qD*Od=V<g&+?hBFiGf%#z_E}p{>VYp<F-8@6Z`7OOOALNL# zE8`m>yt~8Rl!l^|8Gq!*Zc0y>rt_Lii)XJISK^k2yHd^qpz&))pahwxTi_DN|DiX_ zYmbc)D})H2UQLnngIs5ZwQ$jL(-vKvnayQ_ToNO<e>drNTISDz*aKURZ5^PFvg83} z)gMztfafM6OY#N<zYD-zdZzUnC6ED3gBv<Mqc~|c_<#hmUbP2#IYgnmOc@Uog1cy= zR1&WQ2_OP%0VW=7pf#thBTs>5Gc_C)UsCuehA|J}pB495G!}@=^<`Qgr>Rfon(HTt zE%MmJGkUn(2zU0@#Tyo)WrS=O-LAcImuWrguWdb86^L%kS|Bm>&?`kJO>O<V1AC^j zQjE*l4ty6AMI<3*@_>d7ND0cw4v{3&gYAav24UWG4zTQa`2iS?g=ag<b?>V<_P{N$ zJi1mQ6QfN2+C&YEr1pVGn@gpUfQkNi0;us!W67HoSRXk7NaO%=ao7QU9p%0ezUA<@ z)lSn1mrEDfRH2{0otl~bvK<Z?J+cUy=aKxv4rAh?Imj%Ftx6qXv!`y?WotTIc0L1K zSxY)_z^jEwBk+F>f)1aE)<6IS%)`{pvHt>HdjG=pEogQwt1W3Zv_v5|zmV(1fP7=@ zgyIpl+Q0682R!FOne1>w;93G4F()`u@__7refHobgQn;UC$`Tq_3%`B)T>IwED(0X zQx^3mu*<N2TroAZ%JjLf*W6lVZ1Qod)M3Ays-xpH)6miIey^VQ!!fOjvTvrJPvRq* zh>80?h@u(<zuBADuC6sL;@?(47XE!Iau!ld1Y@_>gJd2nfRerrBMvvPP}fPdi)gz) zjW+P#jwM-?f%Uu!BNJ0EBIC+&9n<H;7`RRc)_((UXTsg)?;25&PF(cJAnjfy-ik4V zDm$EQ-ZU(vwF%JhmN}xi_nJ8EfhjNs%$LToA+;$d%;`!ElEE@9o5CiX{E---WdS+w zpp9WbmdSNz&NnE+9!&;<hyD+B{v1mz)ZYO=*guY!5*zLBbF<u(Dq-}~uJ2t{0|I%~ zH3lDY#9IYf(AE<zM-BN$XjXUMF$%nbeT0$*0+Y@V%s}E3FGWh;ez`M8`HaeENa1!3 zT&coPRhe0C&orpXEO~n6<w{t0iOtNL;EX6(DIv(<?64qq_X%u{57W{ZtK_@-RTs-- z6L3+G3OOB5;=+pdz1s)ewL{{=)a!QhejwRMCr>mlDeDAbHNaMi*Emb=^F)yWl+U~9 zHV;}yp(e|Ab4FmiXMCn7V%=o80mlU)c>0&2*qrQ(cY*|w?`aFunq+@Nkm;=wlm~7b z<h{uXRjdo`>;}1;*b+w}*Y?kn<ea^3?8TFBj*8EK=pY?<jzZj8Ww@=Bulc(22Qu7D z{P%H7aglR3+d@3|mWhsA;kqubY~QdRc)r1blHl&0Q$HfBvGb6$^`i65kIWY0D3s0{ z(PxXogN(>9<1N{jcqDXe<d@EePx``v2nAUU%hdd+N}iHpKXU-OpOtckK<#fxU-KuJ z1;G)q+&?Ltl(Z{Em?E_Qmwk;kHxc^r=8*^x3v9IBeKzM);J)LlGafiLuqGe)Q?2J8 zMZ_O;wY*6Bq%9)V_F=5KCIxJq!rzo-P3_KLhqfb2W7IJNXtsi<&hcI4_!q;>=SmCF zuKkPP0J}h9ua8>I9SUjU(&)rbl`NgPz2uxn8denZw^y6&NKrD;NlU)ld~K`#SW}I? zr)<6aJRx-1<+F+joa$hI2)Jhxvu8Hdy7ds-GPQEqsiOPNQ{n8)dL=-yMSo@b`p*_Q zmHYPFo)9S2L;#T>NqBl$m>0U8aX<Y*OEWZP)d<*G6jo0o1$20gT+iCb2XlIye$yp) z$`fNCDt(yoVA)bYeba`o`-Is6VcJ!~K9%&#NYXUIsw57Y@c@66P!{p0*ZyI*!ztSr zRu`OHqzdv2ot4|4koxuPu>gfbZsjPask-29Qp=(+=q^VL`@2<~A3<QYHX=SFKCl=8 zwJ(-)`aUA(SDA;l5a8KNpRFuEc$mDbG->6;M#JSVg@OMME0Rq`1j^j>Egl84N6bUM zhvf0c!3$TlVw<E729jV?NM9IY7-nIqzCjs5SAfzrs{;5F^hOCart7(yd0N<DI`Qqe zX|1XjiBzk&-L%tL(D;1*I{-YIIfvi9_f~yHleL!Eh!482b^Vl)XD=-TezFQc7}9xD zPS_XbYBf7(r=+#NyFfO@bCN_yu^2(AzL7c>P8mY&!x}j4BhT=t02+2J$nzvI;~M0l z85%H0>?TzHLtiAQTkbakVHs1V8qn6|62CK3HnU|Xib0tvuYrfnUKgYef4OOTr-OSY zJ*R{YpF5bpW|e5y?0wYQ5&mofZF;M>EPvlM^T7KBX_vuyth2=WOUyiveqxY}#JOcs z4LkDM!vQJS<G`6ojT#-;I6uijZ`C_<<{&iuzp6LUsT`4wCV+3N4GPNH1ib2z;ryg6 zg@2Ld=q$cb&wZ3=`W_}wKR6}L`@4r%Yd=y2v8&+H^BJOs&BkGX{ue_rc&wj;;#Mug z-BFG!qDbh^8(k>CbBLM6yfO^PUCS%}L1I+*#@k}Yg!U6^Et*WD_}oi?KP>~a1uY@h z@zVt|#u$M7c?<?j`0x!Ag33%5A3!hZm$}>>o0Smfh?KO^)V@&)7$f~<AT7elXaGYS zGGn<%gdzMYxb-99IMr5UD}r*!aZ(A}(-7KaoP3x`ZBPZu0<(dgDm7Kin#uOgCI`IB z(DxEHq@&=3iUa}rJS`?P?v1i6r?qoycic6x5OUQaCrm1$Co$>MgqyqGs*L|C#N?!! zrpq^G{8o$b)fvU)f8eU5Pzj1zY`HUXq`MU$%_KX=!aY5s$%bBDCp7w{X7wLyVJ;ZA z^#$kYKzFfeR;@b-*4YKHd^W8Zs4>Z!Medez0HI73&WAmx{EHNdE4@?nrwfdj^^RpK zq><*ai^Z=?NFl2#@K>Lizp!HI@m>?bxeSfIh-{K0tep=W?#y)_FX0E#88NbHy!{If z|B&efYzZMt)ui_EW!R<*0s|jGeb_Y24IQ6Me&mY@o{GD6MQYKLA#FAdXR&;S@`9He zp<}B1jJ>fo-bHFW&&%fyZj4<8deK38l!)mV(z7iM-LvYMM;jQ2?7Z+jv-0~_6Q7x> zjMaXEJg%QKkVROg;YV<#u1Mp%>(YhNYY2K<A*LzsbtZ9mIvzUCg1w2eS)A#G=BWMq zl<X)mbG{@MJ!TXn@xZeKez|Qv%afC-yXQ+oqb(!TpG^@he8<hC;Gwi=g?)0bi+oS$ znSzp}D{GimZhahT5mV>e`m%tq>~CQEw&8?YQc<z+wEzWalRs;>2fi!aKX6Ui%EjfB z_YjEl1(Rca^JWl@eI!`+20u<TcPwPbcgv>tL8~e7E*~magd}?$u8YN689<DbaI5re zh!AbHNTi6t0ern3hlCA^b2O=|G&eVKFCVs#2qmPPP8dNXTm6!SQo=XP8SYmXr0`XX z=FoFQ^NTJ9KPZLKa=n)Opr-O{iukGD5nI-z6@m$cbaHEg*E2v<78k>NilH~Cmz4yk z!0-EaC4{VKT&R4i!2F`=-=ki%<Mow4&~y5500YdVK=9M^kPVuVbKO!0JgdOYzBO=3 z`yypN?*6iqicBu-@8|R~Da&!v#;IK#G9~M{o5>8!lZXY!oH914ly$^>0v()Wi9ow4 z5EIar$|X0bIiFEAa%yNE&S^XsHsE%e0^`UM+kZ-*b;eZ7+a4obSUUxyfZnhrbbzYC zesE5jWxSjTkQLPMC_mBDQh3Wo5+KzHk04FDrh&uAB_GBRopQUMQEigK?MErY;uy3x zU!11sgH*=KxR;KW6ULsM^Q?ShZROkjbhmqD0`$BzdJ2&V(>5qU{iK~?;z3j1O4^c1 zwtB(z1Y9gV^Z#V!Q)9h_17H9PBU@PJpWBiG&HEW0$^%n(01$PW2L2Ja59z{tt74G1 zh(R8KI_<^@i~(zAl|f~LPYgz)oAb!*)ENsk)(jTC+E5m;Nx${r9I8M+6EMGg)+;bC z!LbBtv*~6J2H};&qqBm%G{EVFNlvtZ=Hjb!g>V2&f)PEHjgpUu(E+0-A9BcGXNuV@ z+Q|h{9L?@9F9pT$cip#2^IsFY0eAQ106>LsTL&8LWdqr~9mq+XN#F~V)*ygEsMWDE z3XT-_0ev<L##Ub3-*6J~IArW%FpReV00FbpcT+aNwC~@9&KEI$QR*<10+9{Hg)TIH z723XaYcN)QZMox;kQ+;3^<p=YaTL>t*AyyJMli4*xWjpkG(tY%^ge_#`IlzFNs|0u zLzhY|1>kqXGtlJ50MsoB=%~z5zQ=}5_$C2Z0;l~1x*n#~hd`uM-$XG|H?V0wfSr52 zR*5-3R)9gV@8B9BAV92hS3?3rmrb&`r9q^AcMsSEiR5oRjBnI6inW&X9Y(DnWgP>< zdfotK5?5h_1g^ugGvU%X4P@mJ`xmA*F_GSiK&ERbaw)bV<ahu%D3mUTKlextdE(NK z2z+h;S#nk(Vxz5{_7VTn>N+XfwaYc%{1D$!)y_$Ne+y>94Ll^)28@Egod7=Ikh|S1 zaF)zA1fiW%VE_bQbENw*WFvMSW2ZtVH{=dKt+nm8RcLTuGFlr>&VyD;TgAOhHof5T zFMOzacFP-j(wKOKodjxO?9ff-22pl8N5d7lCBcVJ$IEAkw^~Yt-yOc8zqgd|47Ou? z18JO~LQxiCZT}ZKM~9o!a-1pSJ)yB*6LZD0Si>^J<B9FcxZeYn1P%vLA_T+KSn`V= zmOUn-!Mrq?EW#ibX%vbtII{qIJtJA=X>o#u@P2whwD)hhQW+LfdAmiF(FB_cw@e7x zy7%9labbmCPCM=*v=72jt)s=z^IP&l2J=id1A8aQS1<r;#(27eiEc=6#PhxljB4^# zL?&yRVHm9<i>%}%of)Nl<EL-~8|}`|fcKV#mv7M9i{DzoFFy}i?kfHgIu!Y%fxe}F z{tUn$55<swj_I|zahJb~#&DUiMHGOASk#_Xn;M#OpRrv$-~{7YEEFEri#xo{uMQTZ zWk!n8%Dy9JtwM&XI}*%?1rozn=a`kEV^PZ>6#cTqsv%{GO?nQF`~U=_iK@%F&GOU; KqXEtU0000WMF=tg literal 308434 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]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�~sAIJ<BC~Bh?UUP)Z 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%0DR1Nfloq*_P8Ns`!iUz`FCNM6IC@Ws{MR6yY z)Ri_w<rUvLfSYR=G5ErW35H^JnKc49Y_Ld@pXX>rNW^m~MFe;{l{H<w7b(+M%Vb(4 z3E=Zn!B+7a3rDS|a<NYCE}gEW9x5(}HLPi~yJof&kxZd<fhP1Y?Ah~*F`AdXSf(^5 zt@FIENW6^s^WppJ&?uTKT1wGVTIo!R*k?;hm|!p)ENODz$NNGgvul`ffYUlIF}Zga zXGf9d|3^*K=+!fZUkUlM@bTl3ubbX#$r%wVB0@?Jp?U9nZGijcj)XlODf#=#ma|KI z;q;u0b-RL_6$)4yKI}K_Qd||UKYsAiS~98w6Fk8#CSfDpNp3!XzsBLGwm{R~<r({V z91(pvekkJYCFvDHVnX)99suW9&3_jjUI-xRARDO0oW`})quLIh`Waz?fY8))Fv43? z?f9q!06C9V7Coh4KiM$j_yW4r<*$8AT>xW7iXx~8r^(suTE$k&7f(o|pn@ZEK)Ds3 zwBK^WubS?beE9%8%b4UT`2X)&Q1Ljc9zCYY4AU5@#c)?~uw-cw#JiVoz97fFh-ZB^ z#Bmkt*Io4|w>VB<GYt~5*<HO8Al2lQZj9b72~wTkX-%8C+A8A+y&fCRVj+NfsbDPL zy#%)Fnb9pwj>xVbWD4#PMZiUHIw{(TYK?Po!Q<3E5iflGyhq*n4$ne|8a}un>wg}f zt}>yrg-zkv5xLL1ZxZa4*!lgp#BzInVc^#uJldT7DJ8cu#Zcr;<tijBy^@OsNs^|y z#?*RADij8@=IVY8IbXb~1+#NVTanTe1zn)DzlRxSLRpc9OZ$)iT8b>;DydZHJl*JW zJl254h@k4&M*UcsrgUTz1f~C1^>j+!%`h0qWZ*bEGi*CAwcrgULUHu5(!qvPrBFM9 z{r4(blL&F_Qr4CA6*t$K<It+p#wa)kAm&r0KP<4kP}rJ~5+O_OqO<CTjdW>#(Wu-H zXj?yFXnAKZgG9WuTQgIH=Sua{lF0@xXO6w7xc8&t+cm0{Hs>z4KS>UZDaX37H7UwN z+nbh0AWlO-Z>I3mGD=IgmI#RZ4?^LFZb{)*Fn~jAlj#};Bv?8u?K>vbmZ+M1DmByd zd0mDE%2PZwRKjzjclQ-x1ytUNHRhjV?4|8{Ckqt9dT&$Qk3|AE8`;W$0$$<xtoW}A z5k8?nVVziIFt0W($3Xt~_#J}+IZjbLtCqP-DfEZ*3%yQ1T${F!W(QTrY%;KXCkFmB zkB8WNpA*+s08qTYzM{1w&Q9NFp3jcQ`n8559qIfvH+1-u;04#jQHJIfL~v!>Z#_Kx z`rL+%*eW~%-8L(gWk9;MZLtjmh3i9hbQF8cwl7hkVu;0IbmpqJR=qQV3aaUJZi&}w zM#nPA0+Q3PRME>YkS+~ud`4~~!(9;!!L3`2exV>)JIzzKjTIxr<7GrEJQwo-SYBfi zSca(v>SrrSJ@5BL#JL;4=R14BViH7iDYVQXXihjQUvu)pD6uJVJuAKA0M}lSw{|26 z=;TjVm)g*Q3DAN6B}{rD+9BIn;;1KwLvqN@#7?pDKIgKMJ8*keDsVy};@pNCDKW)d z4b+0{yKMQjy=$P~l#ZN8sgft*AJIU}rO#kKl+C(-NZKf!IGE-V(>tOVy}yqob=cOu z_d(Idv_Ax67X=Zs8f63;0j2LnT*wCGz3fV|Ta6Mp*^_ZULfYQ@zF(PBAo+}9yu#CV z<FZ-rOyAH;_h0NJcF%rBYf0Y}KeSCk)mVs7GcJON$9beApaWQjJwE^|tcwRs%IOpq zfJJ#b0r<{0I^~f!=mauuR91Fo;z!d8(;e0<9ixfXX231lImOyENZKo?Z^Mqg<N7Ht zux4>FJ#vug@fE~i*2kQ2*fcNga{{jD7eYaQBT$7YvW6|_X<{(HJU`f~a5?fWB=FvW z(js5&79@z-Ug&wNQs#;CJ=_i$NY}X=>@NGx-+PGQZpxAZ-FkB4CLG|T3#+lXhe&jF zumZv3M>kQooA6cBU4Du{Xh%Mbs<jad&byH2V^p7Du%;cnjd3??=Q&1o5mPa)<v~&L z5C>bEv#n^cBz2r=hMYV+RSbpHmP~l}PTS|tukXP1NpRx(xpU2`+#V9GW8e*xA^Igy zjW82zS(cE*$H98#CPc?SGG2O?G1{g?DX%KD(13usx$6v5QT{WZ$BUpe%#5{hu?4<+ zXt7T|La#+GcTuLs5zvwuWBsH9WuyF-3Fpe&4^2ewj^=UP(k7D5P<>BfH-m_&ZRkZp z@BDNu`QxViBY!oT_N=?N0vBqOFq`VgtEtF*1eurngm>0TZ&F?cM8utD^wJ-><hVv% z%!;iV18Qy(;ZED6Cn&NaF4vq;uh%NHhs}?5=}Y-6K4c@cUgF2lDHrBU0q|Dd6^N@k z6czLXFvKQ&?D;U2t4NN^zl!<+2&by!89>sKW-w9^fv3%qK^0Ag=?3L@?;IB4BY1No z8LNs?o_Xi44g+oY-)XO-MQJOnE_&W?2a=bcVvot$8^Qfj7e7@qICh)p*l7{uLs5bc z?Tzs6_N9xlK1uVBbQ1N7JkuAL8`9>FI7sScwk-*yvY~B4sd~-gmj!Ol!}qlO)l6u7 zUAfah)#p8-OeC&`MsPTup!$E<9#Ci%bKWkF+2Rr*#eMqZ?M4jX<*5ZG;w_l(mEgGX z>C;sF`gGlMfuObr0aF>=^zG#OH#Knh#ABdrR&fjzDEJ~(CEp#<rKOu&=#>P%!}>IJ zgz4EQkt^vhcySqqmkV3{MRfe65cPiV(OYq$ahyXee%l1eFIDp4QWxdc5H=-)E!XK% zcB#bmtm;Q|1$&8$&J9G0hJtlocSQBlV%cmbf`K2jcZ}lu0ZkZ#k0yy}(K@@k3+-P? zS{0fe3n1UGU*!%AGwc-E8WXimOt?BP6wW98t9$AajpA<>`}V0U7piH&(1vPMf+>5j z)P5_$E_R>b2e~#WfkQv^GN&W;c-3u}F(_9!?)BDL;~ccp!4Ofc@TAb2R*fZyDg1u# z+*&f!zK<SDwM^pb3UQwl58l;X1&hGNa^{vTzuj%OC1(RvYgkdxqE%z<2y;a+o+_Dc z!~Wj;ap)F_VE>fP7cM*Q`Tj0n{n$LHb@zMSYiRue7N^0KIDnKbQI-eqn;Macz+blS ziJ(xe#w|vo#j2jFIk+SEuWY5W3vB0%dz+Cp&#mXg-A01k4FNRW6{wF<*yAefc;FZ@ z1FvV6-NJczfIlMA5Wm5vmw4T=UY!+Jz}bk`5LcwxP-|Ii*RI}KVS{t;Up=zD3|4*x zZ~}>bdF;qp!#d?^6|^U<Jk=X?fpGZJ+@opHf|5)_;m}=J%D%lhLJcbU(69^q{<=US z?xvml?ro7?r><KY(E!G?+tu>+p-=9|+;Y-=Anj+0s|IUIeulu!yl5CLna68EIzi@9 zmfN)zym<a0vTppL^kf6i@5a2{F1YWkXLF2o3Bj+}4PxG(n1WUxd1=p%#$~%(Vq?he z$GQBwpAE3n{(uo#>L}Qtg}q30;Soh(rG$4dcDq(-A%f#kBoo@8Hi!Yke+&MxR9>M< zFPeJYA9h8OGuY|MtB1Vce%3|?kQpA`Fta_hK$S>;TL2o#GYjG4M5pes2z$N-QJJ=h z54X@t|0C;&d-c>AU@eJ`@{KOKv%0Y3BQMX4knf!_$;FQzMvCyaIL9wLG&P6JaiXv# zfVYQX1UoAb{XiP4@|BBagRe2_W<k?F8eF8#stuUU#$?`p+b;3u-A>SJlo~}*a^*Wb zP@bo3Y#rT8+qqB7_~|^)LRq(y$UX&Ungo?i39OYI8|0I%z3q4B-;E8IznJrZG&0iu zzZUf`@a(>Ea52Rv{MLmZfFzehvHKVLG)It1CpJ>sc3yPC>pxIZpEb~&xZZBj9R9!E zQ1<k4fsQL(X5;D&yW;>}j1pNv1)^wh{1&~>MeTQ?s0|M^1jXGl1`|>Dz4weIJU9-$ zpl}y*<~6dt@<9^1mpf|rTQ~Rc=tTfW_?ks|6+8(S2>mz&8Q+ns*lq|H0~67hlHE<( zPzWK8wM7LfHnqQ69QC`Thn^^{jue8sGIzD09p8b7fOz{)`V6Z*J$p%Ym3Q@dj{4?Z z8cJJlkNR+_cIGo7WUfvBP8Y)m1Aydn7((0}$PxHFN=&pzPwzg3bl5G`&$L`7b|yc~ z1>RCaW2lF?m9%c5)pZ&mpwEdudQ!SFb~qy+aU!)Tzd>W?-@JAyhdM<}2I%evNB=ju zq1w{9$YeZJ*BJR;xChRMnl_zjXL41G3{1gyUzeWJMZQ`e5-3p`!3y%ajNKL+z7sq$ zLVe?PI_6Jz6FMZbBLb-tYH+Cd0jzbf_(TXVMO_Eb<?lM^^Uql2mLQboD<UqZyy3}| z;IBPVEMqqthBeczJlPj~ZgB4tn58i-_835NO<IAn;?J3@fB<vRI&k4P6n!Wt>pG2* zjWot~8EshxQVCGg|NZCUKmT8g(bM_<VZPe#AMm{Ux1Aq_n{|&Udiw?iKeeu|k*wau z@K-9rZyn&FuFLEDidlpxwE<<F_#2uBNUm#}$>RfEu{zf@xj<6a$>qN2ydp1Fb|-bc zJct#-+4)Z&5__qw`%5P{4O?O<YsuI23%el|gEQn7nf!|ymp>D@)_sqP+Ja4BFr)wU z(1LzO!}Np%7oypOt{7aMcyCx)^1~Cx;RuOjfXqm)34#tw>08%=D?j*Rtc>7io{Gn8 z3KXCESa>)bY__+4faMnj);cQ@S|B55Wo6%GvyES1J-9kIPzNZc@xy6aF#$(v&fzz9 z@j1_?ncpLgqjD0s&eU()?U0e@p7n2zSqN8cKa(fKm|bdE!~OdPB;VWjN7K3#2QF7; zA0a*E8m{LlhfOsUkyPhoCNMk}O2U;XL=MY-ZNiPq8wI9lvu;#d{a-8cw|&Ft-s1an zM}?#6U3y|$7x)?L_>W1DTM{Ap_V47nFjH>qh#wQMtuw8hh#Q4pDWb8gNIeY3o}Xuc zYYSw<8sdXX<99;+oO<8+6IwGCrV`HBkrL!)w*~XxuZ~$adXnZ_i209NSVr~r(Bt~J z4;Q$cF4F`I2NBK)fq1DIu2wvd?Kj?xM$r#jrp3;++~MwD1JX-yoSh^C5XEY_E9nG@ zS3)-SmKU9sy1o*#T@tI;s9PkX)Qpl4vL@ooEv7fUE}ON>nx8;h4w@%&hh#OP_Obbf zqD`o1#Zt1HI$3KfSK0@5xV5<_D4e*hQTF~G5CcI~ZQA{04fA2Wc35Y#o*d0ABvbZX zb>SMO*V)h`koiv0l@*yU?RhPg;NGdS+F**35oLeO_6Fn@0haTvx}icq<4CEZO%q^` z4o`vlk`?4}aKr}iDLl@u)6*%2eSnJ|4YwbeUY}UF|4GlOX~PE!2LzI(KR=cZHcdh> zitmHc^Y2Ycy<q9Pq5&C8nHvcBe&1N^mCn!lo|Nb>ZIUOs2g}!F+;5~lslY`J;h)47 zW{Gpef-bXM-~oH|x*jhoD~KyFHg`-UtwC0yY57W;#%avcUif5m4c0d){TX=c`Whkf zVWp*v&$P;z!okaMnZ8@Jecph}y)0Ep^AjtEXG3wGnqu@Un&+4ooGb3aDs_8?c~#u~ zP73cf>M>9X-B1iE9tY`2P8Fg14nrlpaP30RXLzQgVWx;CGLD5<-(&j_2gHxSUTKn; zpOR5cIkx+lYaJUzkc8X#r#V@&@T!BSCojgO$a8aBBzX7fUy8@gj_AKU3#$sx)7=Q( zpT9H*Cz|Kg@JSa-9%fr$dR>Ru%%NsyhDhZ60WD}O90|;$Zs+ez=C-b>RdTi5|3Nfv zFv2}#OP@me^3Ma3k$Fwodmuo5Jud}<c&J_odMgG?V!&I1o_T6J7dKJegp%FC;Pb9L z&bA44FKR&7iyyd_rZs6f%p^*u8IA9SnaduJ#0<guS*u=Kz1ppOsM!mGYd$2$n&6Ql zP>bUxR1h9gE`oR?7iO2I-jWQXH&Os)FJexXbCoj9kuDs+@%hkRZbGt_cZ8B>)Rt^S z_`9CT#-Y(_UIq2#$O5P+Yjom2iBxEj8R#+(C@TT@WH&r5<Y%KCo~G66@=x&hz`j(B z%k?Fgn51sft0r>IYgQqv^G>-X4FU+)A6<JBZ)<MPC#^oltmn1DFoRBo*R><BILAqw zvLgr^*HkHoBeb+|jcUZ*DDlnN_afAc086|JV@u6{hkmNG6VpTp?&xe$=xJTWd$h?X zqkXVNzLHt3Z<gl<1B_kpb$D%>$4p5avDu_jc6MyET=Z1m7@Ez%m|sTic45ACz8Th3 zZO15nC~D^-8S=XfMW|LE?>X64CmV(tZHaQv$a2dW&w;?KjdDLcZdC#$*-ut)jc`{x zaywV(xb5jWLI&bj(3ZUGZBxB-mr!rNH_OWh>#X7%Lp)|@FYO|wswc?)r2nmX^pebO zGs#+I?YME;qy+~vNSnh0YoJ~~Wn$B8N4{J=%ejhoiR>+p_8WFsyl-|KK2r;PDjt=6 zD@M2+aN!bjJDx%<^%p~%g#Wzwz`~$#uR@&e_*=Y5+jREZ5Z2SrkuW24-uE_?rvuQ4 z7LZitOj7R1U$t!}y&<IAlQ8Z}J>b!x15pvpk6ELjo_gr|N%ie(TPX$S&Rn)KZ&H~M zca3~?Bj1&}^}ds8(UI0C*L!Rui>S0tlSE3Gn&yBr9&d%m)V1eXrrjQJR5@yY`@xCl zM>K3+FB&GpoJL-ba*gFE`B9_^wWB9i>5e`H-W7*v@InvlNjjCjicUuCBL8>i0jH$c z!MI<_M(>EpGh3BdEL6++>`GbH{gD)g?MESv)6hRV#AY?`TXI1OvH_HlM^*jO!HJ&K zy2r>tl&dnO*9z03zTVPQ5lMq+K}`P>sj4;zD5~<Dt^ph62xvi{8&Jqi(FaGROxYJf zGau3cH+0KTSReRMl>X}*Iz9$p5_;^auch|dk{4Y2g{Mw-lBo=cWM_8*)d0DaZt>GR z`q!+hlmXZIq4B#V-t;^9Tjp4m$=jF4=a;a7KT4eXemh~Yu+c(}$i&Mz)qHbdKty?c zaYrFZ=k(+$Vfu)=VF9plWy}%v#1q9k8|BGqA2hXock$&NJl^RQg@A0))H2rr7ZHc! z?)L99lzi=vAYnuj`|M*oIiHS9cN|xVR)xwJw0ogWI~+9N567idAL9dU&#!&4UuSwt zaMQ1^*170xT+A5XY1t)<s!<{iuWM-qCh}&^8MQ&2z_f1tk0>s)2r>XqFFny9_wcP6 zvIZf~Q4bAFQH-Vy2Om6f+cz>0IRzfq8g6Ydz_;c#rMwd!U_X!VQa^5k+al)Q3fYXm zN9i!%m1XA>(pP#`DH;()QzN!3r9)7U6G(Nq{&CS5A3|iz^4ntdNYnN<vqcF)MP^9H z+hjpyn9T9}n*vEsK}SglQ)ZLDQW!{4NABz{`23GnjNR4ZBs_6qr*VcAVCL2B9zyDh z49H9TEkp_=^CnZ%Ep6fUHEj>FqL4a6SEef?&0w&+qFCaBfQ)_#GE^$6LN)CGmgx`2 zrIm4`F!e6->|R~)MCCVK!iM>28Gbj3P!*8J!?+NCaU!nSwfo9V-_;GG&dAcYW(Lu8 zdYs;$TfNb7_@>PI#}>fbZN);S!<VwBOld^X;2<jZ9(D~}1R1k9nK4|1^8~Ilr)s1s z*#hN7Cbsd7f^^GJ<g)$*t7V(&`;)K`?5X?g@rKikw;zl?;}=e!HNH|n@~KHV9=%Cr zE^;=9-k+udk8?{P(N8}wqr>rN)&sO5R0Pg_rCg962bRMji^CQgT`d%XVeUA;mt+l& zb>S4PU6jCC?KlQ}KjkBWO3~2)pfrhR?{orgR>}V=*&nBEOplJOXSvjUnJ~kzBg4uq zK{PcD`5RBvn34|rsD{sJu)a;P?IXbOi5!43uKKWa5Bg^zVgr;RyFdFM6cx?-zWGGP z`9{D3oi$Fl@~|`p7{%Y#wYAP{L)_W(iqis-Lf{;SviR;niv*M!WiQYDrD9HX_siO` z_Ayz@aIa0_<Yx-{>QbY>C4f`74R(yTvuG9F&*I~TaEa)Oc9+U_)~))NeETP*mv=&i z=(2Ct{G-6VVX5i+Z#31!$hS2Rf3?d9b?7(>Sf5G3pzjqiJE)JZ@lUuLbM9Z+&XQJ- zO7?fYyfF%6W$Yqmr|2#40t`b+TW1_pUs<RZBsQ?~C4D5^hNbF;QlUe%B=Bn9JHOs^ zH_VWQN8}mTG%bOK1UL}Ycezpt>8laR%<3x|JU?jdoeR7SLVN0C1&Jn%j@b*rYX&{} z;?Z+ey}8RU>$Uk#|34k>oy=gC59h-6<G`VJ0Y31Nx><P6qwspFUsUQ~`LtKE{Lb^U zh;-L1F#JrH+>Vq95;CI*7v8ZyIX0T(FTvQ^T_@i5lQvj|9L3|V;my5Qu!`b}<?pNO z6hWBSz<^xq6e+{y3bWo0wc%3B_1O@cJ=-jTwC|_idnjnTdOKt6yN{;qN@6+@AjPZ( z?J0E%pAyU_Gdhq5fk9<a*xgW%@_U1Qqa>}X4`KAt85V!-`<puEB+ZWwbHl!d>+k`? z8?AoB_&QNqz!(rsn0-JybVW|D>=W3>BZh@K3wXsvxv6FsLyl~~3DKn|o?}b`$!2`O zU1%|wX!&;&asX<jX3C+^EqpA)@~br4dyA?u%2{0`+7(PQu*WQse^KTuTA2R}zwQvz zUpvUv!zpH-5BN;cV_UQ4Rc{rk=<&_YSRqYIq>Y#vZ#DIvq8b~P6E`}*#7r0Ft<s%W z?0|HaQcPUDlgzfXiZ=VZX){J<OC$0H`&pV34`QllftCw{qBThid>q^dt~{_RHyqH< zHA7|45%Itjt+o8~bWHtkMGcinzuKF?@g1t1Q1ti9M01c+#DtD2W#HUB4;+RRjM@({ zwblcOl78fb8Zg7~!<#(D?tc;tf(#G4CTMsEgRkr^L2%yrj$uIMN|Da0=*?jZ2&s#% z-=b?m0{M-pp(eyPpdHRr3uL8fSs7pZdDIGoPsC(^F#8XZ#`RJ)H-j<rYkom#CBzoQ zP#;Pwq~HJ*2D>`;O2~X`(@MLKTFf!mbqGTQkc*l|UcKyo;fMUVteG&ySwp1T&W`U{ zCEt0HUTvmALnRI9eN+esR1zc>yxTh4_-D3&ws*3Jlh$US;x3ehsaf`0$^SLdzG*SX zi#8)B^i^Lg!yy|d>+^-I0Uf6yYW==I8e_c);86wA%$IypfysjvvyidpZwNRYZA`Cq zD+jDpok3Px6>;LfhjzleeNULb%DgRtQ*elb(m;~Gt8<IPVPuHE(LCuT!m=raNV(&; z8S9_rc{Ax2ilyWBf#3(&z`k7MM8E)}VUcOFv82L4*NKUxnlS0FzFO0jLp*f1k&eFg zE=7!H$e|FS@3!8I|2yn%^}PGzq<fc^Zm|*1x|L4{!AK@>)hQvQ-`o&f4#flB{ON&1 zpD-Z+_YWkOIeN4n8KSOKY5Qh0E`w;YWs6V8bE?!?H`j7Ni<2<DLHsv2EPuBaRqC>L z1?xMboXgE3Rn6#BNEVbMC^8hpVmfR<3QFh<ydNpHSD2oT1C2Ic1Dr;Q{$)Tx9n(KC zrx4q`762U6JoFM22bm8l5VoOGN$-+D`vSyXXLWKXWsuWRpK^$z-8g^ki;#$^gH~O2 zFm-=fOK=_oFXi8fP<mwq;xBeyV;wV%VG43VG1UHV`B|d+*yrZ?F{M)mhA2?}5yc4$ z-0BFl17Bv(lnqLR!SHH|7G?xkpz>9(d~5cREknzDn!<D}_}||)M<}Kwq>yo37inTa z;unPVqM;8*H7pA|9b)8n-1D=|3a~aGpbm`m?~Zd-ISQ#UJ10kz_TvVB+WzQsqW>!4 z60+5M;!gi)u0x6@1C&2)E_;&qKC+&$4YaB}0y%aPqs^E10FCyA)4-4HyIwbNk4b^~ zA=U*7(0xtNLUsV)JZbxb$hr!+q8rB;wmrr4O_=x#4O3rX)nyV+76i`_nho^<Om$ib z(Yz@T)0MxjP<2KW9`039i7h#^Q!FRltC!^ec&?iYKGh{&*Lt6)my;|tLzz|L)#=BK z;&+$OqRhOK5w8!Cs4Ry!*u1|TyAS9l?)~X`>4Ls}$NW_5Z9dXrNW>}SdE?b<>MIEI zwQI=@4Q*#%-tX(*imWM3G=r<J;b_V4A&QdW)@@*8YVM{CiI{m~sUd#hl}<|l@Dz+g z8@zF)wF&VO=`dMdsWqHh38W{LNjAwFakTI?q&r|LJ$->m#Z<$(RUbAzFav9zAsGZB z#92ALllaN?#qJqmYqc-Q>?*>aXS3F(=o7ZRY6jSDm0<kJIW&rfrxZug75I=p0cPDf zH7rCOTY!4VLbiXQCk!OdvLi-e+}!YQ^@$up-O-z4hG~fD1{M(xg#3yUGAWNU)dqM- z7E!^gRE=g-?I<+5CV{J_3gttyXGHy0ddDPZZWZA3r_a<j5_RaWcWw6aO$R59fns)t zPyTwYzB&k;tcn&ZJ{|5kG|5yRe&}8At{j0QRNp<#raWFlM9cfpmOckx#ynA??Ze+* zEZ|0l5>IlD)I1AqdZXTIT+F>h0}|nBqajA7x?;Uw+ogF_`J_tAh0p_r;1>EfETlMk zslY7!FlLM{-_T}2ZgY({Ym0_0<afIOgy5?DT*me5cpuyTsTNl+!Lc~?Z)aX+dBOO2 zGJKVCO5Cdqw%RU~;Q-4pXZ}jN_&%-s)$jTo#DH^acAy^~YCgk>9@li)dti>Ts<(YC z>6>tl@{Dn44fYhLEo==~w+IzheIY$5NDZp6*-Iz^P+Fq1_L%N=ov$aHV;BpxPDLF# znK=eZIMi&d!3g+{5eU1Kq(<!<MQ_(U-s`KoE}WTT=ZE*Zv-nTNd`+B|JI>|q4dzb1 zGzctUc(2d(GTC6lG%mZ@7^MW>_z3D_`66o8HM2-_%}|)3G|6YvKuGC?YD0l#!L~>W z+}ssPm2^hzVB+-{`44D<L$vl_p5lH`SnfX!TB<W36Wz~RYcEhE_V1330fvBVs1k<U ztCr*f@xG`uCcp2MCf+Aya4y<iQ&^z$FE2b!+y|~N6-?<|i!9tDDvTQjIVdb2@Ad=L zwv7&;Lm_LR<}$RmVF6aFNm&o;C>alxUPCTE<2hY}S~lZdXC<e33EmWG-54}h&ObDi zw3o2oI$YlP&e^z*b#++kFmSY>v7AsFIwFR&%cuMp4>-=l4<AhQw}p5=o)g03YN#lY z&zbniHpWSfp6vb4sbo3+0*%VCj#9Lqm6{S53+!A7f>ym2>*W*aDlGh$9AGK691&gJ zia^`=vat)n{|cKNj`YW*x7MBYB`qcr?rM|>rrLk=uwIH+@^Bs!YCJeUK8Z;X4=3?B zHkjLBr<<JZ1DK_LR-#oRi&fobw`mgYS4rmh6MOrNp59W=7@-UL39742px&}S%#93r z*k}7UjfA0<y+OMeUH`Yc2ExDXG3s1F-e63_+7Q2H_cU~|i@7EsB*7w4Sg;Q3k$-zn z$)CS6A76e`$6QU`5xri)9O-B;A}z?|J^!B^8vmarB~D5R_I0k?Tdtv7hf+lLG7v4O z@?68svonVR$kHFSGPNa9#{<Nzkr-K&90S%=JT=B6P;Qc`cq4X&?SI12i-926xxhN} z;Y;9d%pafhC0a0q>bB{Vt@0qs9%Py=i(mccW?pp!R1qv4w%iy5LufC0%kho7p5b}O zUp+RcI<SKC=9m$Xi#FdE(ig;thKVDk&iH~ZzjxHWrlL~IvikEtvghb|bDbnoJhyzc zcY|&YBY=*9b?m(lIFSQ@Usi*Y*uh8BZ?ygzAiC?O{(LZMBYf4K33z+Or#Z5f0~C%- zj>qP~>dVS2eOl)j3I19fdMQ&Y`l61^|0f$O8s&XYV6w8JlItu?4N2?GU?mJmOl%Ix zKVTCUcAg?P<L?(9m%$)RnA5P3wy$yX$CEfWx;H6nF?gYzt#geW3Zlc-&sZ;Sh$q6T zqQnKz0sYc*Hr_LwAY8i}=~K0pwxq@d!5vuB+!fBm5DgxP-M=F^nhc^e1;UW@NH1Lw z|3T8jalm?2B~yA$Z{}q*rMxrZ|2OXL_4bc-u*f&8a`kip{V8m`NnXkWMS1nLkznYi zj8jjiYd+h333WHhYRA@dAWG$`nvN_VEvh>LUj-ng`Oa8SApg*1;OatN>^Zesra~V7 z^&!A!_<yfT;4qG_mc2d8ncwRuEy?=D3!!WZFJ!9h-Fh<>Au8*PcE~U3p7a3UT1`BB z>MNy49EF{hiK4+dP||w|+~-ZZm~arPZ>7w2*WnU@fWBoyUm;F${T59Ys(r4}U*%ys zqIU@wOlP~CV7?L}Nl7M{Ck&)to6BJV7Er;s%(4}n6zNPZf@PLR)-Eg1Dr|Jnoj$AP zsTcK)-WxAOT`jKV9FIWeAc#d{;iI@%l4E0)4)ias$U~I?L+6bjTG)NnNC4ylIJuZW z>b1o=(ex1hii(5f6{a9QaZ+PEf))YuWwWME^koPD7HMORkXOVt<F8Bh&(DP-6=tWv z)dH)?#;C0f?$6OCZ4y$uv%1T}Uj;o((yMX$B|wT~>bk|o_WRZpY)Pr8wV5NhpNktK zcxE7-ZEOtXsD+;OV`5<))O@97CNB`9tBmxIS7iI(axUhrUP8<yNy&x#Juu+|QT~2( zpqI26`cbhTc#7t5u|F0GEupKNN)v!(E}q^$<!l+0KiPoX+oa<#R&~79-I)vmPv1VT z3bdi4n138fO^w=Zoe;M`c`tUIkzj)#bh97je#?WnC95r-nHqMGlcG3gp#o{oLm3Q* z?Ydr$XkooE$9gw%VDq;NeDp7ClL$8;j1wdM@dT!?&wRC8xpi`2actyl_$U_HX)#bM zn)@SchVOwy7i@f()yEEH$-o@CYvMHP^4``4uuP%-$T{GaH&^$kMS8HGw9+vVB!TGu zMzK(fkNEDM;@=@4aUhYqo#~L#kwZsSYXvr%KfQ^s{M>{`!gzhFdOQH$k(%uyM1{(d z7dU8uu;9y%5LZd=WC+9mzOD|NXwM&L@b@Yi^f&2!KDYXamQgmM5g<EJ7Og#cCG310 zt)e<S3=_N}`YEx9M_^5<R1&m~R6Nur<X|s8Tzx$5i2$^Vh2SbUW0Sd=&fz)wC;`8i zZCBRtlNB!V4IW<cb2YE=JE9~%inC25#!(Np9KCMoUYwyNdMqb78^l*g7<V~L`e`G? zNOP{wJ#cXB(oKW(8z8{MR4yuvF*cEt)yEqC&HQE|x1+6j6qFgpX2OapkP*0GUrcnf zTx(HXRECzB+GvU|Tut|KF^GTQAW|!&et%2oG&A(FzP$ADw7w%(7-d~S-tLW;CTwW2 z(qSLr$OuBXhl3tOi|z#sm284%TgRi#7G-OC36pDJH^k^d)Y<+Wfuynn5W=;0wPS*G zFi@XI1HE9c!4b9}rTz)73w^lf&H2Nspd9Y~*N9sSdOy)g;1e*MAq5r@v*b=moz)&@ z2y<MgP@N1A8u0dv(azEht4Baon4HA_l3C?P8Brj4WbG_GGSYlW_&`7rBG-JXN3nCC zMewgQ(h!JrjFN*lBh92HUvHk2ci<LSTwkjhoCO)IJ0*s(6}wtGa29cJz>8Ek!)2&$ z%4#vv(Dx?Sbu%tjCxvpwR^=1ci?UH}G-Ja?ubRjM_)1ajb3N@^Lcm!kzG;l`v8yl< zY=WOTil3+3Q$&sbHhW&X)nm&0oV668?)VWejprqK3!%VIzO7ZIo9xFr;zk{@@b}r& zkU|&D0Q;=%n|3f}h7j1-jb@wodfU62EiAmfo~K17akK%KBBQOVYVT3A)9Joq;C$FO z4ae1!lPq*UGsTY9YBx<iNBE2+o;SK6VX9^_sR6W{O6NR6(wyEfI4CFGmQj5<fhLQM z%At*!Ok<OhoAlcPfjcfW>jfh*z>{W=#0LunY`K-gV_8{x|MY`nd5^pX%@tdRkC$zO zn2jt1yS8$ey%Fh9261QM#DryJYx&xxj<fHUc4Hk9!+BF?6%Rl&lyB!!niasks_0@l zX@27zfEHc~u6lXT@ohE}@60iLk+Z;8y!L6DSl(>@H4~Ho#Y*L)D~G763xm+WBc(8Q z1UD!JFEOFMvX+9?DPvdQXBj32dkDknfMWZbh2|bD9{9DPi<1;OWH@h22HoAo6Z|nQ zT)6Ip(($Ap!x(;I!g}df*1e*SF35+_i(7?}O9rn%({cUCW%LroXbibh=zIzsmA@Im zly0snb&eY|bjnw-YV8sMdIt<Q@gv*R0iq<s9YJdO4QHR*{V!Qkb6#!jb%!%9X<%t* zznJ8KMkosim<u@VG=df`W6NDIF+<_f(|Pm|-xIl^fO2MkxA-5P@(iZh%?K075?aNa z-aB<q9Elih3n1UxqmBd)Y-#f4{FZ7P?Oo5rhlCWzQt?<at#a)4@bcS*3OAZ94rS+) zI-6FM^@I91!rVX|eAf&)#FZj4(jF3JOmDX%JeB-lWYLcx$CPk!TF6W^WkI;c*`|5P zY3r|WBc;|dZv}Ik+%$kIfY7%aOijhnmj`V&XEc+7Syw2p_u9Ul-lH+>xAo<JjQ$wZ zs#c75ewv*$)Yl0_Z9S@%+0YSyTjx#6ySqP4JyW=2GifO#?}pHk$NLEStn_DBHyp!J zsyz+a-s?vyY=c;V?IONstsp=DTl-IzfWtL|rGSz-*hge(&**M*YwGw1_8p8vVm>JY z>WZ_WX%0e*uX;6!+sFz$=TB6bIOCedvlYQ^-<q%Jf#gogi9G!Ho%2NBm?MoC*QCND zN{U1-PtTM;tX~@$ZHD;yp>I&tUP7J_u^e|;SlfCnw_=6*&p1)w=!ZdHEiCB#d`S{j zry9DXh%sKsMaGm8o3T6?0!s|V^Zi_GkynspVEHp(;xz_T!65<zGyjGl#exnb#h-Q^ zt)vZc8VLkBA>$4-#yay)OqJ4wVFlN%n?~^0=z8FcV+9O*ry}<tC<p)6+)TMj28u#! zj8jNM8-U6ZB7~z#U?>z=ekeejbgExW%v>*tYlO$8!C=(l<|8TZdPu@n7JnX&qRl)h z>5!;Rk8biyA^9rqdgmVQ3g)tqe#$VXj~FPy!pT6^Q3)FoXtu7rQ{d^cL|lXRMg{^> zv;f|(vuS@g!1_Jc;sp_M2O8by+hAF*CBS}>V2#WFE}MlI&dYu*!^w9>r^U~rlCf1& zW5Fh`gFg*1W$F?7B8DxzSKm?i0x$r6knxZj-__qcdieIXx`;%eAs$3+-x{>H|NZ|J zhC7UYCtd_hn&MU*)Le*vVlw@uWu6+aRU#G5MDDK=)2JFx$SVRFq?-0)0}3{)(YZ$A zH)>qG>sL3tNOhqkE!5Hw^&#rDB?=A70!Y*B4CAzau%oDfNW#dv!Lsugyg*~84qj7w z9EG2yM_Ci;*E;UYm1x@yImMwQa<v)2LLvX$LF^h(*(2nj-&MEACEfjYoh^8;sD}P( z@z|(?R~WWOKag$k`i&aO-vw5rQK;VCLSU6ZZEF6B^bFSbtralh@c-wHwg3er5N`xO z3FCG0%l^rn9=*Id{Z6}MX=}<OzwmvE`8Re>_3!+l^Hei?aON0bMjF3tPl9QC&8=cv z>IB-n3Iq3mZo7a1ESr!k6Rj-lW6wjL;g|?^T~Sv*Si%L5yz5US>OV6yNHA6?&KXqP z?KdFgQx6Trz?7dvpf;~u!Y!)6p3faD@hd^?PG+nKPt+_4OF*W%Gc6K|YHAVW<M}Ib zCT-_U7!uw$fn4|+4Z<5y5s||q$iMlV^+;`^e)+zLkAM>yEjHP!bf_pES!^>&9#goV z=sRSXwg?x-y$$WA?Xhn&WYG<`7y><h4~k}-##Vt`sD9P}rb3oM{Cs*wS7UxNBLL#c zHv)Yf3(b~-o#JYTWYqA2GjYeWYSFZZ-X;S1X}Msy1q8K$fMwzCPxwKzvV%$Ek3D&< z6WHkw$hS4I55*d9Y65C_xP$f(;5kNNK(2bfBHhTh9~+#u{g%FN+RDxu-iPs8W49O2 zS~;3p*-}rVMO`sMef7@|e9c1DE~v3v4a`WHUILdYg(Qh5#c0mMGqs{}LeEX&phv{; z1<-k397qt8zmYph`-j~3I<w4(=}lYvppkW5+|zPiT=BQ%l;kWbdAkgg`};~h#EVbG zX^K;Og=UP~{;Wy5ZH<Ts2AVr%WJD(ccyCCMT4co)+m1?ib#8a(wUC|&mC_xWU@-k( ze$qDxbS{y7h!?5=_zPyGZno%4*I=9JsTFvI=h3<NB!j}J!7N8_ItoTtCOP47hO%$o zTaBQO$b(KBr>+K0r2Q3HGlZ<7i2Frj=e?_lltWwGqY4R+;q$nhtI0;goAUik3%BA1 zERh#Xo%X#-(3iJ-3cWg)rmyM=pMf;|pT{OD;IR_O14FvNpBFIV$^~<D4gEO>`N*{U zVcqfL#sSjyM)ajWk9746X0YnrTQ4KjO0IJP&CTe==&ZWVfcRc*vU{Nf+{DuI6F^V# zkfrE~y8%SZ0XH^tj7KJbrIy{KxvkcWKn*eur2Sn&*Jgp-Z?bsn4E()j3N~a1E8amY zPgR<*XtwsaQWa!n6Y6IgIW+pTOVXorT#ZUoqJ27K6feYLIgK*0<)e=hepF!JPc@8D z&@4}M-n~!2P|q7fnIG$f80Rh%WnJF0eUs^f#D{V2&zZ!C;~sZ24M7WrqTX*qmXa2S zBDX8ap(;Fa;cR6dUZQj3mEZ11RLJ7=1oTdzT~W2Sze5n?b!Xjt`Y1m+p@hYI9sE@_ z!Za&h(qA!C%AlSLFJMtaDa+z&jog8chTUBfHE#5q2mkV2X)`6e%@v!E@!2WMTBtTG zXnelN`SIVFvq6Kiac#nSrupce=1Fq9$Ar9u8(c1(q3-oQi|TZi5scUvg82b(z0VME z{Qj;cTuwBQVs*aIl~@8AeI@-ifF(Q#Vk^8Dy=(qk<wYcOT*ui}=pdHgzX8c;oyd|F zhPNZgPZuXz56d%ty<41!7|;b3j^f{>qVSgy5y1)8?`eIVz5zd#DSHOCtu?y-N{EnZ z>!I14LaU@!Sllm~8q)0TF$~3|6X3Qj%Z)#Wft{d0aI^7qVcSP<fA}NWJ&^%Y?c(C! z9Yy3X8^pW9PtNK=;D8{XSRCp1O?}OkKh5J~v7wGtMBjWWQD$@LGFbDP2DZ5qMw0!C z2x9veqRbF?GRynj-)8GNcn;%uMUJ&U2{|VYh65kE?Eo3@+6ZxOn~f6Loz+C)L3U;E z1-$Sx+tuQ|ZCr75bVmqLd=UE1b|*pDO^K4})W&E?My+uMED!x&gCWJJW-|TjLqN*! z4YSqZQD7h3^)SoP=K3d$qyY3?ob_i`0sK?-Ll%q_s58_DqYL4AID)Tl*cuCNtyP!n zE48>z{C+Lc#nPbc`x>i0a0^{3M>K(`V33}PqVi5NnKsd-1ywf$HDI|Vz3%*8m(l8F zu9VA*PM8`o)ncA^N=J->X}aCRkchHKRHQ<hvO%WB;<$rgaKKhOje0W8A)2||@mX|W z6SsW$r{99aD^w0J0pfmJ7(uj;3>O)Mg1=Md4~D-?RZeqNAABq3njP980~4LWeqMXT z7Msw>lO3z^@yP}jz$K`0P~73oUYH)~N@5dRf<qn)@}fFB`d8bdQ5hAyS9mEel3nWf z?I|h!yL5F7&X!X{vXiEZBAz$>>%b+--)pkrQMwZ^BS|!=4r|?B^ELiY@q=)io32>; zvVSEJt?ETA_nRveXl5Z=8!>M#a=j!*{)Hvc>tCdU_n6M)$I|RO>?2I9;E>g*89s;) zEW1&ZXBJQNZF^{pDFdiTP_rExJMxbj*noFnR^xO4^)HPZ)V&y0hrg|e;Jpo8?z)Qc z<9R<N*1usMW9un3Bc%E(MNM7c$wfDXWEUB?8yP~;jgQ05+;sfXJszV5rRpIhbed{n zWi@sRKxSp-jaZY7RgMQTbX&pb6_w?58_TsN;fZ_nF782;S`@keE%}{kLC=@_*aAF+ znm3uY2lGNlV5fa+P9lryh6vRXLqLpl>ADy7=b5HnjY)1CpVU^f#<w%}34LQt2jnQ@ zN;;7+49)rh!*X%yEB+2d@dNFz%bz8o&;)%JPNrik{dba}u?>;B240APE!nN}41#)s zR?Py=z35*=&Yd%N2OhtRn4dU0S!EGRsshXC-hG@xS+L2oepILS23LzXP;|h>`PBml zGt6vsq-kl3q%#RWKy{Rms*LdrSzW;X#Fqa-I=2aiBtr6_iYU)=Cz!}6fe}lgW0Hq& zrh^2(@qer|{B~rL%qLI%Oh#yt4&_k*&3+Bu1BO&T8B{!BdPsI93}Ctx?My&wME!G2 z(DJp2iJS*5f-PXHVpY;eXIUw0o%QGc949c_e)>SBlz3<ns=vy8XV8tOBm7Wmx1_%H zS1nc-gqcGvnHp>vUa~A&Wo4YEj}!M8DW)<gvxdHT4#tO1L0goU<?p)4&JxJO#Q;IH zd+vWfn4_#9|E4aZi3Hw?WA0dTSHcX^oS?$AI5-PZiP;AAkFQtw_BYEA>DbU!Um!8` zfRVL9y-2AoAO!6R3Xx+t;mm=oo!V9JR})ajMuOvddVPP{cbhQ!5P9n&bv7)Sl@EU{ zJ3vhyYhHnih()bp{%p#B8G;~R`P#?=N7B|CO9~}e09SL>F>a8O1hi6Wdo+`-=iY}_ zsal?Pn)_jaXXq<DstqzR=oAi*3fGIi9IjLde_v0VdWMURAH~dVy=T~^BS8vByKgmC zeftsax_nGa%KcmdtB_(kiBhusQT`U;G=&{i0uliNVyV}%0jUzyeRNjK0)<z~lY^T% zJAHw+oYrZqTl|P>9A0BPgIIxcE=QnAc=;aJ=DKg~3C5-#6_tG9-)lltKCwo=p0D$p zzwDOtB;YDK@;R>On&jzdAA+Zp)H~=?z^vyN7rh7I)1tbsZ&|ef@ew!Xjz#<1mMepW zGBJ&|HNOF6Yt>wh7UgPNZ4YQbh42=D;HA|64xDuvdb!IH)^w#HvWrrRcNPbNXwlO) zU@yantUNrMH2UG=kiIi#xAukmJVw=vBx<g}6D5P<(iUs)?Q&Fq;*nRTlwuf(!5CxF z-6SLcK0v|0vF!Y5yB*)^%k5C_u}Z^o;zC2T(^BG@E1KKuBh!5`P<3~RC_zz4<T+nz zboY>JMxP&#%izY0`>gkBHr<=hhK8B!_!>=EH9LD(!n1?h{S4mwzB~58*^bXAUXZP3 zJ1_a#x<lrfQABFD7w9HirY%N++YAzqHtMVaTELjU*Vvdk1QLGJUa~t$$=`(EeWy5f zzpFb&w1`v=z<Kug3|_j_A5=p#g^yeePbC{r1&3`gnjlI6P5HODh50qRD&zibE#L@( zsl+R*?EgwG<?@<c)m3L_!)dB>zj1MvP4bgiY^^eoRLFR&9nt>47lnroi(O)XKRBn+ z_Ha;s3%qy*g799-d%VO#wp`_Yw&`gLo!cO{U@n4f_Wa{8K}WezbKdW(_cqIiKxs-4 zI@#7De!zN>%v9?nDb8uHy`@q3AA1c@7^iGe`hsWqC|5Vgyp7llm%)<Z?i#?%xL%v_ zXTwz3DYor&eAPdi2wcI$YkD%K$-9Qlf8?mi-)NwG`DgRqFX}<-5SM=;UT>g)S{+8Y zII|_(7;)~eDW*z@Um2`xFEUn3dH@i@0r=?0S~H~z_4)hf#o`Ll1_G>ik3#?o$EsmX zt?x>(qu{dENE2N$B*%7HYBIBb8)w5gOQ5gcwFoHYPUHB9pPl!OJA;Y?)wMyOjU~{& z!vYpo+AzisMH;EeA)zwdr>-Ow)?d%!01mFg1{Pd}0umB<ur0|Cw9a<oW66iA!ltB@ z1p#Ci1v|U{E#Zg~`hs+me~M`krh-3mf}=iLh6wX19BH9T@H<vCkqExqAZT%~#{d&D zU}is4!4@T?c4MaH*dhs~uu?Z2;f$a}y3iRA7{FeU3~uv^_k*j9fbzQ9D``JYBz9;T z=Sv@QfV-$XvAhYGGRqPA;KBix@+2T!@+Kd>0QKHolBb1skAy&!%%apQ1}xTjSdIyV z&4`Sa&5-WDqDKNIYq|8su%2X>XT(+sL0U{thrC0m_na-aZ80d{se>W#aOW`8Zy)wh zkCA*=dbSN!4KQ1*gN>4<3e6M=a}~q`SFfV8<(h@)#&pB}Nvdm9r_c7iuG#Dlyp^Z_ z&<Hp5PIu$E*`3fXaPce?F(Ek}Vq(i6pS;8G_MIBsR<a|)cM}b9_zT*qQ1^~emI}>D z$sCRT^%tUzKI}6k8$H{d^?Jg3-q06IDke@?O!YHo^0W25VoU%Rxvj-FDdiQS{qnWZ z>q06UHM?MWBLRW=2q0cTP9Zd=Y?M(}6IMgD#bI&-=_6y&mMf-5*=~v`ldt6xfG4rn ze*TV6qj|l`(Mp;tB3@}=g;PaVU=sh_Ec27;3Lj`gT$S-C`5ECJ>eU)(znRQyZhNS& zgn1(S7JWCHr7QD*IpDmO;13kKj#R_s(Ne9yKuq2+pA6+}a3a@a)+4}T8#7)}2Auof z;M(ypz~fLyoo{wD1;5>d3XmKP*ci&+@2V0hd{Ys#))&|R9g?bP@vCx8GlG_`Gi)_z z5ryo!a|cDsE*bWps$@h#<lz~m!c1i)MgY693#HTrwF%@;Y;UnTKByL62iF@p=k+va z-iP>h_CL=WQ`^zb{@e=(E(|79q$O<y_qS>aSp+cF<mMi@;Jr^IC<p2a0|39w@plNc z;w+=nYB~NUaOT1(2~wAy4dxJXlJXoaG6yYF8Ox6}Y@@ufK0Nm@*NX!`hX`WQ3_mK* z&bi^}UFryuX`;J6c`=|~v>e`QwTE=bU&I}3<o7Hu#TIS~e9<K3+%0kDh~C^!HqY=V z59#-zP;@6@AUK`RGJ|q%E7T`YjGL44Net~HYX5BTc%3K0EEno%3i}*o4lT2$gz7o5 z<8vL$wl$XwwK=QaSY#qiI8)c$S@&>l&7~d}M!|Ow$ho1e5V;O7)QL?L6{!wt>;a>F zK{qYuJXF5vGx*dRSDYki4urd1ViTC&Vq@wED#q&Q;n=t%L#tw%rJYpWp`Aag<f=qa zog4nBbA!hnt>vX>jfJ5+^Ip>LnlZaFtlJUqgJ!~Ij+!Monp6CmM8>y|7qbce>8{qo z`TpguhcN;sqZsuG0cV3K%hEXVr*Qt(yjAVUjPc*SiE>g+LX*}g0OuD-thjXMLlk@Z zQr;7DL3lkA`251kZt<ShUPU9*he2)GDcKBlXL=v<25Ol{adHWv+THK_6i(-v!0%A? zG=f7vTFC*WM1j&{&T$h6>0`^M?da~P55zB>0M-WOCr(fSy&5i}HG6^*L&KT7KZJL8 znP|+8X`XkmCgp+NJG@#Jz*J?hT-`$_j*J&+GF=A7bw$-h+Yko<1LaBZ`g!nB)f&|6 z1(uOMk}LF+F^V>$+E|TyWor{bhC__Oln^o1N;bz5B7VdZF}#KnLU;Swd~yG8Wc<a| z=#OFScL*aXYHDYJjG3Cd(RDR7-_uQ^s%2feGmxR>wza`ASvv1vuFpumCOpQO^5IEm zaJK2ULk_>(#@}ig6vtrk2(a2tgKeuI4X5Xi=2BusFVn?;9YBQ}3Q3q{q~y?6X+?kY zWoM<8>6Y{`$|r=Q8!{nz52uAI7%iL#{Exg%4xclQqB|w?Z}=svo)cXRM<~SzkUME6 zG~3%c(*bW~OyFMa;`gLmVcT~Lkx-F>Pof26`9Tc~uvIqLEqM7D>ePXiY2wARVLE?k zO7Qr1<5x=Nzti?$^aU4;f=D)+$|S*ReobYBM}L3+-(s>C*rs!X^Gu~;gH91mn*hGb zX4)8f!lAe;>;qQu(<M2owkP*ckR;3|w`QH>Op+;CqW7X4Vny??#8qkBuEy3$ljPgw zSwu&m!f4stB&b#a#hCO3R8;K;YK3w@!~b7J6}L`_--dEz-P+<Q=hpuQkgeuw(7<CG zM!5F_*F@*+$KxRDvGs@3gik%KZf~$UiOCz;ajWE(GyK=mip_wL7cql+G<KumokPE| zrxhQ2>`;bTUX92W>u(rLY{MJO&RtkxFR_`093Gx!O^s@QL}vDFMzP>ok~5FX6Yp4s zwmj^cyOnph?Z@b;d6-ki+Vo}R`|YY(n1aD!2Ic{E+cQI{@i8zw+{hVA)V>w^hw1<Q z_hd`2hMR8Ly)cKRJ@gDy5Ap#cgxS3x_|O}zn@CNK7DzYImdSIDkj4Edtuoy5x9zTA z{wbQWHG2;Z<08(;JybWObV}xAwcp|HHpuL<Xa;WDuYMeRLk)>bxHk2CmHl2q$y)6w z{ndZ+0z_S_wG#T+KcpttH{DC2IP$@zHmjruQ(sTh_X8syp8x1AgAVZF67EkC<_p<g zv<*(~eBIru4{O58znTK}POHNuvR2A|n|5pkzm>sd&77VvqT)D;huqKPqz=*4?@7_y znY;YKGf2HZu3kGz>#8r)L@%3aKVL<R8!Pm%r{UJyKKI92klm4$hq-S9Qn$#CDQsp= zUPb5>@b^jw33-H{)_=isCEHt%|J-Q>D1KZ&pFqg7y1rY_C<IwF;AJflkg_Mxa8#2P zB#OA<4X*EUz`P4z)(%|jrtxpsEr~^FlVwfVF>wwh_q7$#gy@x5L0<QT-*=`wi@mNw znT4kpx4Fh^GbhhE?#pkL#BK;B{C`eLyS<?B+?F3-47qgnn_F;Ty>6auzIRryxe^^O zU12OE+9}8$)}bcV47l1vwfBqS5@4ok>Wv{MoUnwX%p{dMUoe-ZVRypwmZmO%dcb7V zu0Gzsjx@rCl>a;Ep1az-tzRb-iCo-9=hfCP>5-oM;G`o@w%3Oy#tZ}>jZAi003xy? z!A#eaierTH>pns`Wp7DUms>L1>-Uz$exCL(HD>gBDO>h=Gv2sHGr4wRG-H27I|7Su zu#{L5_lgYhUE-&qj}ay+Si%OF*&c-~>ZPH}%F->wbMlFlL!Lu#?g~SE9)F1evqjpF zXLH*&2iwB2XKdU=f01&S7nOJ26IGO$i(|xgnCEcKT&*-DT^syv{37QOgHf@k=$9#1 zk>e5-aTL**O31Nma?|ZmO5T+ll7Pyj+<B#Vwzwt<R2A7*s}v_Q-qs2wJ-EHw(yD$S zv4J_s4C<~PTe63~GLvr)@Fkj{lS=gmoJU~I+_@+~)>%6BZq{+sNy>aCv9RcH7XrG~ z!p=5x<2q?pe}rAX8a)P@s#gq`DdwS28gVh9wW|7txUNJ5&!}1FdEBHit#Jx0g_Mr< zHgN7*0mbpW3CzQ}W+kG*gn#A$czi9*wZY&Aexcy}>Kc>;uDu+>M-iC)_2{@@RBF>1 z(W&QsoMZSf6xs3K!3=|JT}~dTE?o+%B;tEUc+3v_nSg|m6zuw)6}=RrZ!0ewB<(kw zP|Mvc6OHuXvC|jmt#q1#dA1(vB)9i#v7-f@!`ARcrIU&(LQG4%J=O;@mEtb>hjC!D z2p-_^0}!G@5W>BVoM(Fj^>8e~SiOB?5A5`i!z_ky(IzMtU|oISPaowRzm;#1DUWV_ zXN9OW07SXDnb(dp_Ofu}E%Nu!Qu=-d0iFUjv|1|`usZ_kC^<?!Te<%$kMT75nA0MD z>cuhR92TMjfX%D8^KQ2pIDfL}L1;;|#O1M=v*fxS@p1FHQISWk1-)V#NRsVo+F=j< z|LMNNB5t*)+5v*V_Nz*z>sjyc!h#5{TSvW=NuQJ8p%aDNR}%Ru*jy6x$cq@Wi?!R9 zM?5t8t5wQZW9e4o)dWsFq^Nb=isY-WG+Z_HNTSoiY2o;Y3Hk8&ku4l7ZA0f+pyB;q z&cdRl1{zuG%$#Ggnv}ajayx0wZkZcp@FZ?uGC=&6BEeVDasaz*`My_#q7o14qF<)= zyDNW^7QX~i&l_yC7mJ3B`8Z0tA&}LLh#9p7$fVzq=GbN!YP~#$TYiqt-_?Bbttpj4 z`0ZJ$z(<q3CQY<pmsZ7{W;RF7JjR)#r^<VRV1wNr-dX{nJj0~wf0Kzx2RqE#;k9x+ zPrIsKU}pU0kJvri?sHzPH;QK%p|T*J^8x$>$4EM=;~NN|x8O7!0NEF&SdF&s=u*&* zeD@DR$mG*yIiZY?I_v)MrUgy^#^w!U#qf4u#R7VZ;B@BYVX04+KVvZB6uxq1L1BLy zF)cIU{aT9m=<~9%Oq;u#G42QpG*jmP6yu;b%AobqN96+SGRcU}=3Q(Ax~t#AE<Lc5 zEdRWq&shG+BtK@V)E1gCpp!!aE(2?gHce^2=*M~U#nN8XT~;c&qM0O($l93b{m#8{ z0HPR3@jZbu2cNRnun4FWB=O$hB8Ok5+?;U`<Q-?}k?9TUHkhz4!o?y~>1zn>@WSU} z{|rw_Uj{a_h~noM*c3KRV~KETxy`72kNkNGM`?)9C1#%OgHKf@Hil3XAe#W)uEQOe z_*`Ry3u1^&<=;3ta>&2<_q;oib~*W-H~HNP;$x^%R(s$uipg35#gdieDZ#jPfAclS z+Af)n#GZ&P8{|JIpP?ktT-6c-9(D1rIY<>!_08wWT{i7=va~l^PP+rY-HUqR++3T; zX!|G;OdD62V5D-i1%7F*CM4SEb`F>L^aPZ}q0R#lLRvkEHsZ_{?K`HUfKXp;IiDi^ z{s`}iQsm%oU*aAe<}q&V1+rr7H7-ZP6<C51{nUwg<$b7;Zip>sxYE@CiA;7xf@=() zSITvRSaLuPw0^jm(D%`fQ{>yX6cw9>`|fke`loD_e0$lhA9KR28D(0rbt7vlahr0p zc*F0HFK4{5$d7B!<btHH_P?{ap-sFz86^yQ0A+RV%}H6AeIJv*0aK`ZdMUnvZA2ki zHwV3W<fFqCE^&PGY0sKEXGmtPwy3UcC%YbI%}sKQ8f%whJLjN+Rd6x89ZazS6(Lns zCVSGR&m~cjQXQSp7ILvW>zn@M=%?M~qT3hc!8Lr(AC}7aOoBrmY-CcH;<$Ni!Yu70 zI_w@|$8^<2&uHen=VA_jaBVab&Wit{u6f4^3_60GzN{*A-gOOX-92%v^T>Q+8V%p1 zOZ8c0u+Mj0ep9ws>F)zr&r+33FZo$eE8EIqAcX!Q;xtBv8bOi?uzX7G49i=#c6)!G z1g-qAYchs>67ZnLP%H=KKljTO%%UsL6`>{26CU|OW0PA!6VE8W3<FW**_Y(x-gbYz zbMiiMd&|22uGGweV?oxqb<iBRl!6S;RW5X}@=Zg$W32APtB!H_L{6suA`wodOI5R) zPQWUdxp`{nS7E4{aPwzZ+ZC}PZIHTJ<K&k;c6{3~nM+7)qq<SRrk?j`2@))6^#=F1 zuE$WSoy|e_d<1QgY7gt`8=moH1u!VU%Re>(S>e?qXyshKO2hhAFsol?xBZP_a^XJM z37i*+RG0G2$T>cRfJ+$y{9VJtji6ywO3$4Jf{86%GeXl2sGk0t59)gL&VNU+<r=9< z!j73%iY$S)-|nyS@-L!TKbI|4F;V~zVr3IvG&G_#`I6(&wrFD5-jVNs;#tX0YhO0H zXFnN1V_|_^Ug1yTIz#1AA*>evy{a#<AH);YeDumK64%dK*%?}?SCjkKq@J|;&lm`o zDyt7wah}2*ij$9n-HYO>0&SPpo1QrZ`gtwN3vNo~imnA|aQY^+SksFFZXh)+!kTCd z0<sKWOFm$gRt2=jMceAqO{6(u`(Ca47L88ogopWFsG_pnd(y2t@b5k9%14!0SpfI~ z-ObC>Wz$e#L{%Gi6^H9QvbJ7Lw}d^hq@IfT?p#wPA|%9PiYaDYKV9qz|7s5N5iVxo zkXK+qQUQ7s8Fsc!v)=(Skvp}y-;@c%c@epHLj3=L9a$3a)n14xK4Ct0WF|@+r61JZ z9|qYi*#I)S(m9s*GlX<Kg`4p~j2Y%MMl#;%QF0LQhq&oN^JN~^E?m!V8LH>NL5#}t zcTZ;-?oB6bCA96+=0NYbCL5s)p0l6&Z9z@p0!vd}?5x@5%BDA+8NLFAt9V80IBKa| zCA}YMc(u?pri|@n!0i5Jdc0ja4tX;(R_nYj7wT==R$-!NW}BV_0P&iK`|v}<mRQ}F zKhU`0+RulRSF^KvMzOdt3tFG*q?6S{?Fo5mwyo*j{3SXrz6pIMS5~rqaYMFKAI??s zs)zp)9)nq%$E*7<t5r$yVTBb!eE5zSII=J3JsV*85fY*r#y7{O)b`cOm>?5_G1~t# z@FTTSkIeqzGF%?G@R?h1R!`P_^WH(hYRON6xh6(gJjL_z9Lcc<XDQyR7O-yy-WGm^ z?scaIBh`2oSu^|Kn@zZV{cd%fgWOa4xEL-IfhT~OeW1;#;9}px$~IxVLxNFkpF!KP zr{{m__06~&iz}qxbC9h}MG?Qblbcd~%2ufMY4cH6huTY&?k!)_&hhxQis)b=5vI}p zsQG>N_v0-@qoBa8`HIK3|C73Z%YM^3<gM-h!xfY5t*3OqtOL0cPT>fH+eX-N2(G|{ zCN_pMrF<-mMU|K72a*||Fx#;*gblIKkv3oTG3=R5s(t74bFi2B1ir75(4pM_;T7w* z@pM{WPajT(A2Y%bF_*AFq7jczdzF)3W0pKu&mWWnroW~u{lbSjG7z!)9AW*p6w$-j z4-1~QM&3@7TkGdE5Zw7b?#_aCjAOdW)*DE|6B>gHHSN9b)aiJS9YthsSa<HM7l*~S zbhl+XH4yc+h_+1CFEFZ_f~BbtZgbA@$-9vvzqo}hc`KX*pC631^+~2-Fyd0H_RTZR z%VBCAeSW2jmEmuA1)}&WrqzRdV2%Ig*9ZuQVqG)rDQKP{y!^t)$$qbm(a4n1ep-Y_ zt}%!<Tw^Xg=hxSsoWkKk+}D@}%dF>z*AOGJ&qUVLxD=le<{8R5DT@Z@Ol%ik{EwO$ z&DOhWS--vbAQq(HiYy1!vsp@qc8DbTJuihgx3X{mt37EraqjSE$ku4=`QvGz{ag8B zLqog){hgmv(#b<(rH0e#CxoO1q=Joi40iNyQ5=2~!#*!sM?4xF(MgS9&H6U$vHgj1 zQ8dgGX>2%~dfp?*VIcY*e63sAC}Z!;@BWeSP!8>VIvay_$#y0__(X$8a1luy`0h*Q zg+zCZ-_>7ZaMalzs}80~n+h!PZxQx?&h*O2V!_pMr)M@nuOJghnu50Y5D;f#Dl`$S z@aM~YweH5_$z0}(Lf_kd-=FSzq6RVRlK%`7^dzyD&s_g1Gox?jn726{`AUN6+35zY zYo7j!gL<RS;k?`QCcC5r9U{|dzIDCNdGuRWR&X_}UxekE0mDREDfN1AIvDU)+wJfk z3HoM)_V<<)9j!h{d|q$l@uyvos4%&;%jd5qy|Z^dcbDscy-g<q;(rnT0wcX%9)H39 z)Mrb+QSEi#pq>9m*C&DAuch1mQH?AIdle*T+3JE);1zlYO?;}i$}Ea^fXU7^clqpn z7PVSXOM0YF(L0`2czi>M-@c6BhgG|Sn#7y8f}Nj4<(~Gy#R{c$ZwpBwqUD5*zZY(O zgHf*E|4ImXd%~2SaC|L<dubwf=$u5Us#T<+r-F&veAj!o!ubsW5qgB2eTv2V!8+aX z)hNakC-bXT9&}xP8`Ne0%MQyYncXWq1ANZY<u|LMDcOl`AH{xQ31%N9!Z<DT5VsU5 zUj^9rmj?5sHuxAdT&5V(NN^U;mj2TFr8+_t`J!9$qe#9hn}O0vUBDD_M)(*B64tbv zkW&C7#ZUYWR`q{gW8)=x6nHln3JWGQ<j&ZWf6=|m)ZG8Tjfc^n&y$&kddUDjg?*1@ zas!lXBas1iKj<f8?_a4Bj^$pgQR9~5<;bq`m+Ejk3u=6}t(AkUI?dvBMgPrg`MVLh zpy>moKnpInOwe{dx=Eg&{<I^~Dl+8tmN%@<RZG8ep+9pWziR%jTq&I>=7M!bUur>f zG9xTFH!`?E_{-pC>YHq|{QcD2S`&gaVLYknbiZg_D%;)j*Fisg?dD(+&>&VDvv2kC zeES^26`v!ec=a#v>h?l&TT+foin=Ah2ib}gU+i)ZXZ|a1>auVAOQLyS*5aC1=eldl zQ5$t!W)c5R&jeFpe=#fD8=KBsaNQ}4Y^}KtVu%?285Mhl43q+waO=m<R3p*6&<eJ# z8}wy$AWW5R;3oWiebR?>I=V;C^Zo18mM{23Hh;5?H#ESJxD&?ML8GC~t$T1)_`N!S zFvVKfl&f>7d?8$#>aSX#T5V_zMW#vB*8YC7!GTczLh`W^1NajkpHlPlpRU?H9n<Q! zHEoj2lL2|=(I`TAeZT7_`EYU>`Swi{-@J82g4g~vu1#;w4g=r|-bOskup+UWX@_;I z?MWrMbdB`g0#C%Zy5iv|okleghDC!lQvIUqPM3yfsY@-&V^Bbn>T?g;-Yj4N+8h0k z#;<DpS|m_PPF$WB;aq?pJUv??Cb#v~#*XjY=neu*6JV>PSpDyJkunRneabB(CrDOL zZzFoTU9TJ7;eqCEpbB~6Fxml*d6pK>_*Pks5V*&&uRY2U1!zg=lAs8waQH>M#Qb_B z&A>_Y-C@e}XWi{|x`Scx`VcyOLr29hd;i2aZB26K;L{rJW#QG)c%CCG^K}^O$62Eq zr~MT0kagMcPOp9;F)zdC&9kZaWHhJgx2APg-55)|yjEGDxMhpmTwDp_-W!KR{JHd~ zAx|G`o2vG2$vK3#W8qhGNY&SWdqgHRYf5(Fw?TwA+kAY82qxJWJ7;<{lPMOHl|P2g z;77Mu+lHTE5rN)M(hFua34~I6R(vo*=r&-E;&`q%#Wg0X)s`MeL`cK$tLdZU8UHBX z#_XDg-v4CGvMwD5p#mnBsi23!k_3AwIh_fve`dhwuxL0Vbvtch4D084`&=QMG=FH; zw(dw<3zdqk#S>%axk&W-B^g=H(G{=8ZTJN+>x$N`vNZaJx`5l0X9o^fBa3JWU>q&) z^cOdm2($zB!np9xkK&sKsWvzdroY1)M(v8Hf3lnhXj&ZPTlG+NP0ywu_+n+pNZO5u z=Xquc**_%sbG5dy_m9n$T%U)(wsBEgYqAv&`m~?@cr6u?v!?wVnBDz+WM&SW?4SB0 z=XJoV8<}XdM*1B5XE`fS%0}qsHcmA<cCS12Jzg-N0JHCc!3*OE<>Ne)1qj#V$@xgL z^HVOlnE5fH!D?3bqimJz0jtc^vi`1t00N-wwpt6c@QBCS156cyV<a~#O3-aHlcoX^ zkL|IzO>W|RF@&I_W@4wtwH5*i0juwA07^nXx-Z|s4hv;<ueB@?mF<+eX#pA7T{pRP zf7zqBLAD@N(6xfkmelkdy5I)*sUIH3aXKraJ*nn<zh7%p1dMHj(X#{fuS-eQo`kd0 z1<9?mzNoOlmu(jI2?2kc^H;U}XR=h^4Kk!Hx8C_yz=Dk3;V;!kteb8<;E!MKM)`8) zdK1D?;eauQErj{@aTj4Tw!<{_94|Loa`@2mndulvyI#LQXU85K?^c%OEZ#__;;*rb ze~sM?|JMPS%>IlzXyVhk%>P36N}zJnbG1DS(rM*{UYUSo)6?6(!`_il^{seR^6E33 z?O3UrfG$+a<Dut4GcBe4qTtxgQtlOfWD4(>XUOiy8FOF$TdC*`Cn4z=9bKTKpN6-6 z45>Wv?5aFJy5L}1>i!3EK^7OFO0<m{^=KTwjLFPgH?TQO1be_Q0RG{I4HpZZG<;^D z0|3{+1!kz`lv5G3Tzp}a?}O;`ek80aO}W4nEfLz=ReVLUZg&2s7H+6c<#mij`vTwS zmI@D7)LGk)YzL3@nma8EFSo&esn?UXmNbiHctdQ@+J_S-k1nOtR_z?A)S?B~r3ok0 zho&ua;*;we52Fo{+rVN0Su_qC=RAPfz*AA3Q25`S^4{`Lo@{ORKYI1WGHY~wFoY$P z`sJCwV8c<#zZ{F;+4?iT!2TBUP8S$^W}^YvS*f-5<xNzTQ`~CKrrd4ua4Lb9{*L3c zvG`JM=Y8t5n8%C~B0YLM=3UiWk0}Fg7moeWnWi4fs-cug<?ZTho4U9K7A_>$_Cx|` zYwF&9P9<<UO!7>Sd@^tY<b2#Q6jAc4i+dyp#(L#yK-p5U%|x=j*{=dQoiNvZ+S=&H zrFj{;$eD3631F-B;Du+c%%$t<YAlq-{9pZ%_sAO|--gU>ycF8ahs9r=c~$z+<HZJN zweR1>N6EhWm_&$GHLdwh#OZk>DGE&h57^*HkI`la!5^<uuKDyg`0*b16FtjuDGT<2 z+I1+BjyVHKzu-GC%YrQ=+n>kq7f;Y_I{$BSm^|Q1qZ!1?)0t8HWz3;-Lbbq7#URJy zI5A%3b26OL{$5?KgdZBG{8xUF>;KnPBt#*C6!1##&gKB}_SK~QRJunebG19MZ~^-A z&N^H`s8<N32zqq3<%KE%*?{?>K5Br32if)71rx0qT6v)LxEpq{?s!zT4x9REk(Q}3 zQ2GB>G(v0Fz_TC*Hr)jJ<ViXHqa#6}zgR11dXGHgR!pa)p@#xLzHJo-8YjwBFJmlA zBR9U<%0opP@XZTZljN2`qCjiw{gu=BX-y$<=8Z4RVwf{%#v<n^jpNxHf_0O{UTNb3 z%NGvIrTp==`Hk^Vj^+?NmgF|gElRXKt9j+vhNDC<NDh9mjwY|e+rOoUUN)@=_^6&r zj;zKt`)f<9crvZ46@4GaPT#@j%3b&pyybaJul&sS4_><HY}I_bz>$A8--11YK7jRF zT-J@vc~5@-L*eROA3@%4*0vA^{U0ui{L}rso5jZ+NF7B>wv~t&qrbI#eAIjS6TD)Y zIEwB`xe7mg%|9p0ebYfD>de&h1;q3N%sy<0JOWq+dbi@=wA$)bB=B!UZ8xCf8b9CL zf`R2_nXy~WAy>8rZUV__<4!mSyUXU)&@}_>cW#~Hax8vD`TmB!snK5M;-n@QXN*;z zNRsCP;>>&RqT8aQc9*jcXVo_2kLZ@C1AM?$6G|IJ_E4s@60OW0XkJ>L(J$5uUZ5;D zMy$|E4O)6fLW6;K2j{dX)ep#XhNrCXAD5o`|LP-v5$3yB0|nMRLfA$NkwiGuR=KVZ zRyB8DdbH;BUQ+5`MUuC)Kb#-YQY|}j<r+f2Z`yhJfi(}q3jIOZs;Swv<LBnt)jDlm zbx1qMbf)fnZTzY=ErFgJv{N$7huJ4@2v6+ZVmOm_8WnU<g8xMWaHJ>RnNGR7{YjM8 z15GObvxi>~Kq*lGPW>0p8cL*;Mh{+EP?Z3u^r$PMe-VW%N?tTXLBD|o;;2-D`yt46 zatF7Bmc1FjQ`HoT)n1yKIq-Yi`NjBwg^AU+a8Z+b-)ED_M<e}TBs6%msWu?dYiF0@ z2-6eRiOpW$1drzd-olkl*+}835|tqZKe{0dY<My(qg3ZJRWdyxdnwQ`#CeV_1;Jn} zZ-!9NpYZrHDPK{&;mjNt$?~x}fSOA(4qC&kWbAMv>~}HyHEhk(QTaGhByZmq`Yt;L zt-$?E-(uEjlf%A8ClQARvEjH(@XRDp%D|zP%bs`={%8z6nLim~m?!V)kwxh}(%?TF zu__BUa|e|69hSs+5|6|eN+NEYmDO|R0mhINm{D?aaQUB1)Ddf{S^XJRov0S)s4hd1 z_()sOV#R*mXHEfTEAdd|6>HsC#r3{Mbv?%c%NpSQ#|G0DtEywISO!cfERc69+EKdj z)iX-mXHZ+kwP)9!Cn282Y?qyXyyKtxaQN#??fN}93gW4gSe>Y?imUTjtCY_WqZ`MH z%;oCSLHFUZF3<4EgPZ!q+U?JaW1+I`ey)BTvye(uxQu))h=+{bDrP@p=$9s*Z2xeq z?0>WLw6tis!JCnSUZCaNR~b(;)6&PF(N{SA5gND(_P2mXQx1m9v`uPpMQ(17d;P4i zL0N_I(k;Xp2V1;%lji9zO91piDnGUzOiwR`8z|QGhn3H(Ypl;J?>F6HWgpzC(r_N= zzQg#k8m5~GadAU8Q{FYTT3sLVe~lF~HPiG$u}^qrfE#G{4!Ip)V!h;=5!u+^@ijTB zH3Lg;VvMZQaYo_IIYUoni!D^W{?=|>9u)HpF6^99U<K6{CjhDgzmMI9^ohB0ardeB z6C9TjXQ{52N`VX+7cevnwPJCh>(9@F9Ld&&d?`Q__ADbpXzj9a#?=G$9eSJT_ikD_ zGZa=8opege0%HD2VtL*P{s%S}#oZjgz%+K1rQ_(4F=S43Prqyg+J;F>F$&981T~bq zu(oYcYjVd7DSc?x$*9833ZeTH9X_2p7EB+25Be-Jn#Q62^@2=L=;e{NC`Fa2^B)e* z1u1Rp`*Zgi*e2#!%E5G_8eV)Nr#}U9v_-bdzSm5e2n3`+I~V^<;>uOK_Nrh2$Ex_2 z`0Eqsf17UJ%+SL5tj|F?j8jU&r?$H-7BcJf*4AA!#_8ZqF9AWDSBpHuj;k5j3SI6s z1`x(WW^bBO8NirdPUHddRpNZDlYowdq&u8~q5fV&4CQs7S^?dyq9zI>Q1KCHkAw6a znF(3&0|1`c8EeX<u?r@4H88`BQ}M}!R%@y=hM0BFVGUwCt^j3X+2`_YH*RdT1-lZl zl>`>nla9qT8v~Ppn8@zkjq!#z>k!tqs`OYQnX+RbpXwF{L8DkJl`Dtsh^vLG@;Pjl zN>DYk(A_rAQrWZ?k@jAyxXqx%*FWvEUHQ#Fc!v3Pl6NYHUdSPUp%C{yeo<ReeSz1h z_)u#TGj@bW`1;pRBl4pc1THpl2~l4+4yvJ9(Q|v;5Qh6&3LmZ+hQ6Ms_7s;B0&O<Y zTcTjxCIpiW2x+Da-j>8xwcd%^dkCe*EU+^fx%#SkDc7!`3qe)zYMZ+M)P%^HI$jKo zstp264&&{P>(P}#gkDL7o>at}?Ek0Gv72}w6oiSO!C_RDW+jCo=B><=O@C?EvdVC1 zmLrgb(M!|y`9%QtCz_ia>gTJy$4Uy7HQ64J#_!}F2hELZnWy6S1f?925F5<Wstbx* z82}MJu57Mzl)&C_NG&L-R3sm#m+%8a*%uA{X<Y$K<1JsUdOXbB+ygtz?r1?=>3F4- zUx&o&Q30pQN!1~@%54c$g_qc}L!QMe*pf+!tVv8fw}%I;;+FL?)dMk6cpf|fI@+qT zrsE~WHlIA~aQH#V&!M&#ujA07@?Q&a2*nEjxiAu$^lZL4+kjiTU;NJoUic`i`VyBs z*f!rGqxBfVu8&zIl`paj(stz<yK<Y)2@6ZfS-XO+S4x5wiQg&bL5bO^l;!Tm*(pt> zWma!kFoFv^hOe+G3ZYrsyTYvF-LrHfneSthhRq*x=%XeQvSz9@Twh~<+T{WjpsoIr zJQ~i+^B^AZTW{X4*GxCr3<&C*6<#z8!qw4I-thbfOP-VWq1MCscZl0%SGO@T^QV#q z9@t$@@q|`O;@JCLsar>xq|<Us-@LHVPZImhR8?&`p|9H#pwQ^zpHs;Oerm}XQ|{>! zwo&bbdn54cmBC7!Q|r~`{1@SZ+5*)uO=+D@pOt@hq``53wZoC{=U9tye(R&~Th%Ui zOB?Qc7~U2ougRve^ToDNtQw@;-N%l#KA#|!h3bX9er_K3_jX^ZrzX}mC^P7M^>*xC zvWl|8{SqAZ-gkI>UHA#5yPM*Zr-X89g$=MF(yu(5OJ~eqWmT{R5w9hXo2s5u==$x) zFMP}mCRtz!a6A;zFU?)~c3LiW3Ac8DDoe7W25<YzN^M27;~t@%dd26K?X(ZWOp@g# z+p4+;mqPUoXS7DywZV<tG7WhL=4?VA0y7jlnb~YHA0x8Th5Mx&IZY7C_DQpmH+c~> zGq&1QntA@-2-56DBE4JaH9MTmsNV3QZ-pprUBeC4S~iJmjv%t|atuAMEfq6wK`Jb* z=9HbD82Ob>^<p&r(x5eq>9LU?J`P{MZ*&k@ARJh_(DIFe))owtTQB-IEhdB|gVuL3 z@I+7<i#z<&SJV_s^ZIBCz7Uw@jM_pdtA7WM)ym87b65)XUq~bic$>!26;aq@WW(oX z;RJB&Ty^w^E!*FdyX$%nC>2LqQRMU1iYbr&RC-Op9?rFSNR}nX-Eo@$g9V-od1t1G zD~*#qoS3ZHzu1jN_e3CteswG-t9S^86BrM`R~h>~NK#5b%H<R2U9yo^#Ro9a;sgp! zRDqY6G4Oix9<7^EVam%*<6ub-EOuXjU*MVL22!8dImu@d97t>5veIaJNHzUHnNJLD zcw5)S6=0N_(TN{Y*}vm?`NiIZw@7>I0|yXB%ghSv#wR&2@OTBQwxWg^ULC_t`colN z1>T@Z>cf(I$nrj+f;QN;t9`__OO9|mam6mtxB5uAX=w?N&GL7H-sKn<lKePfKpHD6 zHR=%e@f7xDIv{s`m^WbJ&I;fPo0jRb&WTz@F@Iy1h_GFK{oXBNk#Lw3OeT^C%{~_( zkjgYnn7a)!a3lYJ()OeLrA#`^TnmB#|H_U@2gTjJ#317yHe5y@7N&6MFS1NZIRQ_7 zeOXorN1+%*lo?U$B7(3xBvU|P&J^@ajvTTFhzs`?Y*<XHVztVWLPZ_NmOO_GhG8^{ zu}`nsl8OW$xKIz+*b0NF3m}m))@_^yB_g$7_VislDt$Cji&==YK2p?thJLU>kd6<l z;=5FuG>rsNu0l$Qf5iG7bIzoD^pMZMCkc4i7PP`fF7nG?fK0-DZZ<JqQBVjt36s+Q ztCp%rjLtamjYcO@31qD=V0X7{PCvCXMZQ%1KG`WU2YqehHwpt82ctI|@)XHfN^0oS z4m~C$*aF*7nuWvjh#aOVi7L}{R(cToQ;cL}IHxm7n^BnC&!(F$F2i<BlC9$NwIf1! z1M6MORfcCvTTPT2ygr=W-L1RTwIzSe=X~H*K%Kh?TsF2VaU-Zg<c_-z1MdhFR7;u> zbiJEV*u(K?tbVJjQ`*eP=sPlk4O4tLb!*LNU!+<+4RvQY*z(&(Sb2+_DLhPp&RKi< zCEArEx@rBDq)&Z`DJaK4WN>-&+4~*KN8LIK@&!TBGKWr~zJZ%Ig|e3&hosj#DeVTV z{~j5kBR9@F`70?4dGd?O$DZpIFu{{fhcy_|Ni|XiOtkWK)HuDyjut^Asj7>GA9}D( zu}&;o{{~%rv#pZ~$9NElE-pk8`t#?WG^)Ek*$KpdWQ2!aiUJq$1ZhYoM+uiQk!}nQ zD8G}J8JpYZ3@$h=pOlpGciSa%qFb_e|HA;l>OWNW<lGP1B8)&(4TAEL`oxdT)e3eZ zY&jW@k}doPtEW0+OW#&M;2Zc6ALb6C=&bDC__oAsEx@r?85CECf$in7QxwJw8N3E7 zC5@4T>o>NmjPzaeVKDO0%V5aVW!l*ZmhF7HDxEe>0VT#czIc2!DrGwjUS%v66o0)2 z57Fdms8nrMS*0b0e#p=^hf)CNuJ&js5i|l{Q9stCh8yptZBKVtyE*I}4!Pwi-why1 z-;inipSJJQUR+S#29b4ulGr^%T6r5czQiW8t9d)d*8%a@=sP&h3SVlG`~STS{8HqM zKCZ3xTafBMGk<BQ8D!`h{Uq^TrZX7hgH~p<QLfS1{|X>`B@CbiE+ri7&*drha}zBY zNs@yQ_JhW1d?OmpSFW0kQg2nU>>&FQ-4Q{~YZw&L2IE(_DZQM4SmCgg=WYlFcd$)E z)<z!1AxTA96!deqg$yVvv&_5Z@HI87<3*-NmFot>iKE#J<ztw@p7qFD>C9v0N~CR3 zom*O-sBU|X1OC0?{#hluD7-$N-o*f_cU=RzrJwq{;4e@-jk)bGacO0H2{=5L6f<o& z80H|;%CMBcsKnp+p-U{{u(#&m=uCY(S2ZY&?7&R+og=HuCMd+piGg$59IBq&9ISO# z^P#4r4Ru(VY@R5o8m2XmhzewSN#x-fF-tCE%M!<`MC;-@oph6rkDA+_<M(WxM@f(B z<4&`hm%V7m9{~3qEDhBe-wCof44e3nB{UxhOc}e~z5&^~>RTCzPA#ADp}oJa{~thV zXZ@0LLau)qIR$zlgu(s|I#y3b;3HkouAtv#Yjg-iT9@p8Z|~mIhD|QFU|CVJS{Ar! z>snUYbRLvUc5<%CnnR9qQ#_sjvo-wxEa+nUR&9R%^u{NQ((Ftrj`r1VRlFxfBQkmP zwD2pqYivDdfi&eKhut(H<|aUEi)Gk&io+ZTnZl$=gikx$WxhkRkgXV!kW9-j-2*Xt z?&wNMpUobppR<ny1;2lvM$nC~YV@fzIm12m_@;%L-k;U)*3h?gxkUt0se>ch+~#|) zSx;vye%Ys@0Q*31F2`^6&Mzp$Z)Gq0<iacCxlMn-(;GL-Y1EWp5*~2zq@I4~<Nef2 zT!pA<NGI)4uxc3`(Wc#H+vgP&wH21cQ)sz^hLX;(1q*V;hGmV^f@0}E(fzT55)D3j zt74luA+VJUAGFj~JI~u<=y^Z@?z9vs|A<XtX(Icn$1?JR{+0>-9vAglG3eZO?DR%? z?Tv)MwUvL13_I$Mo|(0XG3VdLQd;RwRa|R#vZX!tgIbTq$j4;VuznN<ziyI-|F5S@ zM$E$92R{pn9soeP44US<T@rAr6cnZOW19$~H{>K!jUZCu%?vs6Q*vlaD6&TmJcR`| zdMn~2_8P98{k6gH@*E!HqePgr%KwqhFV2#YfKD-+dUQU==6DbX%vy7zX^=zSx!d7o zQ4tGQKvcim9yFiYH<hC3XK);8@_L%Eb<8*lM|Wqre%!$Lt7O}Lk$6#VWMGH}W`YQ7 zYE5`prPaeI1FBt!VNR<Uj{Ft$-SGbRiDBjR?|G0qj-cb3<4*`HW#;M2>%-?kfmDZ6 z&=EdgKYq;GTzoU<ysmBAt+fF9F?8A_Y58EU+M7x*b~kTSO$?>;4DBt>E36`P8}qhU zu2>V*xr2XZ@KWBMu2N^RICYGEjA{3~iC8j1%_Kfaciaa$H&li;UDN*zn{jo)_!+mE zdAWvbZ+AuQ4&O-jnh17p_&%%vo3X1wv#U#LIg3Su4tD4mE~`OlvqV6~c5eQX2<PV# zKs_%BHh<xxXXAWh#<l$Ed9~#B@y;tsuV*-?FIOARZ!2gRpHQpf-I@C(#U^FaW78&F zP`9ZAa|t~{W^6E%v=b+fQ60M|>w28AYx}UU)vukd>~ZEVc9kJs0X#ONR`pG?;i><H z1b%M*I|FkP1^>zi_03J~dwb#a&7q}|;DCCebn-ISy4H(8b6;SWH763Q`(oz#*_@)? z(#3JV;3R-RJa<jSfRn2Pvuu>ze~l_m-r-&N<E0GDwC2bh{wq8?Z=z(7JEq?pO*Huu z_!fJ4TO&^0eS0<P{Rr3vFzZTml~+T@=889wfOli{<R0P@yGT$Eu_b)AN9f6I*u%Td z%{=1DEH4a(D2T(Hvp^+!!o3%ltyi}1ZYHor^=qZm###U!<6ZF)6HlMItqB)i*kfd# zjR|}y=J*`H?7N(x?(+JQIOXJ4F=dQcqT6`I?M7l82vmsk>C}MVTkLZY-NQc^y}i(5 zxGVe)YjaLnn>jaV8TJEavgb>i$xJ;acZ;ri;WI^*6a8nxSB3}h5MKV<4e@=)?R>pA zUc6-iiU3}WI4~6~1s%(Pq|+u-m$GH{c0>?5A8OVGj)H{hHC3Q(FVD_S1JBVCw}{$f z=Km+ph9iu-jD@Z!T*n~EQ^>_jH9OM-5@u5Tnru|sRNX@}APL_kBF*wqS<VPHWaRA9 zai6SV4@o=DuOYPb8@Qzb0(12w@NS~XXS`cM=A?Q-*J8`fqZW%;7B<+yc+7yaLKNG8 z7VZQi?;33WL&|Ib^&s~t2dokLx`x^(RgQHJMPxIi|6F6f_{)?}6z;06!!xhc<aS2T zHB)1kWkRW+QB}Lsii3?v(@Z%({0RWUa3~6}Z$0k^bvYT6mr{Mh)d}Z&$VR2lA|h4S zUZbzti|hum&GU1dwZ|cfP{)5>q-|2G2#D(2*JY*EZ_siOHLd~U!YX=W7OB~Gm36(| zZ8Y#4SuknR?{XIq2o2~SrjtY1pL8yRP%L3|Q*wM0-A}&5myUDlpM>>4Y8bkiEqeEl ztGG$N(Ry<vz5b&l5H$z;O=?rEyTg4HGu5$Ix0m6he!E}@Ye2i505$RBNPgOGV1|;a z=-D;^uPH5B?DpPY8UE{sr>1slfbIX*5#HjRsHQS8r%9WE8j8mJ7h+0rSoT&sdcA+x zN?GV$cR-zCG{|z<T4CXo^6=by)y#(7uT!dbngq8Kuixil#mES^oQ3AU3VQ@Yjb%v0 zFTIR+RIIybG_WH-Hmr+ilRdTiPMVOJ`Ektu-G*KSTDQ`pqS{o|53cX<TOi`1&6ABI zl{%{?t&D!4n4Nd(>r<Y4*>5_WZwonzsBiwdGX?2Q#d10EV~lC^w5fd%&k49AgmfXd zI;K^L{jV6vixv$GeHh!Rp2HBUBRw^rO=OZ2$Kg};rkV;JadFs5c4>|_U=;J>@?5d0 zFdr{30JT@bft^mvB#;`RCmDP7M|aAsxG?~HNlk6cF@FcPyAX-?Hh}g9gRi9lNZFAO z68k;`w5cBE+O(YB&GK5aE!tr8-@6cRXqS0?CMwyFPmdrL*Kv!tkPTG%SvEkg{*&4= zqu~-(8~GHNoOkGP`b<qMT!L1o%Jj=Snmx#8qWCE`rcylay+Sx=1{qrFX_P#;n5-(_ z=!FdLa1h=Q9f!4!wjfYg1tHTyn_tmFStI0Drhv2H#5vP5zeM)xTGZ0=%@(q`It3=D z;0<-*nR4}UJW7?z$(D77d8P0E+goFgk7{ec2iFL9^j|cSJ6o&+1o2eI;0f29eyx{V zy+iUz_Z4HCAEENO)py6PSy@xAn2BQ$O0R?TEFJd_6e?XKgHpBvb3dFUeSsBH#Uplc ziQsM;($Sv%*V%IQ^Bie>oAm7Q@tb1*cBaIlSIf9pM&SVir!6l21U1m`wd4^9ffsLD z9BV)FsyFX*aS~VuSRRe%0po3I32~-Y(<vQ`=FbEMTpS09&%Zw!u*vF%b{aiphLZw| z2$UN@iI3S{rnIOPY+0X5*O6=j#YB)~>*eEv!#a}W0@iGBpa8d2k0h+a{u1g<b~d|^ z*Mr=ikz^8b4Fgzfx=bD;I4NCQe@Ug<uB2|n@a=)MaM+^k*@EX52f}hl4?Oms3`30g z+%jQ0HwN5w7%l@&whx}97|wQ7jY_atbViW|j2UW2<il_yw^d(r(uwq%QxE_4acSAh z?i`-Z{-#)ysbA^hi;5h0*IY4=W1#wk-V+=%*J!w5C4NPe(4sm9g@5#-RPqr{4PL3i zwc)#4`)@L#xRZ;`C6X)XDU44*bs2Da9yQ=D#G&KOV>zb+5gP1#3D$(MT!KYnXI;j4 zS{}ED8F{L29o7a#N?;H^SN7`^q+HRgQT%lVjy^sbpp~>U{?N(&WXAd#f7>ZArHb+` zl&Y$>vgy5--+lyZn9z~vZ%CY`nyL>R+4`LHIG)#323*oA`DbZ#U_zMSb$$^m*p0RI zQNglUbhSLP%S}-oINSv6{EOml&)jKs@yUknv>3v)uD)UI(*WrHK!<O~l~BecgrDkl z0HKcgj@N#HcG*neMO0A4D$L7}gpcR@Jf@}j{40WYT3#DcluIlGhOMU_)=(8CnVG7w zld*~OhjV7}tjtB7<J`ZhkCNV2=iLSqE~W4dCbzO;9Rl*cS#Euv`q+urWEU-CJ<D>d z+OJbORLJ9C!L*?thk8A+QV2k$KE#+T^4=z<Iph^&8)pCTX2Uliv!_`W{=ro7I6(Kf zWVfT*U^IC)Vl{k_2pPh9Iyr$LOqmpSPW~l$D?Lo}GI~uGX_I;yqUrZWlxnR$c)Dv& zGUZUrdvySvWR;_%U*?0=ptBA6WJa8N(?CVF6;IbQ{^)nQdKuz+i7P~4JyuE}KJ)*6 zaurtlQ6UPrAvQ1g0ihEKMNrB&Y2rCSatn3X=-U(8#gykqwf8tuaf%mJA-4YlbnKn5 z3>2l573@>#&$Px{=cx$`z0s^#iI<cCf09)QI>L)L;t(yQGRHH75XX@ZXVNi1qGFO( zYl=e`Je<1qea6NZBi47P(8=|fSP9mwjz+~nRH6zB*WY{;q9fYItH1790|SWO%7!UZ z$uJQg)~{s<o*7iM<{Bc>PqyqG>F~kiW{W3t>8@sNyZC>+4dztf4R)By-06^SKi~~+ z#y@Wq!z%pHN|DvOx^-T5o#*zlf%iQFDg&45nI#`jgX4Wm_y|jgmHmZ*ul%9Oznzeb zDSYZ$*1m}0>FUA!FU4PK%qTU9xDr>Nq7In~m-PwUQjNu@zNQ52VdJDwlGa|$UkG|% z<B^bcfY`12D!H~bN1N1D94tMlOaO2p?zoGP2K3d%7W&D168Dey2&*y(@3K54a7tXg zvT_2!^)}3p_t;xYP&f^!{aV3zOSCHfPw|=HRgye~UV-14F1!Kz2^W@Pnm3p(PUJ@0 z%<~M6#^1|L5^dT}<YrL8G$G%1#b7uVcw@V*4=?A_f4KPS`|lNls}B~^(o>FrVEg=q zll$W-A+{{R#4v`_V3J)THmRY0%dDRfPi-N7cfey2uoluC)D5HjO~GLfa{yMX4QrJj z+98!>B{60pY94wZ*Mx>pLLya{6?XCo6c*HWN#Ah|D_4nq>aQX}8}Nt(^25{vZN)pQ zMV7A52l@w~s73L}SO0phAD;og{~?K5aO1)lccxib6EV`@yK;hqGUBg`{@-#=<V_b@ z)G>C;4a{y?RB;vM5O5?(nPivLB5D9KK+V6sM9(5LBZ}4Kq#{*=iETbRF!l{HpG;8? zAEU;Mlluh(A`C~M@o3M7;&AR8t^7~D>9$xkQbDsv#IV!RgeO6_?yYIzN{Xh(WU_H2 zYdyvsUi9<8rTwuVT2)bWO6<3nJUu(Qv>Y`NAs*A|%FDE#FjUMJ5_TLd!J@sAWx640 zTGd!Lt_;qy@((%P$=dx?pSCOrsn#k^&1(iiXSk@#{UvGT^lM-_ym}i%;YeLDfN`&f z2`8J$Z9cgwj=DNnBj#V$O^obDPL2hkJv@|XFRvWX{ms`nnu>N&hT=Y@7y3uLiG)RK z2i+)3@)CCp)q#9z=I`@53!)!hl-izhF9?(c*-uHH9744^U~U-==<SisY?MFXu!PK? z=4|YAzzQL(dqi;LG2&jS!0xGe=bodGCBNmz75=R?=&j<QDTyF;()KjNNd`%<YfzF3 zNucs^qF9jqUu2f9a8brmdhZW^r>5pLpo&{tcn@L>ZGwirmv?dKEM-?q)4Pa)ecQAE zStzCS<x%P+wxNCri`61<My;gnxe~5yGpJqAgHndzWOyIq%~TU4hsD0%T*eX8G1Gvo zRIsEpU+FaE@Cq8TEe8<Zr`*(Mc~_hU6MU*0PZqzN?9xEgMVVo@JkIKQEY`{RD#By- zxmnD7o)_kr)k}ZDl1TEOpLUv3e4NS)^GVPk(M9J;7Btd3Wh58LI()OETL<3TgS+M8 zeECqM3Vx>>K(Kvr8N%Lh`gNd}A*+v3jtnvFfCc;y;gt!D~r!*2S;m)?X_Vy^%x zca)U>h#jt00B(8}xH~sj6XGv1FZz0Lt9gL{{@CKVwEv*37@{c*v~YGs)jSb0g@2s8 z&dC9rAQbV*W@}(lAp>j`Nu+QO8wT@Z&I2-(fa3%14V9F&)Ll5m{}PZi{l|8waxxVn zcrcXD_p?dIU|)#f`T<qrqjxdHkqN}IWJGq$RVNYe*+w)Ot`lir;Ff-zLgY#+dwg{t z*5wD_rlU-;bCr#l(RhiHk01nw3j+;6_)^m*)gRUdtxRAD@C5W*Xs==*#0TUZQ?q0C z#3+uvydpOhcv37{3{|p}$XU>GHhQ|ObKNKKmJ}<{G*Z%Ivf*)Ftdv$fy0g*s?T{DY z@98MwEjJ-1GA;9iE@0-PRjQGP1InipDz}`K_++-+Q-J;|iteIvxhb*Kv6v9uz7L%V z1yP9d_I2D~%kWZZ%Xh(tr8}f&xc>o>Yf*WA;5Cwk56@A~@L4Kk9;D12W=wB__dN;e z0|}yuPh;cJeaJ}~WZ_ai>-h|9ri1LOq;h(l0;zkpmk@|e@V5)yo5FU=0`1hb^7*He zv?+Wk=v39E2-;j)E={ylSJ2`$8d)B8q=H#m0!kfA<;?&RKMUf?h}^nCqx&B%(8Cp% z!Ur1iZlzc-{)n>YuQJZfr7Qq~qk<44SV7|i)t4!Yl}%Q)gy<MSiQdN@)bb+Gm4M+K zH5ru-MMM~s%X`t)a%y}#AK`{mx~svQ=B-$RNAvCI1|QOlxhwD0GEW~Wk=y+XWvnBC z$>c2HXe5u|Syj-@Alm&=cF-jAER!92KO`8s<5{n-6Iz|GUf|*=e|$YtI1J66<;)_@ z9JPyspDS4z4EL=Pcf2nTPf<#gL$;cb@fr>XVcA3cXna<}gG*%kUf-`G*EnCHe^wQ1 zv(IMw8o_1sE4T3_j>KQmg5%6aRUvORUQwTJd3C}&GVCgkR-yCaHITKzjHOmtB-W?; zuI}6Sm@xAc_<cV|xn~(eW`PU;;*@~{vW{ZQ)QjySZir{=?6xmKZEFsEONL(0BVggM z1;*p6-GLtMBs^_MBRIi%kjh<(aheoR2V}-GzIpiA=1K5$5WJe|y2LGHF-1Vna`9WK zthfLzb5?=N$!Z82ja8Zybq9MinDV*J<Whqw?g&`qtveu;Hb+pNzYUG7jUOV+Xjs8k zlHNaawt?{XB94+ZuCs7f!&xheubp2G<J_^c`PQhOavJ8OFgl6;*h6nrD^sO0IeFa~ zWm@lAp5GIrB%RgWMWc%dsnb}j@%ThFz_pv3FF&7LQqx~jDPYOwfY;azgO>rz!+PZQ zy_<RH`?g_dY@fq0`Ik6@I5>~YkD(<X)(e)SGml3dM*x6e`GP;3>(P-@shZ`YX(Eu+ zq@wnxPBl><TcM|JmV3|BOR04V#uLZQirgJ)cH=N-mU3}IQ=>}R<<QE{QR!}*M5@oT zQ1h|<lq&XDLRG^nfcU?I!$eBbt5&<F%7JJaK{#1DzmuFdNIAHgOzTly{yMuoM;5%! z*m<GH;}Ari{NXRo{jnQoE*`Dmo0ZnJo-;YP1FCBTS~2Gi`0X!MkWv#T8u9T+t@kJG zcK_dctTjwbLOoTeGsAgA<m_%~<3mu6qs*FkLrH~aSurNk4yI)(W{?!Hc|v8XwY_p9 zgDk_)jz{>Tx>wLf*vW``!AFL{<G_Cc95iJHjK~MqQ^ru<I(0aO-!i=*cz0RuCp#E3 zD>28n<fmmBqtqXLVs`&3rFzCoqM-q~nZmuK)jSA8DsZ%a>M;+SrT(8VJTCzIPVQC% zFs`@)W0pX6u^FohD5tuIY6^(;6Qia!I1c6QOHN2iz*ODXzst7CH@)uS?*w@gr}~p$ z_oq_Z`Z*<?LWG#g7dRK!agqQ;@axg{84kI)#Q`(Z7%4MC#FZI)<jN7n+yeU3DMnAQ zQFD7zIV{~Mo4!*x{bDDN8CCI*b?0K&I358V65y5`dV(DLs#b3^B{d$Fs0(Ly@L7jB z8NgKKIU3HnwHBzs_$IKGSk@d+J(xJ)Fte>jpv-u2J62`huu%wWE6IL~du2+4shM!o zO@D9r)>zK3{};i~UGlUbi!?$aQz7UMH{zW`sXeRnI~k;Ki>&}8i{>HStn4$lqG+&6 z5e%K-VZQYz>qE98l)Cc2QTE(iJ89)D`b^+ILm=zG&@RSlpiPJ(o0_(iqAF#p$`O;3 z|GpuQ{p)P36u`T|c3<lzAxiDaYa0LM)`ME-z0q(r*|&j{ylS%|RX^Y4)LYLCT9Lbh zHE18V-2&iCWRr$v&XwS{9uXLC9kaa%gW_-%2Qtu`ok=$(7M_9aM(85M<$6nRm;l>8 zQChV92d~B&oNTrIfW>Rx<D_{XsQ{q8-?VR;&NTLog~Bvd(ZRhAmxD-2yeAo)?;511 zlQCpON(OU=jj?&}3czqW9MSraG|~IkbHazHS3iCLcZbC$TIV>s9^B&zV;PeQ+9IF6 z{m{Mi4LQAe#vBE5Kn*!C=k_JHSmpeQ_1LeCc5@$6&JnSn`J5U<z|00_`^NgRLNf`} z$ESv>zfTB;F9iR{m{dV+6EFI#x#&nL^i^ZX9-r?!3w?0z{A8xWKC^#OteX=4%~#8a zYM*Qk@~bavb^dNQA18HLJ19G!#Sofb?U4Z3hguev_O9gTvB;Ez@JVCMT_hu0l#rqV zxXOY4P*S*4^BEU!W=LW(La_hc`93c*WB(-%1RWnpW?GBYnB&c)kNI^g7t~bfFZy5& z1%d34MoZo7qu%l-7*rq`@3D;J{JjZacz~;+$F-qR<f9bH6!c^%Y({r!74U^9rrtmL zm71pAs?$Sa2I9q%c^zXITi@6NSJN|S2#gc70<#5(gz?Y~i(qHznV2Ox*haEwqtgPf z!v4sn{edV;K|9s%e$}sVZ-iPS#T-@KH9klb3Hd=+6q=p#Q7T5;|F!`KXZSGbC$Gu) zz^<3zYz+gfdwF-7x@EIKq(Uy%20K%nUiH=wk`}2ww8W}ve_89HQ;pmjK#9(Pr%KKF z6R!}1^pceA#?hMlw@syHeOcM-P~I=wfp;S_e^RjvPfPraBH2ht4(B}W0j>iPI?d&? z6~%l^!>0@y4h(HEwe#8gQt#Y5Ph`2Pff)Wprd}qtgOyjT1{GL<OG(mUleg~Y_j=++ zyzzRNar2c!<w}LtAD4j6SM%1OP4O^WJ+giR(VZf!9_W{3>xk*zQVjyYcS}@^zl3NN zBo@2y+ELayNRY)MRcNmWhnDruo-l~3$rj%wKcdF~L0a}1<_k!K35OB`1ds>w<?%O1 zBt8z)2WfF@KA$!7k%b1%%*B70uMRD28wFG@la~^cqx6&5Zc&=?;kDaR@`1~Jivdjf zSN;oUzzp@01aMVqWcL0n1I)XzD*mtBs1^5bo$H0=TuB@xbz2JmTkIDoK5cg0Y~QMZ z7>>^FjgFci>ZPxAxwMWJC7Y$R$>0-Du;;Pd-A{NKGi}U$wSd;m_rL+0-?P-OSx}6T zV01Qbz85<v&=adiwU|Ns?Nzq^idIUyb9YocEn6TKr!@+Hiz~ufoN1!|(jcycXW!z; z^5!}4Lcb~{z%UNgIN)Fvl<h_rRPfqUxU#=#45tGfO0v_S*;1*O_;c*LZCLIZmY->I zYc_B>-}h3}X;XdO)9nmo-uVZHNp}sY{xTnQ4>a_}^Pv9Seb|5tbBoho{{845@KD8D zi7tmN+^ndl?aL5&-9)(mD{#O$Z^VzGQRu9Z*{<#PTphPu+Ws|n>Fyw=W6HQTqq7s2 z1SKru`I!?@p&C%_K#z^ceT3L7&yu>m10Vjj+CF$$gbf+vD-dU(3*(~x(S(@*65;7< z8u91QPWZ$RJR${VKV^_Yj*<#O__?2UPg}89r`jjdMWCu<5s1)?(NMN%pk(XQ$!}## zXl=+_Gg<F9Pd|Zwc{#>SRR2JXQC<a7IOt)}RtqQmxp^Qe*pksLwKQ4+Dal7W84vdO zV2|?~5|pczi&tda;zTUk84$GNEE(L?&3D7*%O<<?m!IGr-#lBWsZ1eD<`|pc<`E>j zFCN~TWvnvY*V%9?2<d(RB&_5tF}~As9wlxBK#O+_LC#TiR<wvo+u7}#k#UoZbN1Rn zY|#<~_fzJ$+kmQ~1==OfpKQAwVUeZ#p_#OY*uHK%EQ007;AUh?%7h;p1qWA)CI~aA z^7{GUE!dJ|v>Wt&F#>;X+hG-K?i|FjbbH+6aahnTR$`yTxz&Uh`2EA|547wX|K?W^ zjfl>xP{vWMpzUoc;<uHAgBLR94ho-_H-iLXwdv2yYuxT*`;Ut8q-eOafL{pn9pXvR z5Gz%kFtmg)T(3<l(?J<MQjEef)1zyAk=TW%>CjlMF}kj{snhNfp+!EZ^uWTVXeB&{ z3^gV+rafH@TRUqCeq>CI{XWOAb`U|En17LDYQ^6w4q_|-czJNuh=+V1j_wW(pJ-WP zX(SJON`N5q5PhA<36TEq<?7~gvbdrJDH2_9o>YbQ=cMcR<=-sm>U=c`so*Q5=EcZ~ z2txKQ6#B@n8TLSWOEXE5s5&%t?Blzzq=G!V+^agQXLmZ8dE4Xn+z%)9LKO)!u3@hU z6G(r-DLMzfdGvuZ#~;#BUkY}>n78mR45@!!2~l=f6V7zh-47a|knvEfxQ9@~TR0$8 zll#os0VCO#*~kHYf`>MGUQpZbE<ztbEh*uOJiNKIsNfF5866L8z$`jo(;gRe5rP}9 z(N1KzgW%6LWs|E=#gVpCZ{T%acb;D+?rFV>0VC|xYIOrbO4eg+KDs7`TzOf;h-7a6 zvHQplhn9zvMU3P@HT+e`mxK1_Zt8H>z}mfMjL&z4aa_B6iy=B#ihh8a_z?SXrA!(T zNHnyMp%uq?=Q%bS<0CC?+=EEHy6)0R-86192ga$)Vl9Q4H?O{kpD$?q`__i;{A@As zi|3(|r0-Df{5M10-B85ocQi25sc9?PnvwxfL*lV@*!=w|j%l;D*8iZ~KS%k&6!v1Y zeF-+K%9=J)%cfRZADVm>4#{0k`a>Wo671Exdl%Y^0LU0%U1{t=Y`%kg<oq<(U7U$) z+Kf{j$Ukj+fH|%9)QN!seR%p{$<caP5<<X+@=D=x6civ>Q*S!fo_WJRNIyN({igx> z4$)|lofRD(lQ|w0(}mCIJpkh9gTZ(ZR1|%u-{~S4m-YdVbA?e_rg*W*rI%gROftf} zR6w7W$bFOgiR=Nh^bR8;|3ZlCF)sSMe-^s1KY%a4bAkE!?D&b7h(=ZrZ{dQdeV{1X zcR~<6loV$IceoL2e%{6=3d`AWkloOaYUo)(Ati)FgmRJ~fLgsvEYGvn>v<t}-GHTm zJMRYeZZ{v*30^_0fHdRn2l4UYGpt8}!!hACEe?6OX?9a!U71gZsL!!`Bvl`f_0#rM zmCKbK(QaP0jxLbBuDNCIE&37iv|g*i_gt-49Ke^w?>LEKr{_~=t=Q%w6;O^WKluP< zYSBnrhXiBjz~?e74?9G4+32TMSAOhg(rjf`;pBhhFP6lknP>5{7&hSEjJ>+^ZyFLL zL-Cf*K*Rm0X-eN;R*xeY#*H@T#idsbln;hbvyoV0UmM3kC`*BFUW_(C<&a>zVh1Jj z5Zzj6K<5<*cAVy|!uEOjX2btHcAWjgW`(vqa7OPwbW#dwS?0+9p7==?f!FLuy<uNq zYdkL`M4P;ZM{c0mZt67v2Me%Z^{@?T2!i~*$SPxpOEj@)&!H{(b8Sc>eAbKWe%6uv zP*CVhSicFlBw+Nm<&^2Xr`wuU=9{zbms1)y1}R!1<O7t&yyDY$VkXKm@y2*OkRIjr z--mD)val$EbQF}GVe7Tr@2GvUX2~1kW^t$IQhFq*BF5HV7>UlJL2){y#1K}JpCl&3 z^FmA5Z<N358y^Ng9u9w6m}C#;K?jD~VU4Ty?@bO88okAcxn%pI*!@8Cr#}>}1HJjy zjY@Y-7C0_>Nwme=KoJ#un@V$&lGmiSHpVxx&DuN~@VJ7*X`g;;oEmvhiRPYUMOptS zHf!n9%t>cl@!WLee%&g19&woFInqC?6F?Qt&hl9>gV|)*PPEr_Ye;@U3w&+*Jr&1= z$dM%!BP!7Vm*A{J*w5HqMNzBeS}EtTD|GoxzKG;*+qefXW3Tv@b4O~)R@Sy0gMPp# zAknmM*H>l+xVVcanMDLO*Im~kIvLB(PPdyvrAjg2btgf{kuPf3Q3?^tSl!)!OjZ(M z+<S>rvaoF4=?0PLEy?7wn(IxR)pz*@ickboiVT3S6D@uBpDXHEt)Fl><f3x`?gIg1 zlNtCM<B$eVkI8d#H0rWu(D!Bf&Y_daB;gspg#{Yy@^i)kzUMH>Qd1zLZm6Ox?L#k4 zB4+TBVi^=QJ<4+Ij2kP;$K0r5YD_GUmlMSzc0bAtV=j^uU9Pejj6<UFaQAi>x!&5v z)=;(=a30Q~u(^n!<f)*Z&eUphW-px{w!i`;;oZI^hnmn#t+fs@D;b<J5+yszLQ7xl zL<N=v+CrMz5&N$7-33`^uZ+m5K%dGfw1H>{&l541VJ}pKxm(?uZ=$-fHpi!DK;T7# zMqaWr*dNKMbOVS}?vmK#oAqIjGFWh7+T@H@3(RHW)SC4VWnNh|08~$Uxj{Ic757JP za@@w~%>ir<#{&Qv%zCTUf|y(5W)j5>J~DVhtNUHBI5;#7XAMfiCjZ`;vJ3H@(-%`r zozwdJ%YN4n*mCX(d*DJ`S-qwp9)}#v0;YW{Z#%b98M-FLb@LzSd(Amayl*e7Yp@hP zlCxzeJ)|f*e6bn~%sXv|^`4k=jBmF$m7WA&`Q7bv7E_}4zs-9W@bMT}t<9oKZ7DID zbY$+@OPtE3pY7WK;0eiR{R$1$T$@E;2O#0UUOb!%`t{19Tp9?ExA!B2NsFRpnewI2 zN3nY4_h;+3jQOl!owdTmlXoTLB9CmQ;~;Goe>{q=_AMitAYjKgZHD7h3G1Vdr|YAE zrGCkAH5p?sfaaoN2gZH`m5jq_wmNq3IZ-<^JjdcBcn*-s`0huq4e%)MLu~8Y_~LN~ zeks&;wWM8XW2LCTz%evlXCfj0_GD~6Dy6J$FcAE{#peW#?VG1r_NFulTKT@ks<#Ed zQ3=d2sT+)St<FWv7Lv2}nJW~g0|;JIMv5U}@24g{iTEV_m&%^|S#IR}WHLCyA^K-9 z2h^xD2wS~u<4ARLQxqhT$RL&Ruuib71b}0(-vi7=c`u@{)Kxu#5=x&DEx}zE!E={A z0l*{{{>j2R8E<nlSB!QywxZZqJy^5231ex!A0wsSkW<wS<Y)r6NS*~`*5e`f<jl_w z;OgYVzM7rtuC-rhYdVn^dA5wU+0<KgQKmKQ7&WV422k7M52kCMm{H?`IlE*Et{1?f zg5~5=8!^fjS#j_IAubz=?Zhl=e%_>FY$#3OJj+kJle4CG+$AyKhv2YaZxgbpMA<pn z2TZFD=Mmw8#BOU`mE;++d%XQH=RnQ{WE#omwwnuRKBuft?;c#_^zT#x`eNu-?OxC2 z(GRt$E?;T78a@b7$R@_1(FRS*D*aR^I{<jnzN$_ntO?=hX}5T3XpvC7zgG2UrQKVj zSbP}}Hg_UUODle|eiJk!SA{`=eeL}eC*!yJpDc<IIzB9^%OL7mI_UGVI@(?WiqjWZ zD~-K)t2JXB&0UUydreL0J0c*LI`;)=PZYb6NwLWa6ak&DtpEU27GI2KiMV)sQ4(3g zs_X>0tS?B}W3!PT8eROW5Kay23{{FvyJCk#B_{Ja!k4N<2~~048S@*Eki&y3ND}9I zdKA-XU3#40hP6s;{F;K<wL%3y>uON*M*eeswLRR*()Fi0{l8?F%#I>|X?ZIThDhBT z^#1-wHr$uqa?Gq}?z|e2s!oeL2AqhDm)BLQ%`wN)VJhsyR9)E^)_wIctPv6O-{oHo zD-S6@8){eK)xXBpQ~HW$H+Ha*4Xjr$A8yLU;{%DP4)b5fkz+p$m`Uf!JK(5YMksxj zA&t`&qbo4Xdx4(r>^gB5E#rz6ggFrp;X28#{q50l;2XrX+GrDSw9zz|gt;|TMMxX? z5g+pY9x<hVT8W>~Maa3N&E_ghU59-QX@&Ns?}(9xBc%IXE*TUk-n8Q(mpw-^bVzRt zJoNm*-&XO%XV=rZkA@+fe0TRxN>4mXTB%N8Dv_^kT<RWsey?Hin6L|9-i|IG#C{at zA<ku2a0>*m9VVt!<Zaqs+roX|o|<flQ6vK&(D|yF_AUB9vMvp^<QAS@mr+*>U2S?# z2XsW%<C@Kuk%vK~1FMh1$00$@&@)j#fJpw5%GhGpJ4$vpP^{Wl23;PViTkxf#MJyM zXR#)Yo-HRUaL>#G@2@!9jZ{v?Q<Ok_n=1&gO3cG^F2^qOZf2o9SwMX=lN6UVw%ID> z5)DES_m1{sw&Udqu^%BZk&00Jw#4E8crWFj4ek*58@b<-okrm5SX8gb?)HA+|49WB zG9M>kbd(Dh66w%#<zRiZ(sAh4e+3VrnEW6;9*4D*TJsiopi_1x)c6(0nDPygT(|@+ z2l7QE9vVo1lucNl<tch`(N^Wt3*d|AHY}%L(?akEofULxdF7QV$%6@&fxhKw<d6x) z={l;AEnwebXItjvBd{0Y0X32~>BD$20(Am>lioHbR2kEcaMbmK$9BpE*$4!_YMDh_ zJ0K&D*Hwx?5+P03QeCVkE|QRPygP3#bHd9^)Xn7he}yoclO5l-X6>%DabmS2RH8)t zW0@*$UhXkyM>pGD;EbrPs2>c!nH}69W$5asvil;9hro48MWMMb*DBQK8psx+yLiWh zcBS;VZMcZfJqpJ09n_`Sy_7TwMGeZ$d#$?N;%62IiJ2V8+GfD6|KX0blqJ!*NNuk= z*vnNh$WN!Fd$L^m>Ud$UF|&0wD<V85HXt&fdi(jebiTJ^CukM^XFLyll&dWPdmL`M z>Lny1cst9e*H!3C$SI$2a~{#8BN06png5I5<kc%u^OSlC)Up4Qq}@;qX#omef+h+2 zwrBS8<cU}x>~OHLcxRI;C=F+t#CC6n81=ayBEKYM%@YIL!*ev+-VHf2k4|wR0dDv_ zudRqqH0uxJfnb-aq+d9}H9JE3Zy1<m&RR8vzK7@kiD`cy&UZIHT}mFj97GNC#A4N@ z;~oDQx}=FSrwFlXz{32_gKu<Y2QUTtU^edlfdu~Eow%j$r3-jn7h$8@-jCP@Mr*pu zC*6XIG^pyfUqyD>Z>r_(8q;x1Okc75BHK_dNA2)SmNL)Z88%S#G*&E#*RYmxbl~gU zcSs#+*2;g$ipdN2{v|87<FbH!E{AP2IFoayQ^qhIz4I6M)2N!DRa10yN6)T|102Bc zlQY!()TbH=g(K>jqsKRo^&qyy{~mqOa^K)!;Y1h~U#B1;DXDsKEY1UlS{+UI@bA@{ zyh#Ts>zsSZ3%Guz|Cxd(xlLC8_JoA)h|u7)usHtoaY^da3iZo2AuMY@Wr#d>v-n2p zE=kU3sUV<FZ%0S<=dyhq^<i_`^MfWi<fw7+Wr~aG{$lHQg%l3Y&#J`puJri&X#lDV z8m+F@{tf-i8b)Rkv~BN_ouzgYcsurRdzTx7;hKRTo^AOqE74~da4sHwzfWP7rAs20 z!R;nw%iKzkj{@1L_y`HiWh<q>Sx}v~LwC@ynJq85&M@M0cQIV;vK$7fy&k#`=6tkr z7N1ozJoJCRE@doPG5$yt?_lV5900BVo8|D@ijA$=*}xLWfppCqo>H>OZwn~mL0jAz zWx$ru#nMEY@OVSB=~FJmrhidAR|pCFcB&-2B*6j-T8C4aSM=-<#g+v|7Uo*Knfa3& z$8t=4V~42n6V4sQiahU+0|&k195vn(nVKM(Z4=^;DYzPaZ_0Tq7+-#t(JNSTBw#U6 z^0cA7Dz`-@E;x^F#4VQWztfc80Z{^(sYmp=%(<N@yAMKExb06}#^^m3E<T;0;Gi#; z9T1F|Qdsb{W=Bl7uSE+z3`d_l1HwP(@HFI0=Q{^KDs2<qrO@UXr<eCQrzqP&AemW= zbEGmM7mu5j{rSJOd4iIoQPfPOH%X!!wfGVdWm}+RobNZD#N~5@jwaAr7PAT2@9>3v zg2Moj_N$HnpbXx^c=`=-s;N)U7e-W1I7Of_s!fQPXBK<ZE7-WwmdV4idAKUZ*Vels zf7VZ`D-hR$&fTrQi5<<F9ZU~=uj=SAen-I&MGVOu93LJj63wWpYm5}ORg>^w9&LM4 zk2=FCk(7M5qR_btYj!jh#6LdR6g;pXxVXeJA?<QP`;<Ki7~g361P)moQhv=q(4PKC z_OVFU^5n?tV`+FS;^GUGn-+G>)ziCp22Ms$-0qg?C7eqhl@j9@?ShL&bUcyS#j!2y z*_DXU@nrNUYcD-5y`v~IUqykBGU35B0ulTnruc}0J~)7&O43TomPqSvvMztNWTy(j z#gqI8f}a{wYOuXSCidB!NyMP3oH~Z*S8RtY9))KYn@Pt46)SJ$$%W#4r=Wo`viX~{ z*r^2Cv<&~+59k4c-3ds^9>-ZSwY6yd2QIf5{A6C5XC*?<{3W$?kaTm^M)91*$Rb9= z_8TiENSu?U&4x$MzQ=ftL{=8s-Y}C&4;JnD_{!aQNAHL3r|?jiW7R|$3M%khmrUu- zqg#<6VhoFRkh5*k_71yELI!gXOOBcDw}N)iSfUo|HrNITw#fgyj6|^kV2(@7*JAzU z!>B#^>IG$*=${De<-eR*T<fzJ*x-xdZd7n_CdU>RtDHpz^zizK^QFK#q(-Xo<#tu} z&^x2h$*9LrPVnYgF&t&ZL58v5U=Sjw3X<xBW0S!i?$O@i8xED;ehowmeUe^Z80)}J z<TiZNjC*bkP-+q(lR7W|00r{v*E1+oW?(krU&^RXh5xr^igESI&FQd>J1F<us64J# z64B;;LyCY8Y?1L4naY)wbT~9`&aG4McYXhu*Gn{1cONCmLO|w_2>JCBX%NUtCzAu1 zt3I&KUNBhVyZv5zlzq=Xu#S2UiDPBYO8#!Y#u-XlPOEPDqEBXPWqQ>ul18@GKTXez z@oS$hOJ=At-NLu1EX(RMVrezzb-DR(F};Bp9}uozq{+RzzkUZrvJO*R`JMQLf;s%% z=iQ&>G8K)n-~8fk6VDe5G{<u2udcMtg%C`Q)^HeRY3kS@ZS=3&5%fi`*g4SLTgU=x z&6oCgi}JKO;L_|-YdnV(=X3O21%5yj_>L(4_pvCy;w#>M_CkA}op)P4{3a<$^w3-o z`xjp2%s8c*qOkA>kErzUc-=sQAl5?Nka{kf!#^}Jx!(-Sl<d1Zkp|WHq0z$*&21-u z#qGM{BO+|qxxR2m@#!0dVW6Y6al{x6#meX;o%bAhnt0nfEmIN>!_3;DR9hnRgWmsB z7SF}He!4)%{oO<|a$>w4&_S8Esk{V_Sl4LWOlKh$wjk?Y@PtZZRDd|TD^;X9DoFz_ zh&dYaO63)?ci=0Wym&zLx6tfRwnEKx8Ur4Yi*mL;2fPWZ#c?q0ej^yvjepGN{mBaY z6pQB^KN0^QdfrYW;C&J4O6$O39uxH3jQF2oQ>MJAh;~6zOjroVQo(ZWdlS2rlZ(37 zFZ@L8stk?=X!AGkdx#g&XXj#k=^Sck8f3{uz{zyY_Vz2;+R((Xqi1+H=!8@S2Ph_w z(;m^}S&~&u?GZxQUaf~1zDWQDpbuR~99&!C>$j+v7R%k&vVJk%@&k17z-Z{N0GV6b z{;@KE9PO;|_1T2eP2UxnEv0%`T)bu<_;hM;{~+5>P>d(3kgqIc4-5Tp5!;_Ak(X{$ z7k<pQKK)O1)E}#v*m$<<5L_c@qV4e9OR6c5o=c(~DqR~0+|)0~Rf?}QJk(o4FBn!u z-ZZ$s-N*c!Pq3afmnw12$)*-%^M~wHPDBm5NY8bXoV6D`u?ah1Hfrwc<0c4QDuUt^ z?beI=h2g;VT`A!nZm8XP4Q4br+CTt^EHBS+b}3189U))H^3Q?oKk!Ts;N}GdiRl1` zUhks|vg2Qt`S!_gYJoF^2>sO4j!9RDHDd|X`@`t=#GpZE_qr1MYxz4n@<>y^IR$KP zCLd+}Ca<ZbC^Ki04ueW%T0c2$*pu}8#<6*aXJA2s(OV{w`O4MwKe^|n*xIfsV0qah z@;kB0ZRbZrBe|NN1KxNdp}2HjZKv%*tXXlx<#oZVJ-8Wl1UkEfmE<wjr8nODnD^(! zS}s(WM_`|SvbeR0s$==&?3G)C4W426o-t!TLXM-JGzl$69Q?B{UMu6WL%tjyUf}>q zP~I_g(iecQC7;Z6_z1%<8WO19w}H%^688+WOsIvU-)i*sk)Cy%RE#lKT>U83T&c<< zU<L}&^b_s<kMi>eKPrBn`RVy<9Jt#!z%*I+QQvqIv~XYGXfBv{?$6Dc>QsZ$*ZSNH zG-E>hYW_s3LF1j<P3=wEP~IxR8D7*Fq;wR#<ac_K7is=4!32E;R|1vguk_T7w<{y6 zXyEUK3;PH+kEQP&CAXI;ii5E~<p@s?SmM#)(Pz<Y5#C;1S};vgzn+J?im9Rk>0s$R zCd$jw>#wcPqN+@swKz?Zo%Z3u>%z}dWRt4{o!sXef0}TVgpF<eMV$-tuz9SP6Q4iu z>A+q`zGY^C_=c^oyg{;HV(WJhY9vEW1Ebw92oi2H_F)%f=osJVuFU|&hH$(5LP_OH zUg*RTczKP51vX#Yhm_j0?Oa%}^gNSl2Zt#}i3)XP9moIFQpz$0M4v!a!f#G*@p6!0 zo+V^nxr*8m^sm(!9R4&<@1pr}pQxa3Bz$4ZHZ01<vCVqn@2!hzg0kPgd7-0DE(sm% z%~gWbOYIK^tSe!z>l?jyU&C!*I{g?c9uJ$mRl><csO|(Cb>_eUS+?_6qMY@Ny2v2+ z7LrHExKuy7CX1P&ZfDdlcL9$g4>aPzDL9wD9tFN`=vqDUNVzha;=7f*=)h2`K)@(u z?zl++jh2%O2gC%5k1xrtrAIR-!xSOkEHZ6+I6E}>i%mXb-9x0Q|M-A&b-bp2BDQyK z%d2;Y>0H2zasBNt3E+M?^+SHF!K$2SK=Gf6gLw5ET?n{)HaFfVl0o?Vwi{g1E4TOw z#f`(B>Q3uQ=C->*WTgF*M%#`WuMj#;C!~a$OWEZcL*h2gz}Yhb{)%wW6VqDd<}kT2 zzg=JO`RGwz+X?6QhM-p_K_w)_!mV*^YZ4i$aI5&i96|lsrzkKuUTezIV8){=5n`jk zOGyBpA!hpMV`lca<XhJ@Z3K4OI|U4m-##$tzeCxS(_B>|AjMB??h^d!2f{@*_d#@| z>0l?|KqOqwRDVr=!8TC65jnQ|beaq3u~{PJr&3^6k&s3@7%6lJ?{b~k8MgcJ&k_;) zAnvNETlQV)x6JII;`-}UJ~ynvjG3`%YMt9s`XZwzJpXe95%6_s5`dC1cYk~^8_qv+ z=rT!YZOVa+yM6I9xL)Q>b;eJ%!pGcKr=0=o)RHffAp3#HP_mOyV-uv#|16Hva1NsC zkC0RHiyf)1^59{3psX0sDYlunJ5w}x#%UK3pL<qB!fWBhUmH(%JH_wIY29dIDW4Vu z$^?z=T~5x&IQm$~|B{x0nU~|brQcyAs;K&qlMWtY!swg~Cy58?<x<f$4EthOi%e^c zZv3?YR{S{8LFrdjFVu3s+t_{;ju`7(lORwUPSlW^?-SoYGwcHoMD-V%#{@iRyR&yn zy?uAEpMsJK38|mdFX!z)cKp-}wkf(P$WAN<!)Gw<ZhVVwtoOAKgYiC7O>^L6@0o^n zj~RKh<U(|2TWN8h<P~^{(x42Hi<s!7Vqu32VNRv>w~FUmdsB`+AAnV1v>tk9r~23v zn0VK#R!5Q%Cu_IY=*dpH{FPd^0BBs_(7kBgs;~&PgFYQo6#O8X*BgN0<w>OAwej0e zD(y&me}^YHSITPXV^*z0pjeq6R6d@$8vB^62GoiFbk&(bVNtHmk-Ar8n&m+^e5}X> zS9*h&1y(dt8i^PPGR>BWONFi*iRrRlBMMO*s(vYSd?c&byOW14qd(zlW04T3=a2Og zUHZgai09VyKTtA@O?;Dbo_eE4wkM3kH&H)}1ZsOuINGUO-(zvH-Yzx2@x!KJ3@;)! zThP_hW)B}i(Z}TV^{u^5sQ1`&V9{%TD>NrObb8ccN%O;j<Rnu#eH<7(^9TnsL)s>y zvH+*7^9t%|uS2+aLWt%hB(W`CIfaZ%KO-bmqoD4GBcFPgxde@uvPNe~z^sxwQ25f= z>L^}iLfT<4qcsU6y{!`fk|>P&Tlpz!Us>&$)CCn}+l>UPI3JUAP6Dmnws2(7Slv77 zC41;CVel8?EyJ!@LNQE75eag$X<*3o8&MzhdY?i!RAt%RH5YESqmdeZtXUt-?*gM$ zheyR9dkk8biG1kvU*RavJ-UDwM+oPt{y}veUqBk^7>Jl?Js5mmQ$|jIuj~suz6FOm zv85#4Xw|s@X(??@#k>hTSpx@u4te7Ux}>W4J1WnS>-D`ptQ|&+9Cotv67Py2%ct3E zbj>xlV4N#YU6YqsI|yacCjKh}eRtG7zvBFwSjrpkUb{8mFL1OQyN}~TBxInUL_=7f z11C47+)}>3&(bwE2gO88bT^@;_MK$oY#ZoRKfk4<s*vhMTu$rj#CiuL?Y=}_L#F<x zJxUVKwwV6o4|GHrA=gqUbhg>QEndS&9I$4QJ6^80n|HDEK$qMZrWa^()SgbG8eOpe ztX`Wg!Hw!StoQo=#Kex~)0#F(OzAp4=<4}l)<)K@R!Pm98ME5%2RwnA8p}9$E-wAc z!A^Noi@!8}x}Mf}4o*VL?6+*IC646Lu1=^*SHTW|K6Ei?4l9#wzvW4}2%4L)mldQL zQI)a3(6F~+mU$C?bc2%9%NfrVx>=^$lYB{4`~}Lc#O?7sQn?cFMt^-f$jA>?TU8q) z?<^<WvvwW)*M?v=wm@tZ;#9IJ>s)2`QqQYuLIG+WB^U#T8{p`GX?X$q#>?HC+7K8y zMC$}oYW11Bm7UARi2cF5i_1}zAIqG)%Vkxw<86n=k$M{7JWFKx9&Z?(pZBEu%Pxu% z4x2U#&Dzy;E}UGR24mjzf^^ANU3CCi%1l(QvFsvv$T>;?ie-{qy~lyjf*~7B^N(su zA3n)G7+>obG-3Ecy@R#x1EGGB0_&n-5aMARO|-bbw-!e_@wz;E@K0m3NzX4*5^{B3 z0l5UeEdZKOab_1*88ovR9l|^{`_=3Jk`{8j^ipMB6IiF*__^?%$O`Qo_`_hWs*eJ+ z;^?5$2{NhS4H#e{7dw!rG_^Vqv;T_5&8dpkFOoU^J74^$8tSV_EMsrqB!=FN-!i(! zjD#{Hy#=Cg>N(a+n=Oe14{o&aL{KexL@Kp1*mfFI!+mQ}1*<`ng9|-6E>GfWWX{bQ zz-R*~qK50;OkZS?+d+gix?6Cv$8^!-qa!mWtzY5y(HRe+%`*9#W-;Qglclh1m^;<- zC;M}d?N+w+&4n2PBZdogeb39nW2OT%lxaTJx|Y`wXP!ZC-rC8!atMkb&GX4${{Nyr z+cvjyuWC6fnNQ)QU8d1=ZO4kwy2Vs{QbTtnO0YaKX0I}i(+nZ9l)B(8gAZ3~2vCkw zMJTPfu1$0j`N)Qe#}$<ng1HLSsw0)gYGaAK&RTd>d;Le^h71-?1XhELAX_jm66UW4 z6??dx<Z+h(NkRf&M};Iy^A5x*LkeINe|4IWRU&h*lyXzbXHO<kWTQ1)<o7TO8~;pJ zun1QPD-YP_T;dK6&lW9Jlt^YdT*4KX>Zm}LEZ|5Uha;VJ(hRbfl=!Gak#o-3HIw%| z`Z7+u<H$^k8u+6>ZX{#Mht^EDMK%b|Jq;sK0j@S39$8qze6$#-=r7^6qxTL=n<-rT z;Q#@At**c9yGNU3O`lc}AeU0c0@g=)<6O^N78|x;sGTgf<u%uoIt<h7)2;z4PNNPu z{;A#y6GfdDa#iQLNlt!xY2h$6gMWznyHy#svPP=`DwoR%pes75eC)UX4zFUwUM)|a zapNIG{X}ohH}<IU4IP>z9g%ESw&o)|_7e49<8@z@^qs<{LbXjMJ~yb?@5d%>Dvzqj z1En_+|7}0~YN>N>4Z@u7on-EHpuD8GhPso47Li&B6b%dq(5a;2@);IysM1MqWod;V z*ry<<PYureba&6qik*lR(tQ*g&;fy0`yk5lGg}`dMqn@6VnvZxyianvFSw2AkEC0n zl4(<{UC{3aZ^2s?HDBk?FMQA(GVyh3q}iwzezhu|&+GV1COVD-UAR}*@nnGohR?gZ zAH?k$ASFy8r7A$NC1v?c!Z#qR@n+9=^O>yFIA9T-!;JQROLBl>V&^}s_O+_x1Eh9E z`ZT6sFNJnt?tn`ZvpUL6^-iBe@T6!mm9U$$053#uVW3YR6Qhy?GtgLe#z)4Abztqv z?5C<Pc2uv2^gM8*7NkF}+}5?nv7&Nc3Yn+rl#jzKPN8w0odsAe{cE`e(P?|awe>N3 z5*AzLAb0w{EklwDkl9_k%qiunC>}I*8Zt<P_8|N898F?`{&;reHoy5Qrb7-8Ya9}> z*BZ{n%JQSQrxdGi5$S_Xewc*ODVW<SIu&`8Bo(SsKO}k(>R{$VDx>@8+LlUp>*h~? zYll8FOUiX*($))Oc5ukGkS_Kir9$<|@#umHL!_9T6k0myk(<G5N1KbjFqk<h68n;z z7>8fd@{!N*xa_a@lht;bB4@I33L&lFou-L$p){o%0_VHA)IB}f^Z8EEPbhq}_)~q* zB<{)rjM*`@oLNYjk=n`jFp_Eh+5mxNpVUQcVTu~6TRbdR1P=FiI2(fKIBdp1C8qlL zyhvRF6l8&nulUKyC#)l}9sW!=_{G&^!k#pSqa?Z=_jT33>C5_>L_<GQ4kz|+la63z zW`(rt>mWoGo&|TaqO^@j1x98$2qnyo$GC~{F&6S}$nJQM;5rnyZjB4v0n@oF?+V}o z;DukA^`dd<+Beg`kCpyWHDmr?+|ah&!o^zw4>cTany<*}I5|IG$TShEbnsVa28&d6 z?hs{{dbz)2W`+nXh`UbY%m3mP?n?%jSsz0W92vxtYow0UZ4-m?)#u+DM4BuOel)>c z&tw%gO70dMGuM}0r9O&kvEs|j!5e5WhSeWYZ0j=IMR%=qu{>L)gK|ph1?ne$uQMq( z1GWaBL+=$g1L{`&W)z)&Js=pjS-SYVaU?vfQeXJ=kP!JA<Z6)fC;IyX%2E&Wh(b&w zo-BfASX;YNl^~=B3JG*=?CS0#+Jtt=V?(n$9UJ)eE>2W=PJUf<kJ@?*Gs#D^S!o7D z4Jne&7e95nz$ZX9-E|xpp4r$DPuh9na&n2sujCNefopj}8gm5ui6;%&Wqx5)u4s|~ z5xJAjCM+y}&~lcoY-Q2sg#^dL$#sK2@VEqBe8g|#tPlaipgQ$d4%$1O>duj@reik3 zLW)QCvdL8GIn?|)Qv<dTgs>7e-HLeJxxW_FNzr85+~P@?M-S1SXlg@ELT?O?E&D<T zl@+TLGvm)xa_H|<^Twt@JLdT-Y`L&dvdwBTdU@4%Ez&jf8KjkR1sLUu(7HNgD+Cw) z6-0Fo<yCP>rCJIe?f)l5Ju9qXr+GCDADLPmj>>tb(Y@DcY(Ao|Q^?n%L&50SJLKE5 zGrJIyY@We8*DVsy+~io2;#|%4ax+t;NNC&dJmbhpz}Tq9P#_IG;>ix(_R;3ZlyG9Y z{x>|_BII^0vJG)nn$|p}xN#q6vSJ?0qG%WOOickBiGixC*$XRnEvV`WcLC}_&=AS0 zEDtGeIoSq%FVM+rvB+_6k~g88TK+G_0l4wa<)RydpWjqIWcz(?VL&cUmSe?w#oi-8 z;Fx6uqGaVJdW9uBS?D|Qk@*o@Y8S#0qw?7Yg<<IVz~wng$>v-`83vD2e$=}<1GB@h zjzfyFt4D7J$Re5q8Vq<K(ufg5;}~~W+PitOug&O(4*q4mk~j*UC<^bhLSJoy!6L1> znT$S=FRka9s1R+%&9Swnugje_@;1=%7a2%(gG{pqz?PQ)@_N~-2N+}gEr)-ip!DDE zJy}XbU7QNk>Ql~|n;`S=5qN$e!Y*7V?9PEx9+KtL)}@`!;w#5#x=)-3!)s;!>|`=M zs)XLVgK<aKzJ52baMi+KT>Hi=E1=~OpVQk71;LI2M!55ez;IGg!($v}HFrh(aSXZ0 z&cMFVa&A*e`DHwI1|~$3+twoqVqsgx4{wiMx0hzCX`fd&wr*@TBq@5;%%A)8G2sNE z-<i5lKGoSC5eRoT<XpQ>H)i7`b+!6;WW{<aq6TJSI+h(Qoieg8v>||*Z<&(7bY*cA zQbfrNKJp|9#|>;Z3mN`Bzf=|-$^jG_Jlk1JF;O1k;s%X%K#4I0fXc-|ppsY6gR`ga zYHqNu5})`A!e5D7Gj^`4>2)pJeH;{~L;HMwF6i%w%?Ja7M2)353_XUFU&9K0=h2&P z(%u7p4u0wE6*OJfmn}R>z*8J{wgni0b8kL)pt7>p{pSHsPzf7EBh!j@q$3YF!<;0% zw;Q%Fq;iVNTY8mro(6-@D&r37PGaIIL~eRqw{Zs7+zNcGK9+j}XkN4suxTb?Z98;H zi9@(W<o$&e6e28qKY!aHnNfua0ysz5kC8k0P`>++UD9x&8M?Ie8X4(IKjHe3yvgf? zC#_yt_0S6qV_Y(Rg6DwU@;LWEu&968rvA~#Gk{}$`WJ`NqC!JGq;o0C-Qs&8bbQWH zP3n-375jD3T56=0vEx#{qa=!`ZEr<l9~r+Yz=Rn|qxi_~$ag(-uA`8n3?$b|Z_w3q z1=7bJDCZJY#7HR|h{&a5-RfsTTC(mJxiGTXwQP96j}o63_nR@uKg6#@RuQ*AIOZNX zRgux+M161T4LaVO-K6o618$2aBYUTipkI)(XS=>ISt}}2(QnopI_SXboe;sh02qR$ zX)c=e1EPch@nc52pLDL#UEnofs^V0c?lN$bjSA$ggI`?`Q7MKvXCxTarJ3Mgw;`S& zNU(L81OYzt7{rG#8R}Z}R2ymxSAaq6LUP!Is>SUKNXZL0eb6U5XP2zWEtkSZP6gku z(F%zgiws)qEsh;b9=<F5;a<Y$?v<XL@4?L%e(PYT$|(hc)Ev_tSK-9)^A`1h|3m!) zE$SKk@+FIxhJgFLd_2{U=gPle2qDDdKbd9990tMjKI!!Nn@GSFiAS}8aRqbh7lOYb zfs_Ay4BA<dEmw_E?k{sBxUf+R&O%4%DCo03t4eAQe9wan>*<kE797<c6f8RNl{-rZ z?UHxP?ypumM!zB_GwA|Win<zne8H(nu7d^ic%Ccc8m|$?8}9XHl(9#`Mj;hr><Ua^ z-QQPdHn?ym=i!YQY>7tZmZ9<GmSa8ol!Qrgp}D(B|6M!XOv;b#X+pWQJD`>>LYLm$ zC>+4@qtOqTz$Ex5GG;nShSmgXdR9AMry@9gChnIhELvoVlqO1#B>s9}9Z<7h&Vd(~ z5Q?;nm<Q)cz9G&ZF`0P@En9*Dis_0HA)Q+R4OP_|2-NxSo2pX<CUO9;!SsjN^WWd& zvb0*6f?Mmpae0vNVOyk9Yy#2#dpl4J^;eyNV@3=uUT@h;ND#j8ePp~kR|2Y8&=S{d zZ5@_h#`Y@2L&j!wrqAaNw*P6J`A~zxBzPhEj$BNCXSccMS8hEpg5Qszep5*eF4>y% z`2dD;L}zB~Uz>&)spI&rrHyb~MPW~IaoIXX%^|5YeI_rDF{`uh?O$O2S%J!pVqmN8 z@?h|&ag(9$YSA{%Bj9qFM*E$EW#btp*&mp=5Ip<Rkq*7(r6`;`crn^d_RM3*3kn)z z)gzPZK18FkZ9)KE5FPogU1<fwEq9HSiHI)cGRP=fd6Y+@3bC!Q5pfiWa@YYgs$`_+ zdcVrVXl0u;25$ajLM#vjF4tQP*&MZ(6luaU$<_WYW+1i=9PBWGUoSl;BZt6yAyO&h z!5d0Oz+<i|w6Wsg9d7Ma*s<mytgFPI%=6q9Pe|JU`*&c^9ZC=xx;)%pE<cUAQ8*AW z4Fe@TzjDdQyH@-jeqN-)2@b%{Dh8v+9FWJPbu6Pr_z=kGJ$`Cso*lcNI2;Carw{Sw z#bBS&Yk%WxxZp`->Qv`{+gIAe>gItUT<Euj=sEKsQ6iK}(+Ksu9NKPGe?v=$LsFpu zVp09C-T7$zA3s<KYqgN}7Kxm%s|T&V0^kvv6Tz=s<w<lid6jY|g)fL$cJ<LMXTGj& zq9&3D<i1xR<Iv$t&RB{PuVV2cD;g%#`bD~+4$M;Pw9@Mw-8B*ZgLS$X*ajT0=)C5f z)*C_!jd%BXsn`_Vs3whqh58pN0{mepN5yk-S6XNu_CaF;-)|pr@g{$nACdsnC$$v$ zS+93UhT7*PEC#`xV9zya4f??!cPyNJYS4A!+HCmLV0BGf3jHO4o7CFU5X3g;VpOjt zN{UqplH<lWsv4q_*jd}g9!T7sgD&+O;h`Mf0ZDcV_IC%x@V$N`L2&ST)M&3jFt&MI zU+<S5Pta&JD693E0&Y8Gd~BNIas&<klOcKlu^l@)Kd=-6x>R(000&Gzjz%GOBV^+l zCvkwR&ws*e5&YqgG6;WO4h888;9=!}qS}L#`VutT^<R+;KotUpm|s6oBzL7yx-MnX zP%A^nuUO4TmgPiOk*Z8Pe|StMpXv}Q!x_wzabdhX*hdbcEN~9P0g~;ielLn-zU6RS z6@mpQRxkmfiJi|LTe!oe(}bEupo0x@Hq^W4%>YF}y1&htqzPC|kS`u)Z?XqnRbN#9 z_ZW$o$*=vez_!1&*$Tcjlqo$1{w=Xg!3`>TxVl!%ZG^VWQ)qh4n0u(2h$w;VrIexx zmp#d;h)%~}gq#xqK?$|SbR9n{EBH7eBE6AZeSn=9JX(IDf%UP7Ro$~`dw@Z1G5g@v z2;&X>Cr@A(I(UcA3YJkE&E(ZOmu<Uv-8*luqE6s25K=#r*UZkptUk8t$k%-68l>t3 zHuTLxNaw2zvq!<6r^d-OI<HQi9Iv$CzafV{RDtLVpjA@up|56P@6Xi-OM=lSR?IJA z4Dxjf3Zx4<zO99Dy!O}g;U~(gvIY$FT%qcbEh)kk0U2aUR%vxY{%k4ZOb+3_a}BQ^ z<Y+iS@GVLw)=Grp1l86W!U10^scFS5`rwGHCO6g9P%N(%yWPAwy%L<`ud=a0gxQji zBW0`b4{N|+>+6>n9GDkW)H8{cU`))HP(#Xf171L3y6!zAC;Loz2zOr^oz!SWO~)EQ zX{$)I{3m$_=n1c2qCZO?MF58^2CQOCZURm{Svqsy_S~*;tjJ)}vaJrMId<sS<v6UX z0Y%d4U5h3Lg%;F-z5cXvN8d13UD13s72oiU-HzAbj|qTo-3{|-rRO(tsr^j$+bksH z{hnNyB4^hU@YolhjdKP_ip<BfCemp$MEpl@mJL9zSnfmT95p+(?7)%|!P-akU*KN8 z6PG`-w?`1nup>cmxm8eqa;t&vfY(3B+PVG+9hZNLV|zIR<pV^oQn-$V=dlJdXjMqX z918XTH9fz>g)7W=t4JqU2!s$|!kHcHO<D7)&^27m{f0Vt1*2>WfON_bLdR!Pa0hRd z$14+?J^*ij{ThTqSg@ksZ#qW@-7fUxZ8Ps40K9)5FQ@af<Vk@HEcnhwUXR|*<^0{> z_CN7oN5T54fv*Lcc51<qMLU>p8RpdqNyzGIXn_gRe8iD!;q^aPE;gcH8fLU_K;?}G zP2K*H%|(p=jNrG~36b2_4gvVUq3NEGjgy%gO{M5eCkQz1_weD^XFHM~uHY{vZ=^%s z2VJs3s!V!~pIr0jzK$(Q1H-zM1|S|5O%2~Wb5@FZ;oEj%*L(#!9Cu=3Y`_6Yau~b5 zRI7B*m6$Re`~<fCo{$S^vn2RYW$*}0tS)IXPLC$%>qDqswuMR=voo#xe&6cG!&<*I zKb3FT#c%1CW|zyt|CKPa6d+BL3%fahO_J9g!P#>G3%zM$q6>ySO~}w0>1H)eirqG3 zhB3I&G2icCL}F;`yNTZ$+^EReu1p`Ktm4P8?XUKbGyW~Y^~B3nq<)+oS{2neePZQq z3*r3x-+Y96PWJ<n)^Uo90-;|9R;TtZJLEuU$~TNRyt&ii>RHXe=hO_`L2dx!#BeK5 z<tGUn>{w(b2T5^7LufYiK}E1YJU*ckzW>5MR*a?xEsy%?n{GoAPSbHS15b#%@BmFH z^NX<GZ!L|wk}~MB*^cb|a6JuY*|od67-Vo5LaA}JlCYNUt)p8+XcAi}1==~jjo9F( z8%>!DwM{A)DNWmOlD}eW)!d*||J0vs(Za;JccGpTt<XvNL$C@O4*AGdEc?gKw8h*? z5U>%yqWa+HcX-}tFbushC)3*gU#W|>T2oQXJPZEyC^a|>M5q%s(59PGCLE*r1Onmt z_iMc!o~qry3RLuf!3jqt_sFqTA>BJ~ECiRH?{3p99!iyS)ut@PkaNNp#_^Es6F%qx zZ@eV%5_+uHuu9E@dp#;nsOeL;5g^~)q9Y))CGIj@0`RB>MdUocMr(AS-{g>knEAo} zez$1mjhV(iSWz+H;vE9jOa}-ia&7>P%J1tM{cp1n8_-@12H2SgqFi4pBq-Tn^yv&% z21lDNi>)p4g*!fi1k(rIq*1p3R;3lMo|pLEaJ+A13sG-mBSN$6n}pbxap&SqWI5I3 zU6Cs7WFYVyxEcG4-n6}f?t#^}3yo)pA6vAWnrX1-bTMAz=Ed}d{>W6hg({k;$l!$0 z-A>Q6l(`1mcHpVkS|<k?kP-t&PHJ;Y3Yg7|34lX+m){h7)@K9hH6sagHk-Xa%E7Oo z#%`2cl{F{fL5u$?F<sX>1W3d+l}luYe1oA!3h%XiT0b8!j4oB=AoOD;9X#Z3iIDaO z`Uqc7#LA7t7}L{Y$%m@)A7!XARpUR;^PNeT!>W;2Ke}d~g~--I0UA}NI=$Nw?`!JM z-<nBMYGa^*{cPv!reHGrf>(|x#&&>ZG^$=cn|EdOB%d{-&&wZ_@fd50eTWn+tOA(c zoW9?>-0(mg<f~5B%%|KiJAP~6?s#OZjOH~U+V-ygCJa<kmIA2XZl-LaMUBN#KEJuO zZgsN7y|Ih_!Zu_(K7L#kq*lBsG(1K};In{sPqU_}LY?>N0&M={o|lb-Vg!6YrD!*p zRoRP5EYwJE_`kXND)J)IOdH*6Fz+ovy+_IG9D>_3^O>f(ir#c_X09`Nx46Ay0i?W$ zIViF=N>()P4QEcNVqiYSfKb5GbO&Y%mv`Y58_KJ=wuwc)%8*N5<L~X;U7qc29`|u@ zG&jM!!=e*OAm2&jB3b72Q{gcw+Gh<fV5}!i4CHHa#H2X%2TMx|^nJZFT{k+zg#P5A zEOj&u2-b6o*k|U^fDbTAA6ORZS(ivig}NE#3rX_+$)2aJ<|rsU=n|e}n{U+&q$VYx ztk5g#fpGMowd`QPV-i=Rsz&F|_eJL00dx&23JHYXk8}suN1BfFGMyve)Xe+60n2Rj zr>*&KLT@~!m-d#H>j0H3HbZ*|xxg)*R?`{x9cNYpnAhet0p+!5?NXo_6Z#*upV9JO zeAL6hyQ`vp70ZwH!2f!t6j$c+fe>(}=2Vqo|FMM(vSvAZRFMCOn72nQ;)gG{tlduJ zf0x_-IioX?Rjty;Sm+SC-lZDJxzJ8LMdJtF&I~|cQaMkI&J>RRmeDw2+BLuf^gV{0 zWzXq1(DD#HGa%*oIUpLiQ(4HgJ4mwNM^rW8vGjn?-8|a<smH~rc%(Y9Ai{(2KJU(p zqnIc}Y2tf1A9^YW-Wdky<ki0n$^i%VV&!@zcmEt|@|Nqp5Lv#~!?Mai;`3?A-Nz~6 zy4~{};r0=R=$u?dUpXk>RL8GG2^$hd+qc-hQC^<$SIT?VU%rK&1AKa}+dg)9D_{%$ zMgNf#^6-e%_-kR&=^s{Q@`tAe@;+!)?6o{_&F>(P8j>OFm_A|0S~D%Ye|II~fpdYc zY8A~0RCf32JAn=wHH=SPTP`zVi3S5em3p2FH1(?QZ40tIgB&3YcUhRhPVV5N&1Nln zw{xra6U;E(Up0w)gP^S!K6g9W@spukO~sa_>Az=(o2tw%*at1mxpbTELA=Xa724n1 z21Uomgb;N9tQ9Gbl6g$I2IG+*7v$4a!XT;?Vwn3>g;{8#9nR(y5hPOVN!)5QM~W`D zSE8`SDPK3JfMwV$0J!wHl8Ii`(0U&Pho7Zqed+>RWQ7&QVybra4dmNB->XdT(_K^3 zE)6=br=beGJM@-Y*b2WkddnP$<>ZGAMb*FL4%HRtlIB+=ft1l&vA192yvrbsyT;a1 z_@wHtRqp_dfU03JbEsr{>8zxe>vZ3thr6EjON`3(+hwd_2&#BacU9Xh(tiHGRW~5A zyD9sI5Qc^KW#l$%*BnK+hxd>ZiaSWbEOK%uw*DY@`Yl{bZ0+@B{$_m(c7MgauLMhC zX1C?mYPQL74BKnAMd25R3p5B%p-B|CQW&r;*s?&KwodT1&O9>C7Z!SaHJ)JjxjCeX zTk0E2`;`STvzBV!7^Z%_@es{IUg4M3xyGY`Ht?6elSo&nd1}y5ed0XWE9Q*{)CZPT z@NX(xA=Ra8d?NSUoskw?qJ2WEm^~4T72OTuq-_5;T}%fP@?ErLIJj<0a<WyOH;_HG z(h+TX<`{w*v^eH|ZivJ_>v?(KiOSl%JIC9zO#=i&wn<N-K!1)!i`*FG^m@(~NYwOI zg(s%UpHA`Yl9}eVKk0Cel18TkRgqD`p2M0(08JhI$k#H8a;HuE#LHSWsMWK{i?PKT zg1uJ3ayb;57wPq;>f**`-vEZ@&}$1ongA<NB`S=aTViMM_HM|sQO>1R7;N~k#S)XO zm`i!1cMi8*`}0Qu#zG=km8=ta%6Lnf#9#981FG!BI7B&H71rU2t<GZEC)=2Fudb}n z1t}aKrOfR2KI~XxuMKV5BQ`m<prd`Fxse--cUKs0B|;-6t0h9`6N{)qLoi+P+xcdW zaPJV0MP)GgTity+))U0aLqVSlNxmysmyu^Jk>i3)HT~iBGcmWT9T}vaux10fL1JUm z$*xQM8}xW@0J2gbc{QRxRs0?R$hyJ48m29w@6}urk-Z@)UPAKU33cG`8%@j2DUI0X z4E{xTGRjX|Ex%&a-Tve2o%|gvaCO$x1)|YY>6+W&R`vEfsvo^*HNwfCuG3%s!*c}; zC&}MnXSe-t<^sR7JqE^I_`Jr3J>5CD_jOgEFTBWNwmZWX@*|PB!l%A>^9w&R*s|~I zBmd9!mYb~dttbrIU-i6xpeCi8G=mgyVRM~#u}O_&0ZRW&V^+*`xg22c4;5D!vv=+Z z$|oJF3v;a%3}ri))YXkNUsD&QzR87E8Z#&RC|9&xNs83eqTo^HTD@NJ1s({hzm%jw zaYc$~s^6Raahp#lhvF-A-5n$>@i0)FA?vW}4FwK$;3k%k<kOsV0&(?ZV+;e2wpzan zk42ppDOM~8p}TeFM`XY9hrjE>C+stX(uO=}M9Ckm%7}GX;OB344z|8$PiHZqA@ir! zXKI)x2ljRHsr`U$w-2MVG_-n(#1Z2<2J+d=$)AE<q6V#SPZ%ul$oIYzD17S`Yg6-C zqy@q<O7j2{1Hpc0I#c_v&nm^BaoPY80j6%OMsTQc#_o6`57B^8DcGV$1ydm4k2Am2 zGA5D-$JnS}a2Jww04TQdg;RC+?8%t4!~N2zT_8y13FLHQ0~$v9=28h=_>`??3N6_) zhld~T8oI2=)6QSRaoZcavf*sPwV(*<xkBj9x;^tlHF)^nEWRXlb%06=871;C(}7_J zJ|~pL75)g4a{5aDE!N5j7h@=x^eZ0S{^Z<zAS0~P2}@o@p&Q_?qD}*9{5IZ7GD%X< zz_g&hNu<?MN)bHpxPt`>j29GH6?vWX<7N5KWvRCibpvoa5BnKJC!(Giu=a19tzA~} z9EPP+T61rX-vbR{4Kh0FLQCy=&UztSr_OsaWzs;jcG;5H_#*{djI!M%U-M8%7=O~b zFL_!o!@zwJ7?P*ATKQLVka9=}=qQQH+4zSgtCHc->?{yJa_5?4QlDBfTw4QuY9-f= z`g2Sqee!?zH*@Or`ni87V$-0M$pEF|4)^o#uZ~7p)G(56@Qee_6vRqF=m|L^p9Kkd zkm1)4sBki=9=t64KX>?T0|Rg=;LuF)VbwFde}2%@5eIp*)7~S`xN=d=^;FH_-bmkj zPoCO+LlwwzIQ1Jrp906MJ_BJOYj%yyRtJp2+Z(o!^d*h^ak&pjRHwW)7MrgtE4X}F zO2`g?iS-YHCnu)z&VM?V4k0#y5x4EJ%xwV`)$_gDq%4pyJJ2hA?R)QD7#rsi>7jaJ zX)$=d?mk2Ut{kP22fSGZr-8=WpX$YYU&PGg!>^Kexz-9SMGLK&8{DJ<sD_f-K(KxC z&JWLMK*9{E9MUK15ME?}pB$Onrry-i7(?Ypqr5C>*ta&n)fIaS88c7(?iG98Lq}a} z-7;5aAxw=P%c(-sU|MT)etd^nX+iD??$OQltn^p@2qr_VBi6mjIFn24THbD&7x0lF z&#}_m)zy=2pxU}!Ent`Y7j+%{9oLs;3jB6kSaBSKx>~Kz1<orA8-q4a^8sdDcnQ@& z*Cl7kq*ebu7@@=tR!hBRD8RK5f8CdCog+v9KmfwcOb0j-{@$GqDc+7TwPfPh?$CKk z#$mc^jh;q+H5x#0Gd#}8qeAb%x%R`77QHz-ck8mgj(qsX;hgG|Aq=>H;C26%O?#a% zV(>uxz|!_-lFBHq4&!0x?A6N}(KJ^@8<y(1yrX8O|3NO7Vwf~r$`Gexy{5?Ox8N35 zH%E>#1I_XtWe1gR5pHH*_R+dkm;j%~R;zM6<HQBtU=nqpyM@>(V#g+);WK)C%Tij^ z+#en7PcO^+W2SBOri#}*KF%&(KbrCblqOf`^l|&ZKL?;4KyBc4X$IW~NjR<RPTAD- zZUY;a9JCkBNl3+3ienbgDsZX0Hbt>hI?(doe+jwjjLdDiAW{4mL15gbYArQluwQos z+vEsHa94#lhVr%5cLWydGN@gaw=t-O_S~N61X26GrP_eU>dDiqwB3p}Wnu>?2@lXZ zDQ<inNpL|7GQ$@&<S1ICP5rb&<h@s3;@+vHjjduTXPV<|As5a>o5ACrW8}9TCy4{P z4*d07c$WL0w_6!pzq^-8j=+foY*%ln-d{>7Z@qb~2S(@Ir)%22Ix%oRhp3pCH)#Mr zNOZruW5HZw0I^7(2v2TcQ56~8Eq;xsaC87Nvz$6@%Ru>dFi*AnoO<Cu9Dcx$vr3|# zD_&M8)|_?Ebs44RR6ml(hv!->%ZyigOs`1v=H+e^SBr#$Tb$hOno<28{+>zu518*e z<!Eu?9dG9jzY6@ejCdDH%S!FYnb!aTCK0M>hP{SUt~IdIIj^LVe2Jz$X!Oy{>wF$` za5~3O5Gq(&C1nQ#rGfh!fRH@3s4h)4NO#!Ir$Hv8v0AUB-yGU7_q9DCM)M0d2m^dy zUA;{L7M1j|c%n8|XmH4Y4Us=`#f$W~1z?!n3d=bWQB??;O@dI2GX#sQ9z3fU>in;U zofPi<ODP4k(O8(KVw0@<OgbFH_W!8PHOHMI>^VN&I?GB@3iNy-a(7+_HYLuYZDR^& zk6&G&L5!LEA3F({N2+P~)(_2Qx!@xf;OYMr?>JSPU=Fge>#F`z`3eXg&;#jo<Yj^$ z$`h&E5rmG9Dq~~oFDme5xIb_|1YN==;Es%%oUPO)rXW%K8$){pW^Op+ky?6&Ku=fq zByKgk&#s>m%o)=}g~j^t3{v4kJXDvc5h&}SvI&N;;%9&~viFZ8XZe>m%yxP@UB-p1 z&Y$10<>&j_P%pAJW7wGzp-_8#uJD+T^5xHpnpX}+o*Xv%x_72ycO42&0pRh^jQr}* zLD}q1s@#YkNFTp}jtU;hA}K-5-!qTLNPO-wd2&6j9uJPx5}E*m&W$|V;s5`VY9DXH zt?`Z@78XMgs=A}}7+dYbD9(H(0Q2?<rEt%39VCD>r^nuosI#1i;BqWiUOg_0i(J%S zsF<<(kPel722|-kb$JF)c2Cr|U2C1cgii=9iN+^fGSssXQ-s>}7El)Z)>i(w&nOx+ zL6HLLGR>KRQ2Yb&YytfC@oW3}+qfq@-@`qWR(^qp|IYD8v3yv|n10Sig6F&i;b<E1 zit7W18K6xyz_d7BkD@V-6LLSS2BG|SmsS>sSpVdu;VfF#;%Htu+=l%+2N9Hs*MQaM z!@W_v70gugh`=o4B)1yV%~_6qIdXsWDFE<Kmuw7CJ?C<UR>X>@+cq7a+9)sJ5lr+S zkUF8f9z+obsec*wL<VXZ53|mpf+~nPEsb$hAM{v0m^Sm3pzTC#51AcHfHHYSEL-IK z7m+Or8HT~nu~_IvgzEEl!SJVQ(#Ib<WXSIzib+O0EcfZIrpmv3OjTTAj0rKLwYUAW zAP&xMgKghYIof6c@f&WV=ZYD3CHgMW7pQWcP1%8kfqfYMVm4s$vUC6-iqK6Le%%PW z-|8Z6(mN0r<CX`TIFrHMR^|TcC~j=JRDQ|Lxfc8j(?~q$lJhr}Uidvg`gCKkXBxkT zg<!)Pz$1nZd3tobjTnQX0!&u3HMoKC1{R$Zon@_Eiv4>Msd&4k{18JsrkSp6_j8pD zm3yPhAkDX^VTL@zP9Kv>(()iPjU!+NO&_dAi2xT$mSck{9CFa#v1c@wl!f;;vyQpu z>+K2{#)trPempqv_P-^nO4HcyMI&D5w}p^8@Vb%qR|RR5VI4LSqQ0NAbT@+4g?a9U zSLZYWiMXr&j!<JTEd`y4xII84GQcarIrO8tQ+uGhFQUeE2@W0YesKOHqZZSL^yfa{ zzR9YID~T3Qg<<({lj>N}^9Hw)nht~rA~^;<V+=yA_2}<J{z@$QcPN5uQ;;w4>d&+^ zt?9Udh}dAd^R!OtM?#=ucO>N`-BCSFx=0~{f-yd(5Pq)D6FgUT;+Zw*$1DeU3f2l| zr@OZTlB(33D1n%!gnOoyBaeFLO8$W?%|@@4gjm24Qa12gjBG%)&imN^S-)C6vg1@m zIW5}_omHrRC*IH`IX$Xn4p~ou+PsJ+g<_RiQ@?3?3Iz1{Qbwxb3FdYqPB`^gzL8l% z)(fxo(7CI<r1G|?LVKoj1BG{z2(3Fq(|Tnj(3~y|4?^+{ECI4THJ20iSIB<FATr_M z5Ej|2xZ)s)xkXWZ)H)o3E(OLnPd7=`lm=s#wrEh~!j+hW`;=espV?vjE;8jx@_W8A z*m?#VGC^Q-En7lsMk7dNC`1SUO%J7u1MxQqMs3u=Cc!{m1lo{6ewV@;f@m);1GE7v zoh50nT^Z$Mnt~Zyx?R8ZqCI#%pVy|4Yb}U9&?*>{Rh0|;{`J1)+gG#l*9bHoT%$;7 zFE?kxcu)VYGEg~b>Mj5E@27)%i~_oDg`0%Y(+CZfSD)R|b5*BCgVhj{EUVebzwu;V zDuKi(LSak{dEu0{xA0WiZCAh-*@il+djDOlX0U1bzD|JFsxK3SLRl5mkZDVQj({N8 zC#T0)8{yVXV_zyv11Th9T{?^x1=@5Vi4OT;obkh(UPNi$>5x)D2(CktSEF=PfxxV| znk{Z&2Rft{ii4F=uJoC#X~1n13J+HBTB^wNp5dS=1?=xa&;n)10VtYQjF^yFGNl<z z;sPycmEyCDaeO=^@RA}jTqvPkQdgogB3yzK%u6X2*L+Bpg(kh*&n=M(;r+J$^Byro zSn|U#0~XB**I=u=96E2JYM5F^y&{&G9M=jP{4CFgd8X9fUv1Ey=|<v(KWXV!HWp(` zg%igVkB08|bq0lgFryGjy9?L?sXY;gMs#J{E-%USa@hU0w?ijq3g5zuOpQYnHWx>P ztb;xi=wKH_vU`OIIyB8Q2|%Hyia>G=RZ$1br9#fehV#Z<(kgq$*+#1_^Wm)@=0>`P z+tTD*pU4-e)<I%R7(zQ7Jqs7dEEH5e5sE2OZ>rdFFR0}UQ#xoqC;Zj)OSbqR@o+8} zM0{c96iEeuIEmVG$C#dEHYV8YW|1mo33%Pg!Rl!;bCN6`4=(hXnMHZ#zcS`Xc6&)7 z1_5b62oA6hGUq&9xR9%`zSx~yU5&@RJ>A`g<~co<U1r^?%LN%cseWAbhv<EVph{DV zcDDY9knv*<Xod5tX$Q<9oK7C+*I_M2@8zeUOi3I^=wh@Y=X<t)QcS17R8H+<K?u7> zNj-;hj^X%|#%TIuB%-F}(77enB>`E#@{I}^zSXo-1TI0ziTe#1_mq3TxUGGgFOrT| zsr_X~1)6I9>uHV|2-}gH%O(5>{iWMM$xqEEFjl^R0|r>XVzLZg%zTio8Dc5{qg;V3 zuD<oxQ2Ocdh|Jpy^@+s4mtEU37a$tyHNb@R8i=ieh#fZ@;Bmz9LocuJe2nVT9O}%n zr+NW5tOtSoO#j=FskX4Qd!pZb2m2ZhP!2mR(z42t9Y^4^Bs=@=uS!%H7$|!TQc*K9 z`0V>8b_oIphAPn9Sz#GGQmY*E(2=5ZTN?k@OL-pJ0z7g1wXmb_%e-nLIGpzuEm;?d z;C#^W0`kt00N*X3TSnIIow1B^HI{;x>VNKN&IS>Aucn%sCYY||{JG}9qG&8-Qoiw~ ze9SLVfWdF<Qc%bU*{BiM!|oAk=BD8;Sxj`cam~P??t+0V^mKz+9l$A!3T*m_*{J$* z_7nUY+#Ge?orS~~JKuZ&gJ3R{Qn2!b%4b(G*C8jzaK2v8jPn01;1|>_QdX-9>0J>{ znOKH;#imq4hT~Z5c3z?4xXzFZ4;FPs`CSca?8Vj%clXIQ-R`!6qcTvpft6DXJMpaI zO&_8svMH+LxAcY+fEeRp0o?V*-a@<QWAjzH@6nKzDu6SuiFvHp`y4BiPgHvsT>AUc zhKAu9W{_w&J;wNQMJQ-_%?v{(Eo5)@lWC^78WRwAXfVFaQd{HjTt;+~$PRnABFc-B zQK`FH=_Y@D<??X2C}(v{x|bDatdSYqy>!GIM&Po6&O37u*=-gcri<-|Bbr6or(W-7 z_S6_J!bYJEKrXX>ooe!_uo|sJ0){oLuMcBC3|tzY9?K~=^&c@UY#T>FGLRgHV+rmV zAvqK@izo}C(rA}S&q^?oIS*ZH$=<Vo%m<fYT>WE{olb2gs+^TGQxlE1c=Mc*j#g^Q zz)UCD2F9{Z;G`(MT7gkH&31m!O}g_FpN#@cWvw6!f&n?#sE3U9-lY;feHY0!9!i8r z=T&$Vb8S(CX{5%(EB==r>L+HQ{^J|AZ3>W9^90#dHfKTANZzldw9C$6sc^yy&@;h= z^Pjf>0(*CcbvtbDnK^K>m02jg6+3jVM~8%Q=3Q)@`oPTCNqPXt1N6YaP&0P0Q-CB9 zG{<uSYy7=NbKx4kNFns-`;?<*%hMV!O?B9+U!xqoH$KGMX0gx&MB$d9WfLcL-+=ka zWSTJM_)#EJguZKlKX1Ap4$Cq8qo594on?xSu4k9(D0MZ$B@Ch?6pp%J7J~^J`P=12 zIJ_#V3XT*wdJSv{{}Xy9op=|CED*h~EUp7+FnjvOd)-LNZqghwsN6FRvAI+Cv8v4J zh>?^t!&xyRG@8+muU!;g>kA)ZF~@d+ThUUrkLjk>e&FkY>3{DWQ(h$kl^QnpDbdHd zzS4PAGA!dCC%_<-q}a`=`S`oG+C~S^nU)x5npU4(-MOTdscN3NKh2MIkyaV=>KiuM zKpAEjC*oDskOW7`XE43CDP^H4Lr|#jk0rR(9WtCNW5TVoT5xn)T%y0g2Gn>DGJZKe zLy@C!m2$OE{E#v<(kmCF&jj{#Vry-A0khn3i92XR3jbT5?CPnj5m@Rf`W2gR0SO!R z-Yn8VVW1B)6=0=CPPCiTs!-F}&o-jI$$~WHT447XCAw0ztOjLY)#b)pGO`_RV2U6R zE~{CIyvWptuI}JI+A3kunQ@DU+3x#IlLKmqcXLrR@s17byx^i&otV|6##6e-CB2sx z^ayIbrrClRND_3XV2y3*Cs9(5X1}&(GLF-*V;{~9wV6z(-EiQhU{U%~&ml8(D#j5f zH;Er$B|e=b&w#&373QK=`uvP6n;U{_ivBG#FtdaYA)HjJ(|&<pQDch0Kj>vWLYEuO z)pf&gx?X;8!VwhZe~GK`9X7n9YHAx%Po|;5N($oxIO!Vay3q%#MksKMi0&(R7OYx$ z$kW0lnC_|C_^>_FWYW}5;Oz+nWd(<Q-V6i0arM;C0;me0s(87_2I76mLCe6`{Jq%; zS7(}{<4g!Aw3y&&LPx70i<%_|iDy%!%J^&<X5p%XwVrc`;HgbB%JaJ@$bCknVspA# z3=;@TFwol&B6@=n8(fZM2Tbk&aJ!Cf>41qC$=Gb=3Cb!|{MFORko;>3rWk-OOC-5x z;f}6wjjh5cb9gpoLxA}Lm{#OiUx!uA*c(64k#<9#<l~uCu17ML@Yd-&@pMa8d0r6& zO*ZAvSijiJf;NmKrMtUSXBD~|b`9s(hDug*9z7S*#rJk)KwH!s4h|_V%Uj6Ov<)*Q zrDv`7OTD-?D7g1PLKzD<cW`Sx1l~FhZjw%2&VmniASzE1Z$LLOkGY7`Rxt*HbyNnj z@se2!j^YUXjG|Fd?fp`UMaNi=%>YcBKZ;F93~E*yj_BCUm_+YBjWyo)*IJVL7y)Nc zj}s~oW}AcExeC=d+?p(#u_o9yN%@cfV}L3c(`UA}O3GDs7N^(RE9-XN<Q7Ad(l#m4 z_43ex)SaPBQ1_0PZGKr-R76z2U9*7w%wF|rGIPDi?F(N1GEQG|j1jw|Tj;o-dpNtq zAt0cY$q;z^ogj|!Tw<7DFg!S~0Bv_8<KrVOdjW3nV27fGr1-BMy^cA{JqqiR9_2?E z-{}LZ#Nd58xY?)!zf)IMKFXakHWieockgO)3)@bc5Yi00O}SAfJhT8)3`Y+gp{xDD zNa28?e-So+k|)@)me*jS)}zL_Nefj1sq+q#{v|o%Ywo<doFoHGh&iZn_6GP=Z+g^T z3tTJa^}LgY!D940;RY(q^G@OP%9`DCK%^7qqE{|M<cdvL$k$H;j;As$_E!vs1oD9u zxYh?1qO>-By+m2$vuzX|%ByYSP%w~h-21*%_u&f^e5_ritV}XR{Vv-XF=K9e4eLD# zPuYa!s|9tzL7+X&RM?9lY?A=CMCPfY&RDPv!zx-8I{=j(j5gQ?ez*;Yg9O6$jU>|= z5A6|6D>|H-%cg($asA#0cr9d36F_;z#Mt;QrTzTWRrj^t*cdjLHS|umkp4kCeKUZ? zXC<|yRS$L;Ap*HANO0*9<>ouykt}A9VKQrk6p9z(9O!(?$9XOcSCilrc(_cJ4rvN# zfTZ=AYVguf<2Ot69_b`Y*H>wA0&-|Y6|?s;0*k40J9C&m$)=m*XZzPFjuwq9$#I*n zSQoaSOxeobqXzvbftm@{IAJ&eLGKhRA}#-UHk(PBiau-s*9RZLrHvo?G!<gaLF|eX zvaj1>tetqu<h#(XIj~%3|GzI^FRLNXNx*JfzE@HxDgn~`jB46=%pBN{3?x9jd<S-P z64fZwAO>|Qj8C}izM8n(_3n20#4SaV_6jlI#?x6GH{I9o0KyHf8F@n5u$B0@cu3;5 zBe^~(3wPPXH<N@3Y3+BG^g^JKBEH#b?Yz!_eKCUtnMXqJ-?dy0dCi^;+SBFNKxS{! zhONBH6p#duSd^eThDJn5<ka9Vv!~Q3uCH3=yp%^DqsKhu{W%Q-&kQ1EvwR8LD8A$I zW7;W2GSB-}j|fEuCAjd9N=__wM_LFl?&m8cjTf+*2!nmx#E7|nqk3sG-fq71uwz9F zS<^dwV=04m)xM?yN*;$gST+|8Zfv=u`L^F57#1Rv+=}OOFFfg4S-Gl!48&F8iXXa& z5*d0n_)G~U90uQgX&8Jd6wG9S_;*G?k)%F3Tg$7-ETDwu&?LyD>Lh0qG)>)1Rq>VG zp@opK&cfCo%F9lkWIjXKstWW7EH-Vq)Fw+!LAjX6^p$0=*>H@0g9T855|h!wMu6nv zTL?O7S<C6}1J!D3nI0Vg3&dYdp~Xw1>l$}AG0ubfz;4v<C%bTyY{nB57<6sk8!3?K z7dUcwAx7h_sH$;@(fUMr6Ivzkr8$8yQ5*O#3vNEa5l<jjqK@LjLfDOS-JQpe)Qa{Y z|2GkHrpfp(l7_RMmz7U)8vS!K5x=^Nl&~RE1)<XjWTiK~oYML#FlfY%VDttHGhs3& z(pn=#XAY?BCCvY$aH8-LAV<{;IkA{y)Gkcv6`0g}NdT80=fmwG#ibEGbr>5wo*XJE z*LL3$K`zurr3v9nYRI!Ysc`d(R3Tl#lH>LVz`!`{W|2>T%3Bl^4q9CYy<SPQ!D6!% z>zW@j_Mm@?Gb<2{&9AQN7Wk3@CocDO&I22$@tFiPEABf>pb!b-cWY?7!q1|(P53?B z_n!8BwhE7suMMn<T9~k(mBf-#X||A<j*ta|d^5CzGUj=h%)e}A2Sg#K%z0NbFgKDd zw*4@qndRaF7k;d$$I#1(T`Tzs0klU%ADOW>+~O@igHpn(wFGu(UhUUZo<yFUcSk}p zokIe0ENVpC7~dW;7k&-Q3gaBa>6zOK5f8iGyZ*%FYu)<&8zBJwU%3`bc!=Q1n&lCr zitb5iDpAV=64nxdYbPH`eF{B-EVqb02V7rbuDU{XwYB=RHFoZHbGkh?^%8k=^RB7{ z-jIg!<}yn1?~;<qde;r>4iBo%4nyIgs6}o2v#R(JTug#l`-GD`+8jQ|V}gG2)A!=d zQ^)shAJS-E(;A~OY|k&gauWZ#yssz(U#=oyCN3~XBmV|LEXPO;trV6P0R*q!!kvXn za6bTN;3c8Q#w_Z#%iv&g(3-s5b6FruOclms$Qya=3y{6EI<pwOWU<YaCI)@_ArdA_ zOWYP+WdIQolon_myKF?6Y=IZG%@h@ot)sk~Uthz;w%yrkukI)wNyfMajz8Lskw!Ne z8pkN=1060KU{gaCO*!6HH&-KIsSTesD}mkCx1CE(nQIQsPI~^u69!5sv^VsbGvR3w zB6M0rF{s{sP#|jUij3T&+ies;pq}4f3b%-82qg{7_7?8`b|$+Ctn$2g>;?hsMwaAv z(_?0C_k@M&sQO~I)bJ+R!Mu?L=17=(_STe4C9lMscV-l|`gaYy2gA@DtHvr}TPwo$ zg4F}`LRK`Gz)N^fh)*vem6D%S7NyacwFDo}rt)bRk-H8+e{|7yekNptf=9_Br!SKS z?%)LF>E4{|QCa&7<eK{%-J}cTE0-uTJXT6T(ttoFlg>5zwWBgF1PG#$4*MK6o54x8 z&&`j!rkzpd(bl6^GRu`X;xQp6>cg9wRXhPRP@3Tu^kXlX+PQo<A+3$7m%^RE16)Qx zI6{WCsPsxa?UIM)D}OZHz(dEbW&yGx6A#Udx4WOh(+d1Ws!??DYu{TnnbtU?dmjjX z{TyT(o&x~wZ)ismd+lwGQ47}vKq~S=7fmRPws(d0DD3-(5@K}mwFtW)MgMx5KU6{I zVmV%i3k%oUuCcDjo`b2@lBRs(u@->sgw;z?{kRpMh0HBW&k)A#U0}AFJ5n?nBC-wT z6X&5wDABi{&YX_#<{A<?ngNzm%OXgAtI(LWh7D5>9fydWlSCQu0W3;rzjkl%{4c`v zI0@?Dt?I>6-V>a<CwJ?XyCTWPAqW0=$}jcT+ut!3u_BXW*C+@5(}m(THX8&fv+Vnw zPIcj@47~wp1b6-BDVz;l|1?Pl<pcsW!@IjH+`47q6S`S)Vm>6ndU^ywg-8yNv!p>f zd~6ME;%Q%h2)7(rg$`!3rJpx2OWxgztCEB(qR>Rev+QR&G--BBBU98D&;zAXlUaAI z?Hzhilpcc#gjMq!&G+fE>Bu3!0TJdD5sNgeu$pBP4%HZyWx7UfD^vAER?Ssy%g^PV z#3&0C%9IRF9EycVip8mPCI*H>wXh(@07KaWYV`(X8D^rSgZiCVKY=5iIo}+#qE;Y# z2%PMTRMfr!cCj@K(OM#`xSU{m5T^~p)AaOx^fk)7PF;|hRyEF14Ity@nCDw#SH4~- z=K))b5v4h!@ug@a?)z%F_)h+zw6t3*-q~Pf*tXo=pVw}it<`BP<Vt|rcg1QbHby{* zHK@?=!z!NJ={l8vJWE#66~q-nacc1J%TR;;MhIP{&qm2!KJ5V_O=Dfzo$K6a*<aIv zW1??jN9;$GZRZ%fR}-aXke~KRJJ2G>Vr`pf@#5((+YT?`ROT|$B8e((6Y+WL)g%`Y z!qGFAvof2o<5Kth<;DUfOk!c_+YH-_o`JJovYJF_cY-Y<c1h>21QZ+XNm<ZVWR|l3 z!#enK&HM)P`u~k`OMrT_)hrzuy6q^mXnvr9Jsl^0pg~lIQZ=SR<MTcdm~MwNOit%9 z%^p}g!p3sz<IYk3Yte((hbR8Q)1Ec6Lpm(N_nMi#vCs_i(jCTUc3-a7w93O=W3Xfp z_E-naT3t$4+?%-#o9zXnGP+s*3VPwu|C1a5y7L_ZF&7$og>TLml9&$mF`DEqI7m7R zL&>8s!JJuJHADZQ2Dk%=Gh6=tsZ9?(BbW6s5+`;|?F$$+s#Rb@j!zlMfZdMe^}(^_ z>dvT$WZ^GYUe{C`&DZG^Q-@X%Ym~s`5`f%36@;+@9S|pc4}T2(QQIQ3`ijwi0s_K` z?S^ipsA>lC#b^lBn*d=PmPZn_o3#VSk_28)*;G(4hO?!Q_lsaBhJ}6O2F3QNH$St@ z9`4@Urq`+a5a=(0V4HC#0NkZsb-5RYum<H}3JNncYqUxHaTC2z`ngPqq`NBRyWKiY zx>x!~h+zcd!Tr%#D|p<t&c#q|kY7f^^oy_?-ZKx^C|-1ctMzWor?CG=%LNAQu>u=m zDt-1%Cq6}l`LQVcK%Z)I<<OM%#@?PrFKM}PLh-VITQt6Ak4<l2L%?z0{(Z$Iz~En_ z0e)J<$$sZ$>1KwH(QJ(%y}mna_?0=fdP;g6^Jq)-gbGK$`TLpy4+xE2b))KOITh;I zR+#dBTWS9(*qd~#&<fNiYah@TlYSyG{_l&MB$F0nJdDK_c=I}ppav^+&Y|#v<2Sba zI8O;%Q&plGe-1LC)K|VqaO|&9D5$m}B({U(A;ib@^`9UW8V;`hggDW{{iUmx{tH~n zz(;UTYK=Ph`t1T*3kIRkFB?@T+QJ4%OQC;Qx?YUA<?F7M%XG?Vfd{Sexz^OAUE~o_ z&pX6cM|^<52c8;QWeo#dG;9Z*FQbzCpORoAZk1J<e&qZ~_H8yaC0P38b+dkiJxJho zi5=MgkYZ5dg0DxroFMeS!2d?oIWNLUhv>A|-|XOw5_(!q6&RM^k^ENLt)O}^!`$E~ z*BucQ7yVjl=&1}R@x<3S$wnUA42kHV4ok%jC%PJ*UVmPCf7PVq-<#`mDreH#f_AtJ z^?AJ3&j8u#w_w%4)3nN+z*N9x7#NYs_MsO#>M^+q=<_nlWb$=zNHjLHU8ko6u*VeS z%&@46k>r;=VT_&^6T931IsRbp6~4iZ{l(rVMrmA<Af|yzf{z7GiYn@Iutfy@Y5Exx zl<ASb^gcY)VWa{<n+G2bP|~d3>%f)cE6!i~6Gj+e-+`}Kh}LSm(z#H8nT2vQJzzaO zs#_b2`jp#>px%7srC}^}3XN?lGm3*w#j51dPe~88IFe{4x_KQDO1Us<hAXeO2ORpV z*vM=McAxCGRh~yduq{%HC74~R^%C2o%V@BU>tDm;Th;~ph@F2O{dn9y)`FW8!}#cr zd#HtgDwvUKq~L4ty*FdaSauprNF}7%{8=RH+m*szl(<4ysIHm^H&}jtMoz_rQr5%J zEvK_Ncveo-=s-h~rO&MT*`B9(AJE1D1i}}xyq+Vx3d_9?TX&cJOyvIvQSfB%^Pj?k z15tHeIQy=|ZPJdEDpXdC+mCczSGjC?rU2-l7`9{X92nRGXfyNv^aD9zOVep`l6ju+ z%tz8(QV?Ws-OU0s_m=J(0KG~pCVc9VAW3k<`msjv)405hvZHGL=g+U|ETui|oiR<` zQgreD;?3C@hEtaOOkGxr`3D*MB#hODQz}mdT@9p3Iy=K|T~P1nQOTKQ*mWcy)-*P= z6@QNsrNw4HQveFCbC<ir;np~Hg)0s;6UM=rLbe?G9GRm%b4NP9{@lO}2c|q;qMAGP zyA<g9@gB`>G6(>X`5l=PqglF3m-|Gjdl}fI@|=k8{pxCw@)BKB&;4WGS!!A6{l<*S z1$+!wAS5No;FOfN<+rq9GdPB3C$f~QpKJ++?&)P2iSrbk0#MSnqf7IfU5+p8mO#{I zO=~Xbopq0W6FAlq8<#ooF(A;;RqnfFj@^L>>yibf^!xzHWGKZ$)-s3g90#t6kSpop z%$`OE+x|0zGIS0p^j75B)V(f9k_F}Z!kfd5bokVlsK=X{+Qdq7WyQXS?}MoB5Z7~F zeW*hZ^&op5W%R)bS8|s)xv9joW$xgjuD_!Gj%XLA#T(8*!|u$3WP@7SWq?gJ=KVf& zxH<dMb5zOLp&~R!BueP&V|Wxg>bHvkH4pxkE!J}Xt&$c)L&2)Yg@h~sGlTTA3WBpx z!TTWSYEvv@yQ`w7GZ0}ZbF+6`)3C|SYiX82DQ&`Y_;`$ZbH?UE;b!7GkvhF_ppMO_ z3w88S8hom9p0IP-AOqx;JJ-u^zJZ~h`@yHPgMv$4+ZLm0)L{6#D42GQy?jQTv>71o zM{j?cecxmpGGYAxkfc5!Rn9~S<$UatTWle0-GK%I;r1Me%`%MJh0mW;(l<auE`&4k zj2Zfze&$z=;!WytbDyH8$0Fuf!xwL5gEJN)V@Q|_61b`47X^E@s@G|IPDp29Eu-CB zAhq`}lk$_HGI$uYF32y+bGNoTJ6k=U>YhdE|Kzzbq$FMYvTEyzMgxQ&iWM9`v<Zc` z&`-;o&D|r$uq<b+WXg0ti_o|1t+WteEg}CXB6Jm=E3L@3bmT>eD|u%7ww3Rw6&`gk zzRwym?7D9tPjt7MWsqSPU)RWVUb9_%lQHTycdna<aak)fg+vuNLa()=BmXAC|0|ta z8^Ng%$|Cix$3-@7!op4Zr51xok|1U27TY)I^3suqoO7#pe_r{Sbc9D&Buk}~sea3C zkj^wUiV4?)$1tVCnNhwAJ)ILA-0vBu$*a_4^V^$4!htWhU4!e_{<eRCv9PJblG+U4 z``nX3&kCBxWOKj>-3gpR59Vdp<k_7B6a=kZQ0<EyyY^xZ!_hZYs*h@4fR?T}Rqqyf zRfGsh2%b|sW1w1shCL<C_@}dW%`dNw0RH!dOYrhd+WJ7wDLR<5nTRqn4ei0mbR2$D zc*$FfrY3znrgNn=*n8WuTui>mXO3Pmdp!6-B#XUM!^7zThz`2ZyF!_2nJR!~5KwhZ z?^_}2ICLM4ZepN0NloVqeo6!&swlCrx@g6q9z5}*s_QLzWpnryOpmZK;sHZ@PW+I0 ztRPBm#UKc&4ReF-a^nrRa;CJoleOJ62K>BqyX;#7cZ1n!CLN+ro1`e{^fnZ9lFUMf z(%j|7s$;}&m7&!Y)mQW2FTieiz*JBtJW(M>2`|^(1j0UIurxxKlzpqne3zO?n2Cq< zAjm~aR{inQ+o**r1I49-(yO~S>df@sM<FxcvOBXXP}$r@eG%zRZB@-df6WlN5SA4v zVg+@i?>s<Pp%f{{RO6dqt~#)D4B{@#3N9hLkFwG}JlIRE`tTC3vM@0ekjl*`Mi3n$ zx!(vTP0Z2nNE|Z35)4{j^Dqc%gg&sf31K2QQ4-~DO*9D1&}gP%RzR&APFNt%h-59{ zLsPqRFvl{sC8a{W@=y@HfLMm>jl4X$>FEQTA4pkT)W$_ZU4QT|HX-Lxf>3=LY$#8) zs@bUQ2Ti|^NHoEeKH*i3?dFZtZOlrQr9&*PUOfMYJB;Cj%bnWow*sk9ePJQNO+r@u zgUb;G_${(f<%<ND>s<(6L`Kliry3ANYH9L~Nr+5DCoOo^X1`vNqG4T7E4RrQHuNhl zoQD@}NrhgYC1vc;5KiJAn_JkKSZvsmbe@P+s1#dJu8Ivtt+za&ifATFIJgdP`1yy9 z=|%e0X5Q?IqJ^}fnU*h7D0+CbYuaXh4rNaVjs9d(^!~!~714|G#QN5kK}Cy$GLgVo z3%Mo6<RBjlG|_bOxGh;HG#1h9gP5HxLBSyW0QJ-QRtp-R=C`eIwQ4rr=OyRFCAuLz z#J~>qiZG`#FB3a|gofh5t+|}9fqi>kdVdQO(A-!oZa?nmH7R0R&hv-Ye&e^zyj}iS zr{WK?f!a0J5jjZ(dB+x}%*F}-=XnCvzTMPILUTQ9f#&Y9l#d2Bw^LH`vEu<+V+#ul zBGoBQ?U6hjdj6mlt~)|LyE0=wp+sdAkUhJy2l0RUGtA~oQgXroRe5kp_jW^r>CrA) zu>Q6ggPy8<kdQDJ@)~W!4-mZ`9@K>Jc&=Nyz!AIR?T}u@=g3gzbs3?jqWCfoa~Eo> z0(ul#g)wqhDl%wRl_hH5K7ICi6}{Feqh+<ioo5cS%}WxnO@E^->!zU&0b{RZpvpWM zqs)#{yTD5rSq(C67rR(MN*|R;*<-w0z#Ccv|Bc21=qa(Ao&`Lw-!68xhQ&@g0g-w| zsZi+6(AoCOJ)OCtd5y_mc`QmtdEy{N*Yj~(>rLmdjV970ZkZj?n-E*dhsZ=w1>cgH zf783j9Zy=xNOg*vc>z4G%pWTIp`rtQs42ygwP;}lmJT~cEDxI@ea``L({a);ISR<D zrk=utFI3Kazq6?SHlt<Q7|@b1RRCs9<WqHy%`@8S(@XaP{JJE~o#Q0vxs)WEk&RCt z)yhmdZH^j_xOB|S!Ds9!46Z6+pxCT=o7dAQj|d>$re^1?@fl*W;Oul+bd+`?Bk?)G zMsSmoLm=PuW!??Hrh+~H5$y(C80n1gf@f2sVRVnr3V&*4puiUYrMqBxZzKBeHWdS4 zESJGWm1?i8-_0*uM@tVanToh2wV4Ua=LTm)#lgG!oDN^Suq9_`dH$vIk7f?yc}sX6 zq?edk&2_hWV`)D#yF=>CXIRZK!nvfO`OU7K=k`UZ*mLdWMOG2p?)klx4;Ay~eS`vY zlJ>{-eEw0zI{K-ex*at65yYYyY`8m9xJURg!B=?DTug{9bznk|EH}A0iEA+KF-cjt zJ9qV;f>7Ld!F9UcG8hEx421yh=jzlOM9E7F4FhP`XrS4e{1I6?*tzRA>QGW}{PUW; zw_4w@c7FgW(F+{e#Xx1p?#oXDY8OO6yG%1RTTMJq?a(Ghl&q;aE+=QgJ@SOYu%LgR zLmS{%^CT23Zzr`A_iGCCYsUb9z*qm*gZY0;2wK8W<i=}t->vN(<QiII2>{!xKH0_i zjK9O;*^a^57g*Qk3!x6ns@Cx7$Im%gpZ7J?<C~_sSbf?XQ!+0gen2rzoadQU!=fOe zf4dO(Oo<}&UHY_ekx$>BafTg33j;cT1NXS$qy^hT6B@w*_Tez9H)3?Aiyj@UD4v2P z>v}<roH;xikX(>!Bdc!MF;4G=Zz1lC(HY<xO%}P|<rO)qgAB%;YAu@0oG*D@rNH7z z0v|fu;E<vjzjEXz@ckOu%`GH>P{wL`Nza_$e-SeM_b<ZhD4fkV*P=7d^;xry$65+# z%1V5!3A}x4H;T!hIeZu#+Gi80rf<iDqMSZt2;~9pNrFMAc^dXOFYw}cULoG5I`7%w zJs*uB&Ox=vFA|9?1(LiLZGR|*6jL)T%y}BZNx@v8S;#9R&ffN$IQ=Tzj3Sm(Q@T_6 z)aK^&GtQn)w5v4fGr4&;&qC?D>M{gsqLMYvQEYt~{Eh6iArjkoWFcn<yojsk@olAI zT&ZRK7rTcCqG!g+?g8|s^>h))nat2yG&!M5IlJ0<cy`tGt(Kn;wN*1;-rH*Viwkl9 zku7=x<ovFj=)uGJ{YLPwk{{ubcc2tB{{+vBL~PDLay<l_PMvWpLHNQ~$9VLrG-|6Z zh(#rfg_U*Y4+{yv42`rQMk3@!z)t7cw(0@&thJvm>%{3lPAI^bWL(-N4wgy_FP8oo z)rgLP=;?@E^1yC!q`wO`9I11H%wlyQ04K`sWoB^n-B0}CMA`($k}w#sr&voFSDGOT zbazQVpno&8H58FF3KiJAK5BYmzFI1RN+NP7X2seR&5`dZ06CaCyRk1+K4Ig+^Y<Js zQ#h9{{V)Er5v(SJkD2g<aoFn2JN9n7gr}gnbUX8CV${SDC_bp8XbmTYLGQP-pS2r+ z7DmhfML@d0WQP5OfQaR}xBHI+*XfdhsF8}Q0eVyIF|-y1j+N=bubB8F_DUtL=yzIL z{3X6k=wQdBAc@7;E99@P-U_3;R(n2APtZQ!W8NBYbD}W)W){E73SSlo*CE6vh|?&= zjrfZC_c5fr(hZ~t@5;>3mK#2q*sgDx$%DzDc_|>+wQ8+7K<Dg<d=7`;J$g`CCWSk} z_On*QZBHT<xNCnZ!x>j1Y?VR3;+cp~b!$d_;-eI%(u%|Zr?9~u3Md&ZbiV!|nP#zV zoG9m#v4G{S`Rtm=+^bjP4iyExlWMRpO32?HwyBa?1~i>KU^heiLLDgv7E_Iy#u|8; zG8>uw`$}0?*GQN$$5$^h?b20#H6J=#aa9ga7Kx8B32**$*gM{#?FSr@qD##EIUXQi zmF%|BIR8mO0Dl)w{UgyNnssV0qPf^1`!+{E*4JckSL*yh)~2d&a{C3hf|^SXt~?*v ziY`{o@Rd!fPz=qh8rocmbQCYPTuW#qP_z)~zpLh(DmObRpH;lm&|71Ep=%LAyLf4E z#i1n-bsjIiG$J}%a9Af{4LXArTMi8Iht|Am598#2i=ZL`gkFeqH`#`Ay2rO~0MgkK z%uIX7l^|9NPU$@R>|OIJ=vE)7;}ikXqP%Gu_Dee-a4Q}N4(;t5rNO?0ONQfb{1+5E zM+MHh)_A8eUY!AWZTA<2JJdd+e~4czet>s(Dtq!Ueq*uQMJ*`^OkrRd)2EBU?cdD{ zKjaX4$ifhTZU;=<%D)?X^H0D|-49fNU>S6yYPk@Gie9A4YM`P_^NNU~)z@k)i|^NA zh}6QeywUn3#|t$APu}u|?5qh=Kf`~p&Nw6IHn-U%b4|0B(VxI#M!?^CyqWX40Hsd) zC?PJmuxxyJswnd_<EB-O?l4}KG4Cad_KnY*wkBiZWQnn(?DM`b`p}X<+$sTniAo0T z`neeAg_MX9QEL_u-x1yMdrEB7U9Uw}M*+){Jy=KzC48l55LI1(-{*L&w;|sGgOv(w z^(o?=HffI|uIR#9Tqi8D>UkIQJm|Z~uH>-H`p2~A+W<B+Vsc2GSPuYhyIYxi^=swb zo?cI+JdDnENm;5I2Nj;^oWH>XD0<;4V$rvVL1@4=-L!E6C+-~%E>$ZU@HN2!6*;RF z%;k@ou%uv#;)1`Nwir$@)SK%5|A&WZGuJ@ippq66TPmCq+XfCCXnVQ#j{HuocEL0s z?a!?yPJL&`8s6X4fmPtpSK$Y%j8zY&bEJ^MHLgML?@q7>j9jt@IYlnI=Caq_D`6Pd zfs25+U4DN%@WU}kqzc2`CaL#CK=W>Jm+;@elzfzswFOR#7i-4#HQs--pNTNAPAi-1 zA@r6?b~x}`$O}-*(4AbQ?zmAuV2>hsI`3{QnH(T$7sbm8(r{e}5T$}j<~&}M{;}{k zm%1|9Y;c_o);tIxu4=WX;p}kae2Sr3WIr}*%MWY{v!~jXkNS};CRn$)Y!0)DqA1mg zCIu<PA=vJ%IKH{7l1?U>5(rJSC{l&emi_Ag7>!$z3=XCuDqRmAFJEmXd{^|SFU(S2 z`fQS?ii+&$k}J}cPgQ9INB&Luedrs*cWx;&Z2gq!*$HL&=bWSlf~?H){sLL1xdj#V zL1Ke_%gMy56UsdzH+_=z{fyzD%lzqX&>zdT9BcvHvZBGBq8UvUUV?=zEu}LUr1C}O zJ4R^hd#_=BhZE-_SGywwXpMtji?fR-s4rPD4c!e^5S)9=rMLR|L@_b~DC+^l%e{6o z0aF7Q<tR$(y6XzPmnnMkk#v1SYhd%eiTg`=t#??v_xA#3mTA$5Ty{(6->3F}7YBpp zSxRmMKWgF!h1*=z4ud0~O>Z}nqLC!(NG8#8aaj0b6c12$3<wn=yIBsF^!v{Q&-Vzj z7rRObT>4g>0pfe*P7`La%k_x?#jtfZE1A+Q&an};nOSEpl4{Y2jX-0m^ZB@l5sQkk zD=EHww5RqbTFqCYY69+pf|1Mg)R@u&SQ<r<^fqFg+!oCqZ&%!{I)^1c^?0{Y{_khP zTubHS&7`}jf<K`<PPZ7_BcfhM$Y|~5bG<<Cgo^y2!dkdS?iG?P#~&7*9=jteIc^Vo zyLo{4trcW&Rv3Li_2ijvy?!`Dcz|%jhay9d6P|}AYIPtcw<$J4T`H;L>xO!-_GI^X z-EBn&4?8>kAJ0G!K-fZy!wDz<44H_~llcsC(X@Th*Tq>DPZ!Zi_hxf_BO%}1{KokO z7UaXMIXn;LZp``itdZCQcGINNaUBzlRhzW((%;f4G6KAXr_AckMDIak?iu<r!slzL zBxgC>Ql1@y8A~$Z?^g1GV7`eD^T`pzhJd1t@7W6ZY~Zbrn^!wgHvZzN=_humC&$bl zvIrHhB>^Q7U~)chY2va*h2rWf^i2%p&4Vv0AY~8mZa_FU;Xq4uN)5<hcT*6qLe;gz zM0uM=5qL6UlmNFH-FZYflhH~kvw_z{NCHJ3wf6D!Nsn)z+7VYZ#&p)cn#-q#n522H zu4p6nczC~ax;mfP?>-E(*aHboBh`FLHgBzDVSn$%{W?4Gm`p+zV~NXCUbmTzW58%O z$Rcw=_o%zCqFx0jbn=_fFpN2Q673Pz1li&8F<R((q4};B3lD%+<nwvB9HZCqA)`-< zT5;^(XW9XO4U9w|@YG*nWhD%lH@iJ>z+W4|&xDQ(v&LX7UQ2_+9N72pYFHHM6QVv3 zhCWj+Bap{n2fMvnATE1~nYI=h=MbeL{2f~b7WALk4zc04MPKP8<`)%|5swrd(_jN` z7d}jsgVfz4ztZawqH>li>-#hF0#C5I;}0lZis8Vo7D-!oThBy}Iz%22XjBy8O46tr z3(9XmW_!xpMJa%{phAp$Yf{&%B>xys3=7vLIj((hPzL~{rZL~$cnui>!VoAl^!@;% zwxWWBD!D^BQ2i(YN2dt7EFXpDg(-8o670t$KGY`{01VjH4a|-%pmy3I6<18psMiSi z1AMt_n;*F6rs3V*QmC{iGt@2Wv<-R#EX{dtVW?=`mj_N#B1==W!7A@#IpgJ@{ir?h zeaz8+6nztBD<fh9A6dLHRs01+Tz}hp>+Tu_%>w%zZBePe!(6ak=~Qy8kN4EV2I^z) z=RvMu`fO>FKoL1j4SUGnRP#;&p6+HnSC4FVj)(Xgd@1+WB6E&p5|HwzfJw~73Sblk zBz`UqNdc&Js#ZJ+hh0kext<+0vr}y-k$i7vxwrAZ&V3RXir{LpSdbk4H<{f9!|YPW zpmByw>{QGH&UP=tCZAF<fq?lCX!(Tl+`y^Jlau6+3E}&QrtNC$)^Xgb)IX-g!DYa= z5DLu<I?J-G4p;uWhrIa>5pg$aN+ad$AmMhn)Ig?JD%^p>anz;4jUJgTI)inOH2EB7 z7aL(&{?r-qQbwkL0*-4VP;X-a4*<X}Q+H1PS7V8|My`IEYt*$WQsD=5MO*Rx-KBF? zfaoV_0I(9k-YR1hyeHA1FQDs)o6V+bH3^p4X4B5-UD9|$id&=RvUcr_TNrhG<=1FM zAf~ck(tt&Xpl}GgIxz-Bt^-O>6DbK1=Z+si0Y?XO)4_l$KfdGa_Nr4VJnt@jbW?HK zUA3_xD1@BcMS3Urp%Tve-vsZUm8%uR&X)YIlpVjOZD+?~Ac`L@ElPe?B@2^mku_9? zw%BY~h(ET8+8j*<+w1l&I^_TPo!#A$lJK`4trBxjx2Yd$)9GySueW-I$i6)i)12~% zM=V}@NowEnM4d)iG<12X2#|`6EM<`FRc~%=q9WTJf})QRjk=8s)aEu~xBiv`=4fBO zS=m&2=o#dzmTJgb;4KIPUtbH7Mf@BRbCOrVQL1a>cLb!C)Xqhx0Kwq;m|09J3+2oz zKi;%4Fa#!S87$mi0xeRSf6O-H5`ph3(>Ofl?2zxu4zv`8?gV;?3HJuYksnEY@H38H z!d4}HfyNE7W(`0NHL%s_%O8wz^114LB>^@f3+GMHL)U2gx?qH9!M!-%qgX=M4g5%8 zLOh-gCFP+l2X2vk>fHMgBlV?5HGDQj&q_hnkH9iiQy(tPMUNx-<M*f`_2027=F;5i zjQiu#WX)im5s<mwQim9Nq}f#yX@lJl4o49{`7MKWsjkVm<cprdT7O5H%_u|v8PsUI zc`yo?*81Y@MAr(3;t@y^()kJb3(8cjE+Q!5LmEvOZF+9q>&ARJpP$X)Q_!!_sy%Kd zHtB(KD2iT}KBB1Zros&b%c3fsK4`Dd!!uFUaVLLwEZ54dzHh+K=~3qr<OmBNo^Kg- z_*+5Q%7#On*6<dkV0+4p2rFya%a^+fF?G+(rgz&t3kWxXP(wL~Qi|6E!pL<S`E|vb zLWDYEyaeOR3l7;^be9jaI#Ac{3yLVO%P48(;r|@Qcwk>q7^{jG>}5&;&%X@))RG2V z<LT8iC%%FYh9*=ZsgURuu{3@GnT5IsHbk7cTB_jT>|y<Uj!&BX^dWEzQC7;7PO=Ot zPs;x*SL8(#Z7zpB8AlD<33z^Q8TuPsJZ%(=vm#YcN-VaR8es|f=_ph!2^xb4BF_|! z`&(xnIwfK&_A5nY((2?5e_IW$d=zY9U>>U{L4(j&P2P9`ttB5K{Z3r6FV5>_OxbYR zn7h3wB{WR(=)IjjkS$?=UuqdA_;x|zJ=4pgzIK<J<s}%I2SWj2k{AFEmhZ2!tdByU zW#6|*T0vk`%@YKWLno<#<K|5zZE4m4<!z7vQqaC=m>Vc}ExUibBGc`r@vn5D!G*J5 zGFU(8ql`Hi{J@PI?;!}d9);d~w#w&KBOna4{^jGD7%pT-Os!5gEIxrasQr~e%*Gz( zFuO6@777yfVm=o&4%kA2{h;CsqCDH{k?<ck^k(9AD_2&V08-&|aj7?aw@ZGv)6qN! z1np>60uJ5F`|-XHBVV;6c5{$>EgNV$KtiKmWb_<LbUVswfbKujt+)louM$B2DdmM8 z`5A2ZQQ^f+Cuamg8ErqC-WHZjRm>}zJhH!W4qff`S}5Wb7s3ou&6;T{F}a|&HBXCq z-FrARi2=@vv0UdxnR~Tg(=YGhwI((sdt=W0(<xEIPW2*KEnKT@E&R~F4>3R8lI06> z_S*tv1SCTmHquY_CGf@GQ~3R6?Ep9oL}@UeJHBtRm8{9NaQRxIN8pmTJc;%FdQ1Y5 zu=+To^-you@}gwlo}gZ6k>&OZBKnlA^fP!_5#4cp&J-i4vh>)QH%vHWoGd3(H5<=I z;_)w2mRku!`*z5tX>`KtT5!D>#-EeQkfsFgm2u05MZo6~Q;;x-0!9HFps<;BqhD~@ zW^fJ5nYvyan$tdf{6p9WgZFaW9U13h?_sEMBxn$3`D@qVUUSpF^(QFId%Gd&Q>W*c zhW4kCN~3N|#0$IaC}_47Xo86YO()K9uf=_&#OODPib_PbmEC$8Y5UiW8X}L7(JhcO z07%o^wL1C;Fdx2VAwcJUQL0KxZhL$2;-?5$#y?mHtX%0`%<vVVfdbDg@!R}w#_{h0 zYs5>=uZ@tb^5Dv<V$GO7Pn}DjSR{DE$&*5(SG*!zyQ{?*PBubwfmf&3x2*V^48Eu$ zt3%O(36WeH$3R*FwD6Jno~sO*&a|q}nd;0_aw12&lnx@3y675P`OWnIgmoKwL+f2y zFx8#TzSC-6?q}`5S*tqLN&i3lgY*VdjH<2vgn+M(!7@3nV_LIMiV6OkJhOM~knE23 z8|u9*_WUYzgv?usepZDgbNI<vB0c@Fnan6H8~Sda^T17Q)9{j|zQL5VS9t$F_1&RA zZUH5Z!oH?pIc3~LcL)zNjc^TYv4`#nX%0;c_kXbCFqbJsp@Hhk3HDj^Q_jOz{GkP{ zC^uw=3O{G60I;zTB<UPjzd&cXm37$^!<abquu0W7usDI~?8>kQ56>JUO!wFuE`tm3 zi*>zkb-%D6xthdSE6Bo5y|7LK!BV2W0+daQFi_MTIf#b<q}~k%h4*=V(!94Xwq>{+ zIrQh-|H(ZO$x^_08f1DEFFzAd<_?L3e58ou29JKS7vN<<Va4}9XJ&KfUMnx*X_I7g zdhxLm5$NQ5Yl|Xu^Wvd8l(>B74drSCkEI>$;x4hx_U<~_+|ybRyCLoAXGdk`;#6P& zC(h!#g&ztjYA9A*G}Zz%z3`OsnkWhvOwvi2e@)!yns^|nP5I3~Irwfyk0h9l@rJ1X zEh!g5BXoj3e;<0Vf;(-Np3sRs-|Xz1uJ|N}U2bg|n%RhJ^9Pq!H(d3;-`ZezDS?w^ z?ELR#ELz$nj|5U2Xa5nGn+`OIzV{%b+e=P1q3`_zG>bR8^r688`@$jZw|TcE6S&!u z7g#&yt#mZOrT8s%7EINJUxQb<I8%&<Gmj;+8W?J(B%)wQa;5(T5Y~NmbHCTK?5)r= zmF)$f!W<yrD);~GA6aXv(NI|yXqS;qLmg`|FM~%wVsHP6wJ!j@5F{v&*ar(=Wc>du zpmOuUMMLDa@?$7@gidLc`!4KrzCru@w3A(re3N})g#Rp_dRaXe0;!O8v&#idpE1%v zd#>}T5}XWhL1ZQ(GbG(U=I=7KK|Is#RMMy&z}tg#vI5OfDljfh{izQp<0PgGr{q%M z`fsAPjpiC;oi}HmqbKM#0W+}^8yfI6)eO_9^ts<WdHD=EHBLMB_|HHf<a#nlR*z0# zjm(p&)#*jFaSf#8Vb?(!&wEw5g^Z`5kkUVVy0zPauaO+bXBKzf{NIx-`n^0BP>Q-Q zdTS&aDXJk9aJfLh?36*QzGEtlYVHx>$a5OlQdJeMih0V`Po5EOyTA~`U7u!gB6v17 zl@B=(0g9L|37J%xqeD_JV-+>o<cWf%d}e#4?}c-sxs64+T{%?fy<c@t>!o8WF3SPe z5##BP?LzjBnD;EoRSXw?Xfi~3vi??1@Si#_c}AvkVuw8%v<mz6-p$nUAO5LL(B>zw zB>irk;1#$RG4@k@L&nIRp5Yo-%TaX<PDVW^Dm>hrX(!f`|73gsGQ1T0Vwam!BANnT z)uPF@{gO<8z4Bm7T|3@hcUv4k>K@phLarE*l97DZRR}bV7hk1-2I(BUxX0{3!OsA! zBWX}JT7yxqc@QJl?s{l-`Bz`)Yls(p6Kh)phQ7{2ZYki_N#Hn8=%7romog`RG+8#x zsqyl(RO!jBSWMh7|7oSBK;#@HB2DG=K6tYF$a}DZAq@jL1+ZdV^6Y+Oa+4f#17Nd- zW6ssPdRA38G1cl7+_iu({`=^|Qcenhh-kbaILjjZmS*KrwU?%PQIJ=3in*v)QB?Ll zGlI|~-U#2f%WkZxX<zG3FRWg67#hvGa1kC$>gK*ja8?Y_4yk}-+2~aELqvQm166tD z?<a`M%bYe9P4NAZ3*fbQ%#AC0Ze?cz`Vezvp$r#yx;|XD`o8pcmA{|iLnhC&<34`P zCrP}$ROo-Aqqm<dC3Mv}>SoS(J_w~+)`2Yqda-N2S2wan{`fm4vq|RNtwa}(NU-d> zp$KedsJ<wd5sZ+iS$1%#k)4}0CWsdX=}9{Bxmb_8N#nF*nUV~W3p@=UF{jzP$S&!g zCp&CvM_aq)Qjy#d;QQ->$2$=AbC_+QWgEg{0+6gK-=9xit-0SUX{ZCx`P`tHKDj=4 z7?7dCt^>KT(Bs+5*20tPMow=u9~cT6*O+#c;Tr88t$LANVZEgBhs(bJ1%2URX1&d+ zQw{}<!{hm_>O3phME$c75RI|Z;39<R>Dgx5Z$wkq>Ho}9RyVQG(vT%Wa1o(r=3%yO z^L|97@5v5m)>|Iw;|WC{?(<X-F7(m^u|5eVte&uG#<d&3ty&<AIFD_f?Mp1PB1wMz z1@6qE_`ILx%#$M$#tNj+AzII8{G9wvl~EIqP^(ZDx-=Wu2w;EGbq5)O)P`T_iCTA{ zpKJ)4RMTv5a&ji)$z2DO^6Osb{!k-C^I7?a$;b{&o(g>@5TiF`<Af%Dm0O5G8_n%G znTEU5Aw|h27mbpf1)vZ7<7X?JR*ZV~p&ZTBrJ=q8;`QIfuf#1K?bb0JZ|*lbX2MBc zlKtXhk-0vXyrT(??FXaGkL=Jh-xs(1`Q{-4H33V4x^14q<a=#&^59O59kNzaR2*;j zNs#!q)MZsPT$$n3jwFH}j%KFivb*sdm<|h`@^qsR02z6c^^F?A4h$o+HEVdDcS#d| zFZry3O+LTYfUr2Caf$o%*PBM>@A>=GU1SuX7Qv3EI-w~BuX8#)6Y=>JckLcdM&Wq^ zULSjAY%#v>=+Ll;G3lp(4>PL7lPK}Nt-|x?U8KvBK2mt>j~h2a&Zq9fydjdX>S!(^ zqDq@QIjki{)fnd;8yHN}uf8ngvLS8Rf|Fbt_6YV-sSo8b<k72Ku4sdB#bs7ha(yCI z>{+&y7MN*6M8Kh6HthFT<8b8Rg({#NnS0Ca&b^*6x<|AmPHOun0{R~ij^~64%s=5X zODi<Mc+U~kr2CGnWCG{7sE$DC&df0$o7gIUNQLKw_>4r`Q4%=(fM%K2NyanFMgu7q z!c61rN4Z+06+vU;LNtdD%Aa^F_e;JLC=fqE(+F7l1?K?!ad~Z^ta8)>2Y%3Y-#xzk z%7R&X8IQDF)zuwYqEdA&(*=8CD86abycUadWI;ZO=qud-0&z%@6Hq&c)yq`VNG{$G zo_5+oHtNZoXy%}GTN&HQTu5Bra0k}U#iE~Tlud!D!IZ@crFXG43t=hKna?M{0(rl4 zgXR1->y^s<y*tGCqu=+fFdKL{1pbjmOu`c&c?D@l0sjbN%lkh+0z*660^QNU4)n?p z-aB#mg`Z=-jgRYEs1&CD2zUa+1g97Yui)DtD<Az*ZkGUTkj?qGGq|386QMW)khFb> z3a+z@Y~RVT$zxBlAFxCUNfNuU2E6{zGDLFe7dIO7U8O+}`D<0BObA{{ZK7a@0Wo!R z)$zE;&Ujy{u&+XCjLCsfxHPrRSTW8hbs7%<d@NlT>bMD0gr!vdQLI5NhSY5B371Uh z4~rQaExbIMbf&}XMvbr_C8Y+-?k1B05nNcc_0kX}SBD*gd~rNeX3rG$u!wpzvyI1i z<Jo~C7Ia3~@{|Q>M?iC+B~Mh0NK4BoDcHw!!$iSnS_VN$Oa<?Th+`!sw?pRMg!mW! zi85z}piWX!2XQp7KV_>cKR{$Zc&o0V>q+Pv%%#<l9pYD=pJoR*)zxEnBV)#RU@0>M zn;A;ochtPspA|4f+s~m?@$B${VP_aO`tfMAk&rQzBlWvXClaM2TSL{QvB<dWB2wo@ zb|lrN+rFUc!3oLAOcuWwg;<m>m&M7;+sZBkBIB>%3%K)%e{PdVb_4&?E5F((o+z#j z#Q-S)O^ZhP&3GRv{no#A;+_rhX<yu#v<u)b63f=dKN<%^3w2NPvfydz2ZF0&o_;%# znW-fIOKj7viXlkwhWx)>r8~^Y+jYtkzM2#@w0DsWuL^7)htI5HCG7iu7X|n~{)HcU zT+j7AfJ{+aN-w<p1D|cSpsqVU7klrUFh)6+)+nEu?s7*S4RyT;HArmLOZY<4DcOIT znLDz<$0cWPG9rXXBzTkV(m0L%!woL446kcu9XW$nS7`(EyleOHxQfE@-==)A;9NL~ zJr5`RtynkZ;_?|iZ8&KRu1+9$m3FJ>i-3Dr&BmL@m;hSM5`;XL_w=p-(zCD*;HkW& zX1m@s2rvbqvqaZgy^QhL1+>B{r@A2~geh`-yrDhOHj{%6D3l^&x&BJ(P^cru2n~ez z5IpQ<_&jM=CUmy^F0p?UC~K`9v6#f=%X%4qkEfg+WT@EbMNOC}acVD^J31%=Z0L;X z)iLtRjjRq^zR^6aCcFH?RnAys3N>|)IA(}JXpZ~7!#_a&A|<UJ=EcuF80jM=*e$Bq zhds93jcV><{0zP0A{0S(LJ{~bzHgW^ySlqt<#F4I<eH8awC7TZH_pWC5gppG7U5>J zo%uQ3e<^l=upw&)qcqT+Q7rHm8Pq+%FC&T^BU`vL)<@Pv#Cd48?q8O^|2_<M(S_sy z`u>7sZ|StRYUgX9oJQG327-e>aJusu{*6E(jHigU3I2|&R~F>rQ9*Ki4GNor2Y9Ww z?jigIq$zBp4DAiu%zG#t7OG_vY-kX2#Nv<1tIu?l2M!Kl*JBqnZ8{yU2?QjYnO~>m z@Eg%rR5*RK!YRjZq%a=yipO&BIr(i~y>mTmiK!#$PI_t{AP>sB@xXvV`ON2JdyC)| zTHJ1#NrY+@b}&<)$NKoCZ0p55N$I(Cl$H|)5A;7hBGgoG*Z6a=_UvxfsgpCyz)4MZ zXIJ9E)tP~&JH}?-b7jbTY}(t&A3-d)r@{aaw9r}}9e2dHPX$ll6LI%kTgmn$<{Y9u zWt~5!4@-T;12=@j{2^LO!VY5-GvxCNYul3E1MJnjaFdn>3PWA2IR7Xh!8SKie@n?% z2;9B!vh&#RLQdwkh}(JvdxxE+^{4n!mOK@At8DC?*lVa5(})w#X&r_{c5K(+&-;^u zWUa0ib$s(A2^nYRzNe=Y=O+&I-BL_#1EJttxRTja*^bY}7_=1|InqOr&oWc%)VuF@ zR;MkXp<qVq4s3@_j#09xY{YvBm34S54Szx9%D(FK#{0@!;Uk7T?o)?IxO{E#*c4Si zVe4fV8%pLyi+1kn;(KL42})D(0Al==FU+chVB=#jn*5xY7@oaqn{K-P)cdh1qbq=4 zSX2HLhtbMx_X>*x+_5oHc}=1z++@9Yk#dt2{}tkkh)DMHW>7agHxwnu2Y}~l?+xWD zYgmx2+I0Nq-N3p3*Lfir@*_pU^KOpNB1v1~+k4+dN%bSfJ|;h?^o15+BJCwwdCWSM zZ=pf!UT57jcXwd}dUBusZ~<6%yS;!*BOFuFIbEwlM#GssSwF!qCOf?7e?oO3TAB6R zE0rUuHY3B|Z8c@U>|s3ON=<5QZ8b^a99NT7oxbfP$&SrIz<VJF>4ek48@`<-%>RD< zJdK9C{yLoN*5~IB0E*Yo&13Wz6Fr8_<}UuwI#Q7HY<Y^71}@%12UtzJA)Z8cY;v9T z<o#4?K!4J`6U7t4dw`on`%or;@WB$I>EF}%XZy|T%=&&k!)-pcOILALUymqwO=qQ# zk$S3=YN^`e2D6}%&{8~wpl`eSOA9nx@_!dy?2S$tw9$(YGj9%?`af$MkI748@LL=E z`l8#&*<h-)qa}{@&=Mfu;c}$7b&X)!$*AK-6BLyGc2Lay1KSJn$aw}kLLX&W5KiUD zfBKn#6QuL33v#2AclTov?ZmtZ6e5ZrSZj~wF`frHHoe~=L|;E&mUC>1)%>08`V#kO zIx=(h8+Z+KEM37D_xdd-Lh6<Wt|?OcfEk6C&Yl*82PR%K7H3*!DW)3ul_xI6iN!|O zDXsB2>!m#o=je$|Rk!#>pG4BCD=f&2sowmDG?JzFuUKi&XP3*=sKt!T{d0}YI{K_) zC?af3p|*Ffpla{A<wB7bE-ANi(2_~rg3oejwl<6lk478-VQYm*@5R~->1Hh*Yi;=> z?DlFW+Q!Apna_gJ6_~DEVSVHf?2?^(aWO6c4JOepP|-n}ih>3#IbcViy>H2%UrW`= zSBAzIaA53LA?E8pC0c*vtF+dw(<ve*jIt5)HxIe%ye})r{SMw1{-xbd|1I=qoRs`` zY+nCS!nz!B&N94<J~xdwOP0mai{-EMzomv7xwsj)1&LV8lUqB>#3?GW))WFIehaKi z@~bhK908Jq*=J2#+3zCpfjGV!e=c-Os6cRjyKmFR!V^qU3k1%`&X4lbvw-B3$^EUp zJDyn?AK^pl+1IzpC3c6abUHT2olc^YM;B@7kT#w~>jaZIw-F}SSEx~hYfofK`XKd( zcS`6h$b^=MyLOk;<Giz3VbLQEADIqwX>lux78W@f(CA#ACLywaHr-#g$|mWaxxUBO zT`!1KD}4Jx4nt93)E76Oup>S-2|f|BSZdj|NWY`wS$O?)OkABfoPH!xLcp#K8p1Iy zUKL;}X5lB2Fh3-O$Dw$%)fC}s9W_-vh%;qOZR5#FL8nXLaiaZMdSwl5pS2<6u>%Cy zY5$~pD0g`$Fv!JRfU{Pg-;dYcNX>5zel6Aq>4KRSEVD%YN&_<TCJ^rPS^?of9&2uL zKypAwegG-uZe}0rZ{9=B`sc;<-e+t<Yz{%W{ibTBX%u$L9^Q-sJ#o@npn4S?G179p z8n4HADb%jRU?y@(*pLBY7yb`wNF@i1nv$RjXxPJ%BGnEW<Cq=ek%M6=iM~R$oi^c! zn^nF#BZ%^9xNkv(AA6&Zoj+M|ml*19Bf9b1J0JRineKRFw7N)9SVXEybSytu3{i1Y z^;#yY-Z_r4{5K6tk^S06hDssy1tyLhD@=S6Sp53^oN6re+dg>CXaQ^_gH{zX&w=*@ zHq$Py+Dv)!xEnw>617r2msmo--9$$Rdr+i=O*XJU*L=}ImrKi|=s&cwZIS+Hh>1K4 z&JhuGiNL5I$i#2E&xRMFG&cka?b?!Y9oQx-(01mbNP;{ke>*q|UQMsrr869*xw-^c zMOHYH-p?`^zh`}$Abxh@)F0=xGoo1f`B_uOJBcOW7}OKu3ARCL7R=62MC%8b_q8_w z%K>RsDqN@8JDrgoDrK7C><WE#?Y+<@2#D{F>nHA$O5&Zck$JjTn6Ja84yP5nd?geQ zIKH{c9-UFZwD<gOJMsU|Eh9oMG`N-!Rshs@a>SRN!5kEk#DMLFRQEwG^ji$>^2(Rn z0sE<&Y0l0}sx*Z@nZby=dYEQ=iyD+iNo~Sgv*#v#2A6FgIc`xdZb1QZ6}B4|)^|0_ zA|(#)4hdzj8Q#BMa48g9!}WM}@2xcAaRPDgc+OB%N{8qcbrhk)QR&0o2T@43<tEp4 zN9UK8lmDBAD7Yg0jR^&Gu0##$yat?jmC6+0u7?g-i!^(N9)Cgg-Rr5)GJJ2!1egg2 zY$8SQ!^<P+@z0IonY`}xuNDGL6<uo|v<*ApLUH+oo`lx7#z&Z+f1G3N4&Z_3EL3C^ zl2;f}bM%kXEUL&9@zZm&)q1A3bx^5ILI0IJp~2VM#F6k&P|(m8NJy3n=gl-T=`(PL zO_tnkmRm*L^@q)CBfR$n%yyxZHFT=K;L16SloMJfg{Ut{dO#)hUyVRjtyZ8D+h@t2 z(r=uG(lIXpSy;&pp0Y|l`O*{L?}M0%HL%Ad+NR>fUvE%}wG+->&hBDH<^%S8hDFiU zwC37`8bH`1%AwznV?E|b8y78M^h7`O+?!2;s&{TD<H2|t{3TW+(609okJ6>7Y`unU zH(vb8iwiEG=3JKPf;`ZR1ri6+Lh9gwtIT9H+Jhj1w+%cn2#J!YT(HltcpWCH$n7B? zAoC_BW82H{5+fuhWrU&=dqGLx`N??5(_~@*qyzg?3fmWTlcA45aL8+I>#95Fp20`k z#K6h-Pmqn;1;td-=LgCKl+ckppcfM135Q!I4zK-q7+$zr^>6T1W5051+BSV7GMb)R zlFn3b;Cnx<!`T~5&rYND3mNaiY&UG3(gJWg&>l5>6`nPYXMlqqQ-X;GZ77Hoh~Q=J z!)QJ*kATPNi!>KIQxyadE?Hw~up*~7Dq-U~6lz$C4&x9Z^LbL^d&<_+XO7QwEN%Z| zhS=GWGrgj@kTbp5RJ_Xr{>J8TMg*Z|h8n2W^Z=W_?QKdBI$-q%p`_|<-*88!lRjH} zCI$@QT}7H+nF%ov>dt^+Oe`7wCOQO0v7s9~0Xo1)H+oy)MFH3rsCcW&Z(t5{zlH!P za_`$~vyJ}K#f0c?tGlSz{H9(=HTLE)Px?JwI%s@3&{8fw_%Q|Mz~A$~xsq(s?DTjx z_I5z>7qP`(*>lRS4g?2Bq+wV}UaiASIFWW*d|}@6ur(!PBJh?LsNUbQ&BqQkN|Y*S zcvMLMe-m?b`0fe3T`42>w=GnrGZ@mZyl-%5oiq6dM!M12Q)YW67qMqqZwE%2wZc@% zBXFAk<?Be~tpAFVS#{C({LE5e;kiIgjExQ6^<Jg@t`^*h3cTAoE$#F9aLH1sQX@WU zq0QM+K%hP6os}T4vrlP-I&;=fjKrW>fH|(<ZIcQ^wkSS2ca4xc#yPjxi6d<+R42P- zv?Db{KffLl;4UY_rY<IM_Np?}CR>%+T=rMPy*Mm-aaKSoxXo&HeL;w&K~3?1k(eJO z`}O_al6fr=5H0}}SX;g<;th8;C7edPHMdy53(}0Mx4MkOAdDxZ=;UAOn*Vtsa`3Wn z`mfIl382=Fe}>@ZkXT`M5bi-ysvI5H7He|^+1dDJqWr!PEbVwvB_IW7WVMZ`KBdRH z#i!Jt&FD{NGy+ToieL#&cmb#upJJ*$?iD;fR(S#=ac=>P@7dtYp<A8HZ$uq(s$%R- ztM``dFM!*UFL*<WX}QQ2pUuJtU~4Q!2iV1Dl%TuUnu<uCG`x=bFoT|H@8SyPE70=H zXa3#e_yK^<19y~#{IhQqlM7GBP4)ZN41Tq{BR@7j1{!=q47xxORDZ|;hS`s>V2N(` zCY`@b3-CZ~i;|ELTu@GpkHZQa1J>HNtXVeG5KN>(1&qBu_M-!x2c?uUbkF02pRR1L z1vt>mxfl$Kn=nxsm8Ic+pf-+;pE1R`T$7WNsm6^opp=$6Z@wak6UBL?&30YuHk&F5 zo&DK5k{(o2g9W9<P|Jv{{do{oWK>$55@I%5gFz63RexlVGUp^s50B!n`*(-{&fh{c z1Aqqy1;z3(aG^GA!!s^9uZ`3N_$i}9AoBy-9je?s9dLtxtNl<MO<eFM)P8+ZI)R>9 zYdm|zSgr~@%$HCz-5F2}FX*3qc$S%8mRSq^fIt_8xuHM0R?e$pOpp!>f2YrYCr8kn zIZ4Z0wdC(6`Gh!#!w7R#j%3D6J;+Am`1_Ck+H^N$Caq^z38t>4FNi_81FeRW)oiIb zb-xLe%UMc}Z7YIW*;0;E-lM7-bWTU4;Cw}ATMe*Gjbm08?uWcH_N!lYY_3dDu_i># zvQ;Kra}TfMIL?|69w|`-=51fj(BCOm{%%p3$1^$A_xY;)k2B*FX$CjxkouJNaN-PR z>2Tfp$Wt=Sd5%5=gP`ZT9U2s^_`K1ysUbKF6C)ko78YE!Z>1IWR@yW50AEBt+4eW< zL+-KeQVl-OSdLb=Z`$uM6q2VFhUCz)&6+3SG+1Q$F<K<B5rv@U6=Ban0ITmY2|%L7 z?{<!((D+(tO>@!RW2t1wmJe;KglI_16~B&W*TG8RFn`svzk|6tyI-!n*hoQ&W~QJg zv!g0i7}78V%J7GcTVLl(YKFiy+w0tAW9Jde_~lPKpiXu)b&o3B{>l2!6fGz}pedJa zUq0inG(OuCfBFW>@}!JXEx<{c*W=>6|BsGq=&F=jLO!Bl1-oaR)#qn>DO*)rNiylU zoNVVm&_S<qW#jjobrrhVPe6iBQzYkHx?j1-nqh0g@Gzq1airId51jX821O93X<9F1 zDg6sogET#2jG2Po@dF>`3zN8bwRxc%!d8{>s{b<*i0blLqaS0n3n5b_WN7Yu2ws6( zTik-t3uobR1HX;47`v>ZNy!RS3h26%IQ?FH5gNy}OEx;5W)quA@>z!|(F4Y=ZPE;+ zOtYdH>I`!RJx2J#M$=G6_!i=zr&f;|+bfVvH-rhlfIxg+&$Q5IAZvo#aeY<=)mc<T z3^%H#S_k``Q2Gf4QZP2g5oesk1r_7#cw=GU6-}`&=`WlODx`ah`kwa7D3Vt5ko$jU zxPpO62;4n`M?QL;b>y#MD<yI#ndQvRvCn41TqQlxmnm3i7SC8ow?neHs-Nq)R{Fu` zDk#E6$c(@;%_fq8UuBt)9P?)=`}xat5P2x6J1?1{-<c}OV=2CNI|k3DgKeFKJuf3x z7zr6@A)t14IXM=bu^>ctP&s*bf@X?@TQgFlhYI<PC?)_xKp?SXl44e4dXFP2dwO&5 zy}sYLiM+nvO!s0OfD=p(!%toIyUL)dA+lyupQg@LjG@;g2U?}*c(|>=+K)wyHe=(; z`6!rgg_1jDD7<HkeGJrK1pxs8l^%6PR93)W%8XPs#^94HK203Ch%NVS>21#sADtTX zE<NB1!(Cyxe*jF?1_n4ru>hciSnp?P8tHT|ssqC~Y=vB{Axa*5mvrL2ejM)e<ygu1 z0k0=WzL$VZvK_eI%c(+>%HammT7j?3{GxrJm!EC$pz|vkl<ha2;+JNnR!FIyf6o<8 zzP)`nmr=!(R`^fli1KSlmLCfL<+R(5VQ~y_kct_qY9{gF^c0emVI)E@@$S!So$nt* zQn_3QVRhP{KtYF2A3v@aawPZ(g&DwIV(QN;P@PLR`4SiGHF>}>1;6Y}=&<}`DkZbT zM7}6IE+7kbg(~>V`eSa4a>LZpCcGHS30S_B#E*3^obvIxke$XLJ<nwQq1`3kx~%&v zG~4qd`Qy*-?NkpB7bl`MN>r^81};l@N}B<NZA|DvkW?sv;HrI|U@w!m_mzh;Vrxyk z25VFWri`pjvB&JLLa+yv$>&;p{;s&a7PV91RJp(sIdl^tB=qE0{}Y<uuJwCN^p63x zBxigZP_~f{OA_kCk5p9GaxGY;KuJEGIE_y$=qswijOV=RkbJDTYUoDQnPUy;?hR9Y z;TUNkTxchcBE`y0GqV@^7Y7A~Cp1ZZ7wP2}I{&_H0uOt(VdfWhy6(-iQOhr^fi?(} z3s9h|V6kpb@7xCx;kzQj_+L4u0Y%gxWzNf?wbMF}KaZWnh<^Z8LK0}+E<Qf3Gyrzt z)HTd#(phIbUNPSg=UX(ady|NeWH@OuzxN>92qBcQe~~KYXXpm)M=8!bM$l?CcVs`t zA`r-SN3bPBSL{TZ7&i$$hRk)3k2tJXCmFKk^^=EJ&V=dCXY4N;Lf`L}iew?^2svP{ z4MK0|H|~?}-E4Tr9>A%MKaV?C>`Z@{;CBht0Rz{z$2Zr*E4zJg^ws*r(_B5>w=a|( zlsEU-t>439cm%r=)`0b=i0X=wa*ZqNGyc@oV4cPE&68`fnJ7KUYQwH8lM9923#r<+ zl_<Mw%!9oak**n^p0H3aDeyKBV%Wir9ysjrm!GRYM8I&6)@tpKn$ybqyX}7WJH-y~ zT{-)D;Cg5?1NHUGN_3Fw3{%5d90a>+d)NMe{EM|xwygk*ure0q@U)ry|F!Rw2i=Fy zYo&I*M&Rhf+*<<D9R-SEC5^8ACCYnUx^>zAG_+12B09ALR!dcahGo7-Xry;WYTFY3 zbmT&ADi3$ufu*UzLSl6o1J4IF%)C`6F*0-okc?JB)|c91;R_}({4b4Mx&uZpm|Y_> z%BCU#%0T~Ez=snmu8X$BHjfF8Ce3~QoF)?>I>8nT9Y_e+?pQnhOZTC9JJTXJ#hE5v zziL>@AyfaSM)<6VcXM5j!u^g;uXU(Bd=_)s9J>jF?lkTMPy;P&j5mFO`GU7+2t7#} zy?07Y2swHu2xrT=6?Z+2NVGFM4kNi<uJNK$c0ZtKuvbST-zZxdeB5^NCO@2EZu-e{ zq9mGy|Eqv3h;gRDf&yV0e3lTqp2yDxteH`)<yOy*O3Y13o3Lwm9u_#FmUCYqZhbcE zZY!$iid>xKpVFYmo@(%Ov(O%tt4zCQ%*ab(Cq<LiZqx7u&SZY%9fQ_)ic)j{R+s>a ze9xL@)lW+kWzJhJb9Z$H<Y)U9n?xdalgx;`@;fUr<G^Q^=dPH(j}+e12@*>YzDqlY zB%ncE`v`(?gde0WL9L#fvoy;dIvA`obYQ+tvunJFXBImf4}@b5pUM|3I64T9sV=Pj zM}l98ud;rExnY{0#<JNsb@1r{xg%J1cwCJ2O|DLK4-tMIG34DuTIkXHYnrhzeL%uW zH}v#z3N$}{6I{I~hk}3OYb^@!v{k-+7pfLap}_PWGU7e{<WAU}2PZE%(e(LOUUSLs zWp5AOl-1=RqL(8607&=`hwMHLg8KIL3u?#cmySg>Xy#B#Oh@nI7V~a-v2*nC8PvQw zO(}?nf)D)vk08uKYLKf^3<mPImzEaGcKXNb?Gr<Ou(5ypAh61~Kl_H@uj`d<2|@S& zv*a^-?_71cs23LvK@qZBMf+H}HIB*s)4N_#nAA1g39Yk##T(YFO4m3@$q{z+(*p{> zwh3*rFa@%V(_ACRcr(EahY#G#GmDKuj6N2wZ|%Ui9}F&L2%&F9SEtb*rgK=1xa5y> z^IafMV|cMJ9?Bh6Q)8*OY8534bV7fG=)tN4IXF#LTSOwV{c49#V(?wlt$iuTcqcXD zot<1=WrVqCjcBx`M@ybp>HWR~mNoMVnuqIs^9Tv7#`%wnJc$$f8ALGQ5s>{!{{=tT zr0L`u;6}F^W)>-E=IDHEUMB=IlWb!<f2qkFuY>Sv0<&F+=)kR!X>tqX*FB<m0;Myb zJrT_b3#Y766F|S#svPT=7DaQhn<~VHw-zmY{GM(9;o=&3XY6zs3r(z3EBA$=Xz=k- zS7}}oBxvNHGb1?*1?K4(Zu}N&*@s(wz26+sdP2f+i>)TEIIw;#`&2ns0+0vMrXy~e zOUg#{bBCQbX*F(phGlLS@$<3UjXE_5PyZjGhqh0<bY;-e9`|^UD$0Q@(kHO765!%( zx*~!<WTwu}=k1UVq9J?X3%BkCKuZc@8u_v~%F$9CxCtM4Df`Qqr9K=ekZyHTY?{D- z)W#Sh3ozlY+WR9_<(ll*f+L~v!@T71H6xf;BK}+75JH&s$a97?bJ5ZQ&cs?19>IK@ zpJ+QzKSM`~W2Pn{TH{3VTk~$|<m-*7g))BFi%)9|=t2JDn6feKTID-M9TEm0$UKYg z0Gg90)?>@b2o%vQTBAy_oF50t!0RRB!Oj@J`bb#xr&|3GVjCg5lLTy%0ab*{j#GM| zjbp3Tl*EV=|H*+C+!cZM)F0hBS=A&h3{J>#FhHcAEm?-^&ntM3IL~V{HrP-Oxo4<Z z`)IsU3E!=`#eW$xyp?QGCk>4J77{m`x5`~`!9${j0m()Ik?$0d7%4(dk-41IgU#50 zhmzPrIdP+gzbKozu}SWpEg?_>!`A)&&usx>YiAUs;}2Rxs|I83^_7D4`8$+N6wb7W zlRr4(`dW6Cd*IU!<B>-1e2T^~gGAl07!A%?x{yCs4zdt21MO7o3*y9qchDR71mGod zEN$+V>QsOJU#sxSlKy5DW{)ctm99`i^^}aM!V@9yX<jDgsj`ls74srA_U{IIu~?_Y zY|a(Q@`wM4BkD!w*8n8eg~^EIXOlT)@5jhLH(=%t3w}}zSo-DaTg1q6?%Sss63i3N zF{-RIM(3bw<|B1CAGo$!e=aQ^rMr#dj}q_g@P5A}jlBUVP0p!JdH6{Izcsi9YHday z;;aMHvY0IFUAwCQkT`y~f-JRlp8cM`eIS!eXJNo*ilFzBqH!U*xeDykeJN}%khXx* zx6+&_%6TYA2$2Bn*x)WH9c0(ealEa-2jy2SUC)`d8Sl1O-yln#prUhzc=al=@f0dK zUg8Oa+zjLSSXHY+-o2g+raT3^s}G8F{G-^&y^`dp@wX{WBD~rWvXFo_XQ(YXyoW6@ zA9bPnpMLMyf!R)^giW#>$R;PWnsmU^VMrG=f^v;Gm!ioL#kM19GKmub-j{)OHm~<) z%rrr!#*k>m8I4IbhoG->un;DtQ{bab0V!>iA{=q>Kf71vynqrFdIu;Yl~N%iX)Z{1 z`YkiOJX#oQtLXK(nI08Tq8N-i6=hyIWDHEugatu)Ms~dIbPlXyq=FE8XKoBJWW=wu zy$YC`pV|C4`l4q~&$+1;&~}xK*$ZSxp|SXNXbsEHKDJR<evL!ERk$=krD5`A`m#AK z2<hNyJz6Dg<MRArsbTqSwZI&bYqte*utF2oT5Fju8B5r^DuaS~HhxktihV=NW0x5# z@dEayy>+qR_K*!XnPn8IyC%YS_-r96ceQu$q?W_Mt{aiBl&ghUX^(-uwrm0^VUD3< z2v7XKazPJqqX~a%i9sx6_1k>R2vG|WWmhN<j9AyY17Lyrn9hkU4t9tsUZTdQeY}Fn z#rO`Y+NrLSdKMN@12pS@cn!>nupgj&x(CS4sWG!!RPo8Od`))-Yx($^p(+BnOV>hM z9idQZA3q+~YqbnS=M6tjNbMgj$aO;0wlj+-_}i6Yds7u6HZ<ekDkbGtQt=GuhxE_g z)xxR*|0I}m2E0`LYji-?O9v;Q0nTE_DDw(4t2HKIw4|&kjE$rP35AL5MqyP}ysi*1 zPplE3(J;g})FeL}yxoo;iI}GZiG2J+_#2w1bgfN?5TLs5O~U`tRO1^Z5zLTshj^A@ z`HT?(bo4&c=BOz*wJ_W~&$o%6^DqhR1`D~Z9QoNE<d?jM`tVfAym2SvN@6{<A zW;OE7!--J=zl}$_r~}-`|0Xy2qelMr|8OG^XT{y(Z|n<K33}GEg=jd|{GSoeiH_WR zX$HQFr|$l)tUUmu!B~}y%~RR)wi&YI`AKEq6GA-LP_D3#WM4k0T-fPHdijZxYc84P z^SxYkZp=mQrL2X>HW=sI*QoKwR%^@+{^wxiu?*u|M%dwtN=2K$gIVnv16(XSj(K`i z4~D5};R{A8@&g+<@;O@NR;nnmsw@e;0pW8bIpsRC#?h7r6~5-c4%CQZav;5WUg&jN z{IvP1H4kjEP`dU2!FTcTHo`KT@|I-z%<nGE6ICjPvW@m%<@C7m5~T@QF{yH=z@=zj za~dtWLEDM`>bB)`h@fPzNUOnaot(u>n_YKETS;KVbH}Kn3Sb9}seu^FL_j=PxwBrq z4N*AomQQXT@d^Psp6R5bm$-H`-sF7Y&F6~W`p}hqM95x67pCm*>N|3l?}E$aqtO`9 zJMm)bkFqk@w_+~81g;JJ)wg=E3}uc|SomUfx2?)YD<IW+7VTQeo#t&{)5NC=YS4i> zo!XJSWiscBsqN|AL3qe-t@mpU=%R4zj#$55L-tDy6PXTO;g&Brw7sC|N?L`7MTl%S zH)z<}z3Fl7%}GMJXr0Obw2tl+5ob#9rPFdN#64o3=`O1=b%_E`U1DTo8%RPT;ne>n zEBi2Jt5uUz?u4cIN(ji%g<QzD6k#e*N06rX_Ld}!*E>?>WN9twBu=#Q>F>T`umqT$ z)#`*Yqy4aedmqdm9t!f?QrWiJ>U$oZZuc*S;?11Ai`1cSVU>5v5!Awbnr$*66@~TM z<MkYoZJ56d-!a|X9w|yQJ?S7Sulu_KbUkK{yE(cD%H9We=)xJ{Z8xg?_5v+Bvm`1P z-?886hh^GRSc87^SyU#vVxJ>AUT`@90~5&s6tkul4*4(Ld21cjVIJ?B6O>ZD83L*= zQB%VTi5~u<$jGvuc9!%gbVr0cLc=9K&2j{4?566c9v<A>`>|-4Q(Fvn#_*W=bfM3& z^q6t>GG`RFo<x8|#SOEuLWDUC9X|dX<Q0q~-Qc28k}T?m-k9p3JdB-Mv11KzCqjNA z8n<(vZeqzvsx5gHF7|Ff<u6f9+YbL(1nal-*XqVvp`6n@EBFx^iYct7TN3ivhEUUU zH(BY2<ArlNPz+{^s0JHuLV5pMq+Ya^s0>)5^8h<Q#J_4zXS4D;6ePo)#2YL)ROH_G zupM)XUOG`wvFFq3cXTy^3={@Ic%b(=hZD0W#z#RJ#U>Uqt|ky$=3B=VtPnXz5$M8B zY~}3RCy(*;RY3}`C*||5We%y7U#YRF@iwXq?z7H0ieF}i4^7)AG35?LI2dHM!oNS~ zSOB=xJ4<+EZwe{^kQcZG6EVkw)-s}<%=lS(c-kZ+W#55bLY}0`*jeFG7?E2a8)~^2 zWJqd9<;hfcc)Qq5Gi^#^awOXEaSlFm8<kj-6s4r#jI@kUbMh=AgUrC(<+2=}ZRABi zQcjbIW4JNs*bsiOOT@Ljb5*SzxqS%DIo=kS!;p)dF0bpDTGl^0@YsP60uv5Xco?{j zDUvMv=P|#?rp<l7xm>KTdft;+H^obKQ?pwp?bh`sl+u4|`)J~vv=e66ZdWdEx_AJT zG8t+5pnIH`utD@~=m=^KeoMz<?-h4T+-b$e^lujWSQaIV*$IyP#(_lcz0~&A$ug)1 z{FalHouw8e$rYGzT11{Sg?eK>ln+pJal*cwj!ZO7aMrkN$7|D?l=^vK@qufa#q3rz z9A*`{Dm;|AU&;Pe@yOiN&W6B0y#AT!CZ_n5|3PeDovv}@)^ohSp)?Fc@E>xIBH!j< zl>d{AIEm#Z5(4jR{3X4!*=Ipu{TfN%_))(Phf%Csrnjl5^lfUqlmopP8tV1@P8{=L z3NY#CbieX$<ivD}Seh-yr`B87_2}SaFFjt{&d;`^Pnzd3)>}|!2K)6QgDAR?+TqDk z6ewuSs#Y`7y;1t`CHy18@q~MfvcYic#c#&6-HbL~BHLbzL5<<HeWiJoDuY^Z$=;Yf z;DUfMEM0us!|7;{p$t(R8?Yo^*7a{7Gm5aIiv6r$$tY=ta0)`%<Tp2p(=W(kCfo;a z@nZPJ4DbWTj1;5pH+DeyHRT00c{j0|ndybcx2wC@&81apLE5eB=rO)tWtu=iyqKK4 zPm_uI3jn?n$CTxb0|?2AzB0(}Dn!e6?g6B=2WJdNPiKL7CJ<#-)EpsOuD6+&f~)2; z^Rog7=O>@|lS#?c0K_xx^Kmg%NfRCO{rRweY&ktPuK2#;_?;tHx(w>zaPY$LD(8(w z1n>7&3}ID6C(AgXI^ySEtPmL7+|T>`xYe73oocr_IEchAd}{sk)<xrkZZPuG1us50 ztx!I8G`6h(VcIHNoKr;Lmb^4~Rt)ln5>Y^Pq2(Bk_RTGz=>poW@<MBQuUn!tK)in) zlT(K&>@WxN3&5x)T%tU0@Fe2H2VPL^%0{Kc;HwIpeI??tLJfbm0Vh*je72>{4%PRV zDVTgezT!<NaE{yGY^b|;-5|TvSW#&iovghCGhS}OFrx99swu#aKYlP6aU@pao4R*b zgoTTDsayrChsF8-aBoaU=V@mo6@jnhPamSOH&WvmRvBxHAk)$S%Niq#D}DqygO6#O z+s~`O#bQlLQAJ-&z}n54=gY6}!y7vX#wdvNGv#Okg=5<9Ky*MQKmk$^l#f2GA04;s zY+N80*gAWfYS>gBnmqWgpRlgx#2Yvl0RWFxMy8n>s}+n;CAH0Fs3Fq`YT0aaT<HYV zw7q$s@>yYCmCpmBqD%y_QZ$fTV@|+SQ47(~K^i?7-yRULo&qZ6Abzz2Dlq^*n^C}T z;SiQ2mCNu%Dy3W(T|fXYwP&I8Wq0C1Yp6waJtu5>1Y8k5h5-k;#pQ5Nh;!P|l>_QD zquafsYgZ=h)E1vkeb2@1qhC!k>Gf!{o<@fZy4zO0O2|8>91+1suT>y>XCXWz-|1T0 z(@kT0`eue~{qBtpwOR0Z@bt@s6Pp8tsIa;Y6rwJ8hX6T=HT$_T@ef+kC?gU;>WrgR z+zr1wOemV!gIkU^YqfCCV^(YVR;n@`qoI8}Je?KJ*l-RJoV3>476x*kfN0=jx0Ihg zV}su%#eK~gJOf53+7--{>j5oyj>s_I95+)T6k}yPAWS!1syNd?35`!8JVXquv1OlI zgv4PZ%8B=>nZM3^A$^0Tu9*y~DNwbgK9*JE#~&5HI2bfLY%l+7hiKIhiuu#6ueh3* z)}5qQ@xj)jK;*H(^oH0sV-FMi_*25}k&ZA+`6^j%Q1TmtjLhCcv78rLbL-s;vW5fl zFD0j%Q1EC>LwbTqcG9=^xf7oKjQa}$<WsZG(-l<~!!*I@G*S1kB3RLL_B!IjURoc8 z3sq8-1h_BKHfsspQqt(@<X!Gkkt8FuHHgA3H6v@nrF%c<rGyo30V55#X^G2ph<fXJ zY+2pAPwbu&RXjho##sC_XcBkF>Dq3FO4^#}fg+t@{0N{mATSvLf$vT^dtcFdfTr^| zZtDYMhQv(j5Z(8O{244Od8ej?1}>l*!J#=^HgTvJnplfgJn?szNjo>GCh57Gm?4&? z{PE)RSC~C#AxZnN<z|yg*TZ%RvpG0~y?{e9+)s@(7L-8S4z>_@;8zM_(#r8h*<@6l z#WnZsI%*BVvfl@jjV^s6bC&YnJsNx9BoKvl77Gz<!t@u5lKdqLaC6Gvpje2Y04=#! zCbdjYRguOjHH_sY5h&WCyj}maJN;2^uQN|M5>fHAPAd4jx=Os}325;Is)#)3nJj{A z+|Ao-%u1o}P!lS!n7mh=dbXF`{JU{zq;V7W)x6CP1w2N0uvx)3317ae%Q?lM-(rL6 zea|ZrdCtkzRsSmSbPfrVi}<SCUOJ@e!yL0jtO*s`Ve#+dh9qc{e4lT{x^5aKPQu!R zd?V9cj*GvdD<XJfK0CP}O5`5J?(Le6DuBgp>TN=hn;(oL53a4R`nal+^k((VAyPJ1 zObTFRiQBBH^u+SbuO<QaI4)PLf#LkXBZ@B?7{*~SLp=k6zqWBI^IZsJ4Zi?8`E>)H z3agk!m6~zb=Nz%L1E@X;-gSNEfFmMU6c0VY?c18CFdMREbxKn4fV;g(74Bu*NFOAM zKShp7y4}DRTLVEV?Aw1Bl%!mr6EY&SgJ4@h?8BD#)Of}G^oDW@&bDY-u0eQ&C%uNx zz**gc4^LNKxo>wyd3gx8Pjh|W1m2vKPu;Okx;wgL7Q&pL01A)%RZ6Gkh2hFHe>aTs zpWw2VOkhRFR+-PFm4EEmedn4g0vN)FJ9E`q=KUDL6E?nyomJSsaNV@!{SP0*i{+G5 za9v#g1p$A5-9Cl(#*aC(LJ=||%z|UZKe<}>k!o*6IUbsC+S61R-}J$g?|Q}DHg8OX zfme8as*w#+y_cG~eB|yyE-C7f7@2rXv%*m6u8^NUc&O;6q~lrLc5rx|1d4{K5uqX? zr$PzX<@rYxVpF;uFUbeH3FdN3->sAH&6NF)04FBv<0GE7{D*8iuX&D{HF5nW|6++e z(30_RxE^x@1FYv)y9!rADI*Y)J!ULZN7vzk+ykvy3x;wO7q`)6nR*Oo)hs7Zl!B3E zL!zhq(fTzuHK*m@!&~V*wvaAh`ZEc>Zd_>DPj+f^j{HwddrgxLAZ3INTWQdXR94?> zUg6pam^RC%MGWs!>u4o;S`lZ*X17bBXK4`{K|TtNt$}jC0xOPEZgJYES!b)S;4|c> zAP&Gxua)2j&(JeSplp}9q7Db>X%%uh)qN9#*WbbP@O3A`;`V-<0}NLMCv%Obsn&f~ zF)nLmsy(BC<yJF|g?u>x8F5qY;Q`PpH?xEE5c#3-m(LODS@~B~Wor+|g>=+3V=0aI z@fGJ*Qau=^I!7!+<Htf=x~P>#@f#TFr`BW4V}55WD0rgUzY1bJ_OzMBY``Dko^b(| zvn=wc=b}+4l&vUHP+a6P@Mo{_bY^Xe{2zPh<UqHBCVfOn$QDis>OgY`HgJ(M!QfA= zZT#0AwXSJ9N*yMt+d|(dp0TxMQPNiS?xQEd@(Fbs7r*02-YASF=fKKm2DLqblT<;u z^(5y)gMcPC@9?H);<?RJyvm6g-Jhy7=a}#Rxp6Gh=g@qmCxIPN|AhUE`IKyhe2G)n z^8S`I-Q|N-v|dT2fWj8B;V<6rqfcC}ZW#D6I)zaKI&e9KZewPtP8JwNj6}xxM+jaZ zPYk9bev?DDzPon}lNf?p1wjUbfyb7oa#j*WP2V`bc0SJ`b9whtp7wI}|5T-u1+Y1} zeb5#nmu4@vdiBi!(K*zguteTNy50jm4k(}6eBFGTk(rTu`foOEtxIpco>!1#>F6v2 zh$6}Pg~|K*jDDgnqWj<3i-ep0iyx*B`AM;)3R<V7JXwG7f_QXS#Y0YEfsNs6n?MSv zcl^ZlQf?GkZJ<?(S|4t-(%)pxj%)`KGxE{u`8SpvNg}dM%EsGdSFIl4^bK$USe*|b zpIU8=YEERxVS+70{kWUE+v;M6(&XFf3x`=mF(m&qUcBdvo}AU@4RqP3L>M-B*7m<B zQkG@=?DjLza0()zlG#R9t@EUV3sTM4_icl#zTbCT4%ZbFN6ivo0sE=+#F-QuVGM>f zG-*wXLR)8nGrEmg=BjKBJ39D$)~s@;tXpl>`}z09B{MUfWdZ<?(J`{{XT<=JSYb%9 zVgY3qOW*KTd}+zX8e%4o0P@k#V4##rUJ^PpEDVtS)Hh$WTZn!tNF_rSh}~ygiGN(} z%Xy48k6mmD*>%ZzzSSoKsHxDlx?i4zxDeWwEDO@Gqy1dGL*My6Xb1Zp>;>!TbxCcF zK!Zz9MH!){;J*C3bP7HIEeIMSjZVbj%m!C55X8pz+(MT7Xq(<tnnnjAY$jUrctN5V zx~*Z<NhOU<>5Yc)m<+5zAdfrJVBl;R1+Sk(2|E_7NioM3qulo-a+|ICsFWb;ib(Q_ zL#K`bf<4aH#>h^<?Jp1vOtNjqtBUST(h1EHyfeP?gA%HNq62;jro><zLo1otg)NCj zh}#AM+eobC;)J|N{sllEr^Yr626KUHYwvdaT~%Fbc6^h9jrT35t~#T34bszN#7C@( zqrt}>QFxT7L(=ivNdII+xhhQu>}AnN=$p)I>0$L&u7!9oxbIQ>F<bdZpTMd~9`Ce( zFJco@5SmgCK<v7<wz%D#Lw-jdY0j;{z)B=20o&EyguZvbG$^)csrrr0XV$fG6^Kg^ zc{eIlE}^JG_O1LJQFdT5YICs~)am>o4k{Ll0sRH56Lu~H$>ZJE4_MDg#u0K!8p=Ho zB3B32$-_=0f6e@Cs|`Uc_^}M=`B(?F<_FnkZonfSB5u0vbdkY2)MbDuNl)Q?{5qaC zFsH8hg_zX`(P7WRywDbgEcMr;aB))Nc5*Ve9M3skAwETv&@|RtLEviDi<u>CNpO#j zndr0rlro^dXG-%OYLxD>N10-fiK{Y9WW#)Tg&fqXC<u$?y!62<veU^)w+r+<XL9?y z#C&X~P>Z$l7I6C;NB2C;5l&CT9IDiWKr|m_!~h3{y7zW)`2{_rNB)0!*)zWki{aYB z_DIm9eu)pwr}FqoL+cV42po|dY6e#En35@)w!&m&EipfNoXETW2vc_IkHC!Qa!2&o zn)`J<wN+)LC6x-S=_EM<P4{L>nFSy-(7URono&UJ$w|YI600f0DRjsc!$%3sl1n2J zQ33*^CkTfhr>BY6S5X&%#siq{`(0n79!Y|adp9~*9es1u9hXMoc^ZeA#ciw;xS2S> zt98J(Xe@A7S*0KzPx?h*h({@kH9dpXvhEojY3u-eYTHaG<aM%~MvsMzf>jsI%#aL^ zhtA`30Yk95Y!3-mtHUoc4-@*KEOcRC8y{j6f^yLb*_ww{y?cbZxKHT+6=1CxUq!u? zg9k*ekQ#*!%1rz)LX`ETB=3D4mu<H4YK;&sXaa7iRyaHoXZ{t~#TgVVs$dfm+NK*g zBWV@-DR=;VG)BS@+fTM%K!~7x(gywMwG6|G&SJCe0U+kQhz60zgns?((rmw-V4qo> zS24{jvFB(jD_P36@Z9c$Nj{Lcir*j!<mIG>{j}V)2d1)`;>UN*6tVPFEE~OG!KKC$ zJ)}`TQ8oogVQ!tc@Rmzaa>|MA=w;7}0hApRl3gNp&~rWLqmAKR8sEtAv8yM}6Zk|m z4dVD7BZ{Ndn~N+{cQc}UDR^)cJW(@sojljs`@s08;DIpz(tet9*l;3%#HmU^=<l}+ z6zOfhjmBHW-wvz2&6AoMDX_!f8o#1a#nS5Q%_y&L3?hbU7@kL87Ps|bnTm@mUpXQ| z*s)4p#GG;HMvg?@>x9a10FEN?)DL`PUcL0`x_@CrpACRH(Ib7?el6!Xi80*uBip=a z)E##?9FKJ>Q9Ow$Ias&#^MaVjlV4~I_cl4{p!{lHx69BSU7D8gzpW5oyit>(PWtCp z&X!bcob!98umYkSs7{YUZ_lZIBNlJ39^A9LZEXKzsiel1T%~B267eNzlb@z>r_alP z96ZgAct6<%Ci8v^Ff>Dzh=n%QS?)o17vK}|@peABro$R^+&p#X9YU9FTGiE3uVbJ* znR;@%7n&2am%gsJX2XAz(fMH>GS60T3P4*421p1Dqtc6%S`jb7?8qhljIvKE@b>4w zLJr@V^McNB;I?OS+|S9?63F=YW3wSEX44`~6{sl9jkk)c<I1((PT=&kY`jMCj2q(r z?7b-O0tdyY{LKxhJD&bW&lxjY>{7zF-y?y0g|g2ilR$QblmlkyX#<D08hwSqZ|;{} zoP;!Nxr%_@R#Fyn67pH7tKBh`5e@0Iy5A&AeABfx7~K4175vW_)teIS?`nf;x+*vW zl$e6adq63zHGF5}r);|W<#wFwy95@^Pyotsy0alf^+tqze{?j<jW9;4Y>n(CM6bh- z@Hyo$2p5!hEW~C|QAXokez%S%q>oBQZSn+l*3gBO5aIf87&$AT8Pu}x+QZ4KSA2x1 z*rtAe1)l_+@i>%B?U#Kc5)qM)^zcLHT!LA`<MGR@2cPM2eT7dV_~u5NxLtV>f-K57 z(>DLzCRBN*si&|FKpAC~08>ys;Y8L^>S&MSwGy~=Wk<yX!o{7~o?EgDNzJJx$U*Jf z$-K&<(q>EfK)49qC7Vf1C6r6UzC$n^NhvZ;PhtxfvJFTuJSRI}(CFonV)D05xnnWd zyzB%vfqc7#^_j+iAM8`&n9R$6=MmX~0{N+_MiFee3dwC;)Jo@CeAG3A!^c64x=ZoH zT{Fagq@qZH@Hl6H%ksX&#M4=qD+izAqU)C1_J|aB3T{jEc5cPUGF`qf(22IozqqUD zb^)zHR9R1qE@y*5)~jg*PY1FpVi-pwZ{3h51E&H}=VsMaz-9l?<^@C0N^I8vP|d@9 z!ugELEi^wB*d_^u-lcu7f;sNk_)X)wGQ@ncpTHvSA}I1V6W=fo;RK3T?T0GIg6#0V zx82l%Bu?>1b~L&0#y+?=l~(9Hwz$lBba9yRno|0-yyeO(uhVg^Sv`*ub(Y}OQLQ0? zQ=S4dq<)K3ZRm$P@pvRAo!7=jjK^7Cv1bN`#&}4WP;W9K@z+Fhg$Ty;mxcIP9ebsn zch&oy49aLD{?ro{!UF6H-@T18Cq}q;D4Y>XGd0Ku1bO?v<9?#c^hZ1>X$63GlB7cX z-X)A$mn{*H%i$DP%Eay7y%94QOM2Fcq4dddKnQ;c6K0lNX#Lc$F8Hyo6YW5zL83FN z9B2Vmbg^zRPF0CXBw5&HxGybAi^Z6$7xT09_O$CwN<0^8rAQ$60uJhV=&hW!hPgfW zBQOC%z$|@Ez`)sYZqj#(#mklwEl1Hobum8(xQ<x{yKF&kw!}k5gA+)ct0HbrX=mSu zR2mSD^Hsu!rI7^NlIFdx@Sz)q>*OCSDTwkU{fWN9e>!Vn=u!;avsBuHoam?V;Dcm6 z1o{&cBPbb_8YBgdDuNi<P?34Ama<oySTJzvBP&2OxsnLx*J!vYtZX1fWtU)kP8#8- zbc&mb$r4R@-(#Gof1V}Bjy{G}TimZL-bXwNPhk#m!5*l7tz-k;xl3VHGlW`R*XT2R z0Bw5PG`xBpoq!=v1WD5@uN!AX9HV7lBkhI!0aUz{*y0+X)p*zLT=0j5Oaggg>YLiP zCrrwOy}5xGNJ(!c`g?~(;pR^rQ-}YDn*s<<U>$~BL*i^jtc_Og*wBvZ;{pt5BC+vA z&BZ2XDYO*6$}sscz&l+8fh&hKMNE2F)USJJ2nM>}<Q2ED0{+VM_W+oYXuD{T$j*)E zU&6O-nZ$;WQ}(;Yd`poO9sUb)_DQD9N^DHWv67qEQ1n~kU1j82G1HKH%sr$7$cPKA zEdrKzBtkm*S(L3NbrZ3d`4%%Q+n*YtIpuui2#|CbvI||uLYaq6q+~YPWWfU{FO{ZD z$B~Ec;=JpST|iDr^lX!uzCxCkiB*26c^E+T1-7V!fb#$}?6*=RgmlXatbUQ>F{!!s zSbjk2S>E!GX9&0M%NAfYYD)qQd|dy|K`k*yC}wW_!;XaBA>z_BBPp*-ObpB$E8L3e zHVmlqu7X7ouU2baj=rf%g!d0%88X}k1+O(72Ahpn5YCh1N;<I`j}D`4<|(qc=JG+V zcc4FEJQ3p<qUb$Iu+wq#$-S2b96R2?nW28V(rSg!Cr1-DKRW^BFbxO`%<lTlJzj|9 zja;W_v^0;(fB{M;xMcTWhRNLc48lA)RGX>{HT=cfq@|cc*sYR^?Lsj&8B%q42qMxj zk3p%b_}2%o=?eZ<?F-7`tk*7)Jx4(pD*lER2859o4lflC44>MD9#I27VP1&1QnJ>D z6T<w+fz<auhBUR#Q)a6k0iB19hF%a890~c63X|Xll0edFo935{LSgy>0Zq3eK(bI7 zSc@w|qZ!8pMMu#wcbHZGUma*pN^e?*&X?{%GH2>fYD;F6gCYi_8y&czeU|>_t)|7n z6#=uiDxt06)=<Wz^?O>R4oSh`xfKN_{ryJXCLjG1f*zJK<Y)FK_7irb1h*T$hbw=Y zWF7%S<@eV!J!-c4?R8sbB;E%cA|t8_|L&1Xu2l&F1225XwC7m)u|x=-F&Cw#$a;cP zbJ*bh#U?6`<b&&&iS1hiV*J#}#|&(k_e>FBTpOO0EF;ZLfn*Yer(2Qwi>Fo+{!2P; zb2R3TH{!;h2dW|%C;eO)jdb7S2+T*HrPRbv!4Jw>VVn$+eIIeT0~-!-e+je=+sF{} zO6Uz-y$DQ>koM?=6`VIRy`*7c5&Sr0dL>Ua*<6-Qxmgv6DKUmv!lLh4>DMGTkqOHW zWgibKWE!61wr~rASQ+oBk-2<ufqMa40i2`nXNDZabpRASb-kvi`~NWOUj58lF_%s} zAwzKse@Jm^6p<=@*bI=lSWcPwB+@`O6A2bLovu_{x;*}6#cZ&ldva!(b$|NXa4O1K zSDa92hrMukjaj3IXg_~YSwMe^P3%WdTFf#UC=kBcXLCtkshyqeIZ$95A0y_Lr4!FQ zaK^<&g}1Oo5Lui6fBpAyBk5a;ex!1k8$KvYfw>dRIjEUz9rx|?@E2m4SqRYjAa|MW zYHV#{?n{IxU%4Q!n!HAhdraygjb6IJJnkKZ3HaVf3*YQ-bDWMXkco7)@QTH7bcTU` zh!uBB40Q!egK;MIn1CLJ-6axHe1R5<0YVKhW%T~F3$37KNIIufx?;tCDvn!scwxWy zP|B@!?}++LQYOoF$nOF3mfbxivJyb7vPvRs)b^vi*J(+oWY&fdG(UoG%AVXV^uVku zt27O8;AEbqX^w{_;`*j^Az3vqEhf;9#0(5c1k3)d0sH*30POv;+4K`#!fQPx;?w2R zrvJr|h7E@l5`cF|`_}L=N2df6xNdS5YxAc`3CNx6cOLMr@})8*_v=5hZl9>bL1Fox zF9}JIr?obHJ|Shsc|w)n3&$B?skOSbxMk=DQ!FJDs-BG&`UhfwFjK^L5v7HS&iv=X zl}Qw~*4TY?_Q&6ETDzO#N(s6@L6Vc~){G7k(D?OeZeneY3tEfjtmj@(GfA?{+K=L< z%8dvOtmRJcXj6#5Yy}muTUj3`9*O))F)Y?@{=LDtLpB-BT;q+3iEwa-^;k`R0?y(0 z-MJkfe=T#4WVgpuvR0@BG)ot!wzVV#S}bnboEfY6kTpPe(zI{-%S_M(2UAlDFuh6; z;~LexZ$rk#`SDiCnv7DNjkTKKRT}jT{&ygZ7o_LX1xHO=XLiLX!icA&Ht$nY=yW>C zJ1>|`TBXp9K!@HQ)gXh3uPJm2`89ZR0AP)+7s14`<mnBAtIC@yJ+N9S(65eAI)HIr zU{mHoY4o2oANIm#EowxhsJvO&8y@|p7xZ&sFhQUUSus_pd8d}6p#mM%--6t_P%qQZ zypsh<J1DzFtj<F)Vz7Opj2!P?2^i!Pha80Gq9=?Opp%juj^k$zd_Bg7=KCA=!TUNu zR+UG(6tth7O%T8tmzCu`&)3Z?=HcT<n}MKlC%VWGiiDDg!SSD`mr?l6hH371cpd|l zw(+28)eno<elF*@VqyuhST976{N2gJ2T0N!w6ozw$fFozY5?)v3~xeL?pK1Z!}(w( z<M$@5%Qil+8q<^~u^%F)Xt%nL@qK=N`osvDYP)&6RuSB&1-NKceMHQFYn=$>1|+{0 zp7(i?acC_St+D6v&CCQud||&-TfK3GK~M)V@#S0F;LkD0wSUFSnuaBM7D##lh&M62 z{H&`L%Ia&2*5B617D64da<M4ja==Mr>^bg9^p=B7jAIazW!gVJsjkNyNXUBldN7u6 z4KJ3qYZI>(p>`#d|NH(<caJJz5Y`XB5Uy=|sbIb8E2NrIVHvbDXWxY*X>oV>727*{ zVCKh>2xUJ9ik|j%X2JtWNm(1G8|sG-3*{7u5{NSO#vNYXGQwBg1pG=Tg)mu{hEdy~ zNH=tJ!#W$0;h3s7G-VvX#z0`60*@J6@bt!Wj$^mxluVxd#dLqeOGU~A@2~}vNz0E4 zeG)e?bO3%~RoF>vCDrUL<<~ws!v(U6neVgc5~<ln@wi*iKapszY91#vgwqxSV}iuZ zTd{ePd^vvN#KXy{X=DJ{azea$&d*Ya27n%oI*pnzcZzV3Z{c@KSrE1Gxt=(tc7TK} zTU7*=yncqxzK?^E`JTViQP*L1-&z@I%lG6nGt~n_AIMr-;<9}z_NzjBA0XTY1o_0S zmCd>@`hSNqKTVZ6mxz;UB1D;V7|CQc3i|=i;TJX0ESh?$35cbOnaqa(3xFDWv+o7v z`n}wtt@4f=8N|WrBXK_8rMTF-zayN%AI3y1?<Vaxn5wo3Os_jvNZ@v1e}3eMxV&Ze z&%gxX@0hg)EoSuL_^#*%o!=s|@M2xGc;1OjgjCQakMpwUZN@W3!9#ZP-L-M~ZqRd( zZausXl{0N@kEsJ_r!1OWZ?oh~v2$<EqIzNGMl$16oaV43G5ho#``47?hOp(vB}X2S z!iZ9l!0<(AzpQ!LPyv}2FZ&xpIA{(68X37a>9tk$aY_Nkq%LrEG@=ie>M!s*$~ps+ z8M@HfK(<g}Eagti)vpZ=TFj)8a<QXF^-yL)Syu}^7wDdxo*-h{CrFDljtxZ{H;$jj z&c2lB(n!$yo+Hr`kg3R0d%YVZbQ6R+RE+Y$pXz0ix9PXTR$F`4aD1h1D?ho_Q}?aq zG{zR46u3!^7}8*2-EtgJhi|;cO?rqs(*9!Cf{^s$xdhOj58qEn!R=yhvFH36%v|vO z%<u3%yosDT6*KV=wc1!lOZV5%nAEVF7?OHW;@^)_+c2r68W>fvjxfTfU+MLVnmv$? z)bqH%@uNOJkO-F!m?u5aBi-Vb@3*m|65S5Rt!+dy_5cIZ3Zgh>`*k?~;8kV;uI{D# zldvR?&Vpzg&@Z(;ejq};U>6e8pl+<of@QVQ2-bWW=WVq$fDXviA<wGtjHx=$?i2P= zsB6ak&0n4s;tJ}Ep|f>w<fbrExHS)r6}Ku#F`;bx;!Q{Le?$vt5E8P2FDO^|BSmaS zujPdjd^TsvJBGz{jf9vu#Yvy}?lk)tg15H&?-OWz7!3J<-;gl<&>fs#;hLna6jROr zB2}d$ZJa(I*LYvrNslci2e!bM$}!$I2a3kGCl!C9`HIO@q?nHG&YV;;JKo34Mf^V5 zto;#~y+rX+4FwiH;I!YnBW#Onwbe3e4vDRc86pN<Zp&)n!6OTjoxdoCbuBpABb2If zR_{UuW$O$2n6G%E)9d)B28f$hSJmG-l=r3EJti@MilJDs)8dm6FJrRry~a@e{;q!a z5$$6KfE0PR{4TMvMJde{ii7@d$^wUD@QOUE^wd%u04l%>VCXy}9oVZL_)cZ}umvo) z3se=rvq3Ml(3>5me=43DLMg_E6KQUfLsi#-+dkesUJ)fue8~Czjh*|Q^Wzm~c_(@5 zsi)DMi54U<nb=oe(2s;NJ}9FVCTZ@+tZ#xT9;qx23*B6L5HP4xFF|g^qiB!T0Gfxo zE`e`cR*}g;NlkApj&wgSLT;W9+a02Zt)+esAJtP9L~Ky$4DxhoD?y1&9&wf2hFxy8 zQE_cjA!53zDM+rU_qnrPyk#T2aE{r_v!hlSgea(yj~98rb!V<!P|NCDEYs`klSIDx zriu+S50%+Jy0Wv5+;M{bX&iID4bx@#Qxj+FHK4Az8|d(aNISBCybN0iV#<^BF-pj` zEHE79DNhmSXN5@23>q)%p9op<#sW#7mPjbVtUE6iCZsK1#7AHkZT@8EQ+fu5ml}DF z(<ix&b_@UUhHweYFxaSWteHr`U+8;lLpV}WOWTHAW{qoNdY6!iF@9T@;(Nn7xtvFH z@E_gfTx_SzEZ4W2E7w3NLTe#wFL!xStPf;OK)N@gKc*~7OLc9=t1he{WOy@Ekl0R^ z0}Tc3mKQA@J~fM6`gW^2NL$o(K!B7HMpXf97r!2b&wGeiNJSRENnwE<LsR6wP(c!1 z*!IL5TM)Q7+KFvjrI77sUK70}sI<<8{j|N&pYPyr&YZiH3SR-HH3v#kiBudDA-hY| zON{3v<AH@Wuablc`Ui?<r`q9`P3b#<c+py1-;C}beQKm}K>!>}8r3A^1xbot;7829 zz3`Y#CDL_C07P=CO5+ujpa4WH?+<!<WT~<HYX3I}v^(^>)Oi_HT~}I2CcT+-o`&(M zvVt-<iD8mh6bx{KD_25PYZ)I=M;(gpoXsR#&Ei0SZrmSWHT(;()ytad!Sy!U+48si zkkg58n^)m@8)`)P21XkKSsOhzCkYcjA{ESZk7Vo=ALdH;u-yF}4K%nZK6QPs2~?#U zu|AJ|-scAs0k)YN<Mk+1=$ZIXI+V{(cPn6j0%kKw{WhK3%V0OO8b&AQVSQH@y1%wH z_of`v`d)uGg5C%GA6ztmF&p#{nc|bCP!-FfzJSE5W2hHQ$_#=6Rz;|AGyr}cw<F$J zO+Vfj;<k?QX8@0LHw?WmjHOYYjI*^6TXvvC?;wxX2#W)$UVqw_tSKhag&qZk2F|Hw zCl5CVNZtW584b_7=RR0xmcI@W^+t`!+yI}aC))6rkK5$=-S%hoXFY;<ZXSq`x<mQj zi_^>OuIdSpHF#+9a_2YUd)nUf3i<w*On9U1d6`J_E~c|>cuue5fURN@mzdgPp#iJ- zh+x2_q`js^xBz{ps{y!~2e$CS^IR!p0$w^1%!-v_()rDmdN6`+wO8;A=AlpbD_GTo zt}Ly7pd~6?DX1#FU`A!4f|^$h(t{RNZ7^X0Z`F4*rawFK0n<)Sfm~P%CihV=2X_Z@ z4{eNOL3HoRL)gV=%w74Mo5HOo>vbl~pOQK?6Mu&neifr1d3$wcBhCZKfqKZ95vqUs zprCJfA=jsJkAxG)ju`n5_aw&{eqTY`4ji}ps<nMhC5&G|-yGn3u@!W!DVU-Kge`}N z$uHL+O##(1-|lXZFSLHw5e?zWY!z^+)}nMV<qi>kPnp1q?PAe`2%p)bH9Oa})t|8N zJiGn&?_^7NE|)l|eNjcN*u3~NH-W=srS~UPf@}Gd;mCR9E&5;=c{zk4r019#z;_ZW z;)nRkgvnwS2Y3aojKceI8k{GyD_t8oeNbe|cKu%71P9Iw%Zmmc1<<7b8ie@Fzp%uX z_`E9(FQ_Lt;bH|uaM_W*u$}0se@_|6#tN^2r}dt7Us^8GK6#x;ge?7CM}DQUKg2a( zaW&O6OqQjuG(rxl)ZWEexiQ@Anq+XlzH*%j4PcK5YsoRWN6=~Yf*2Gp>PQsz&FK!W zQBn`_2`4F;ydG&l{!9gjUZg-`^k9)f;fiojyA+X0xrv-~{&&LqOH7B_%^GjjqIlj0 z?+0W@<$v}H?((~4aLq)`^OF?~i)!Cm{GJ}pqbM8hO%_UB(VDJvvYj(}>mNpuGN{(j zY&B2pSRE!j=#eJJwKAvrkpa>o4S+|Bs>pqj^bR<wO8nWXI5ki6E~LvpwBfc)aRy)a zFkw5q9M?gJiuBSdg7nSF&dxN4(#Z!G3jgkDg$A9ah6%%VIP$!o6($K4t&5s=PJkw# zS=?nLV=gB2^C(5z&D_-dc`ZZKqz(#O+dHg7tbHU#4CcM}ATZywdv8CeK1{#lI6(dI zsHFT@Bf$PKh;t{Jl-9alM!e`_IS`5uiHM|Tvo4l#Kv}x=jY6rrmC*^ONMd><qrF;@ z+>AC?+AQc<ws7EKZ_Ux(QV@$49A8QQHn>#T$tGGaGSLK=I)K`(v*a$17Z5>DDB2A# zs3gKMXGvYczw6|gfVdL3*I@9)*@mk8Q|Y)jDWCJryh`YTo<@<w=qP&rypqh@hb5~O z@X}5M?%)l16t$^ctCn8OU+gcJqeNo-iux#=eA_(^8S}7d!~x#iF@SA2l~M?6RV=L9 znTHaTqxcRQ`vTfWB$aeb)e^oY<~gRv7)TT3n1)*!xjSiAEi&$GsBR@KHUO=PL4g(; zQy(}(NOQgg-@#p8{%*jbe!)3$pPOU=e~86l|7gQWA5w<-?8y!wFExYZ-wpjIuCO52 z*03V2@KdAkF}T!WHa3uoQ{W92EhFx|*2318#IR`T5hb2=2IP#5hAcW*3nC0+!wXZ> zb8~<wD^tcJH8h%4IY+vp4}Q+1p^6eA@Jzy6&l9#5l>{j<mV3N$L?<V&>EITdbV{2X z;F*2bG_{lhH9DwUKfTI$>_{;HLkxOc3ASC1gNFQCq@A?vwj%a;$29J#N}71vk4*-q z$hiizys!J{Fc_kMM7gmWHxi!cF}L0#2T>O?9y_x$83$C}bs=dGYYGNPnI0v25HHLl ziI<?O#&Z!$j03^*%CjN}J0L(V&n8ZPV1>faCz^w1?+zTAujqyA(A@;s*faXadivzs z80B!m3CttC;JWgypCfu8dhVU`W|eH4RlvrGc|br*Fln34;?*=_Lx8>PW+8XC-}qIo zQ^X_>CfK!UNBg&f%Iw{=>)I@U&hGzrKl&*_w;=MdD{#CLK3O&-6j8`9m+r_VxaqBr zeuTB&;<I=yP0(QQyta<x&L?_HXaT>&I85SS@QqKB70{AhLr#HvM4bBsay8H@if1yb zmcxXjJQ;(p=Z0|i<;w~prhH#PNq0}>)Zdy;{aq}F@o3!YwItNO-(}NMuGYA2u^L`Y zb*;ouhUuc32LWfqB*<4qq(gqRJ!OAL4qj8AsckKf=vU5Q1RPSs@3NEwxHg@9EJQ}w z*1k?SG&|TM+iO`G$qr-Q9I%(`rrGj{yJFgUjxZeVbK2ecy?%Z|PI%ZfV}1?PJ~n<e zq&MQU5{+5(<w~3L_NsXBeN~6KyO5MN?JX1QrfsOjBdVquS_04Isa807z0GYcuCSCK zN3r{-13AlvT&h)8p)e<k8S;eilDoftDCI2v!D3WDyI<W53BePkcYjwQIcEIb)~b1w z>~hcccv!)?<{n?FE5@Icq7-fj{4CDm!Irio3`a|8{5z@K8SO$^K(>B}$R_C`a>Q`{ zJ~oQp*MD(jY~626`|cgM&uf-Xv*P>&j;gG+*wWVnA1G#jH=#FpiIre~e}91)wzx^_ zA)5u%G)t+qTd_imQ&w6j7IVs6(3PGp=!`sT@h4Iz{BO3*787QpG)0{E_mUfhn>f%h z;GavOl-76#dQcouyzEBKj3fd`1S`Bd=hdb)6Rnt?$1eatf^_WDaJ_HBaRe3xY9D-Z zJRhq=G!dqBmr<5|O#ILu(8O~5T*K?=O%;7;D)Y;CUXgb8)B6?^J@Mq;#mjzvC^^%0 zdiF+EZn4JxwaJq?f292c$BS<d+R+*HH$5)NlS(H4`oXFPmb#B@^>_{oA>j{~zMG)W zG{y0$4izXFi#fHghx$8r(y5}x&3$|gxs20{P@KGbqTeZ{nVfm#v`I&dPKH1N>cc?W zfNE*<Emr-`kt#jM7J5c^kAopsvvlFW7l-m8sUS1`Voj0Lksj1_#ah%584-Aul(ax{ zb3{8T?mZ8ja(HJJAsGyC8uWOC-8A@M;Enn{L+Cd1UU_w2)SGn90Hq22iWp%v-^I5e z9A_=TmjIKylyj3N9BPxCyS@_m(>lk{5a?QmxSLRoU9LyCy&?e>X2`hWNr<i}R2w`0 zY7G&taT{2-a8^YLm!%8oao2~uH#4UG%v0Zs7b||9qWlP@(Y5A$OdTmim5kvr3^kt# zow@wPFQV(CDNn&{PYN>vg;fj`+RM8G-c&7Iz!~wIx_9!7>PxPxPL)Ek$qKkQoh(0W zA9VnPNMtKS(P6;@I~ly0(n0<o{)?9xjq$%lAd>!YNf@vCe>GwV6IW_8Y0j^u7s18# zZFkTQnmeoP1ghsF50FHDzvX1U*ogp29Cnxgv3E#&@r$dD-h$NGUpFT~8eeFF^h1Wa zF$uSpMNA#nJHe$|1!diMVQ1L?cd+#<E$WnOfZrAlK!58)pSn{dc1mP?HKS<INW_*c zt;j>7Ho-8+ltVvG5-{pb^RRRoC%(x%{6dM1Yr*Ec846b56Z%5I_k*{+3Zx|c>IR`F ziJ3w_NZGA<zH5|1>0J|#Y>j`Eoeg=@LZ|LgY^H2J_UgHJR*^6>4tI+|!vGhBab>nO z1Z3;-dG+}-;NMYn6(YND{nnKL4J(2o!``?MBRJm#k0QB)INu3{TL?|tf;b|PM8XOB zY2?7w9H`+~*Z>G-C@((@q7jr>TSG4>WrTTror9Tz!}jQ0KqTGGriBi8hIL3Eh^jKy zb}G~3KJ(bvr@aaffMvcam>FH=-_#B7^wVznH)CcS{Q(>a)?k(L^Tsf;W$Cv%XEIHu zGAc)zEVMv}`!!ZoHdQY1EUM(vG3}GNVo51>?*;}xU_-YCAqS`%T$!kLe^Y_952n%9 z&sIMrfe}1V)p`O8*v2u2F(}kGm)BOsB%o;@dNS3ia|=|5C-5MD4pKCfC$oa!hO02q zqSw0OyIinQN1iWGC==Z(^zQ7`F#VA|jh(;8;2BU*Jk+CqD!!1$1C3obzM{VQ)hy>} zly3<BLIe2#SpAZ~(2A>5vtM|U9BjWR0WDm&Jj;ufYA#qYs~2*RFyEUvD&8ym!;=lV z9hyGCk3L$AEsn%-{;oBT<nPh|ozwK8{GOtRh8S@nzX>>Ij>JnMW<TnIO<gO+`=Y}C z)R@+k5Ta<n&K=?Sk_@y&u{FtYe<H?$%G96MQz;CZAL5*VMI!Vy^anF3abjMfU>PE$ zs<T4WMqE^|WlbZ$3dH3;2&ykyV{R3C>o{oz&{E)(2yrT&%K`Bt&D)14=E3d+Y@0+@ zG7*4iAX+Htk&Yvm!ec{t(w0koX<el(B9@KY`qcH`Gxo#nV4!OJmkq?Fyc?84^k=|~ zl)LnVIj8Vak~x6}J#uPbMdghAmE$)_Nnf%cXI{DJ`+E_PCnlXKj98@XQx|~3utqYh zg*LUH>PINEcDrtWuc;6shaER4CuUYB^#WDSXIFCjM%RXDg5u#Z2{{`joVmLel}0U0 zDr@jaNX@z@m!IifkpownXE`wZowZ2%7XaJoZ%+bF67{(MUzo1E`%!@5cnUnz9sD_} zNNP^4$tU+01+fziv(l$|3}m5`3UjMuaa*xR3IWdCH`Ex%U)LdPl<!zGgvm1$k!}x4 z8~~Ky{KhsOVj%wWZu4j(|395`kPk220#X>*B5UAd7`4&o3)79JXGRmgs30M#!aaBF zmH`o#Z_%EBY~YETz1_oMBKWjOMkVcUOtdH<W@42B9~|gEJ(U?Ju_0srZ}yrREah*y z^74QROwViKhOv+F*TS*-U=-jP?o93kIU%kV&^hQHno5OA(Rx$&Ze|LN>i=tl59}<^ z>zA*?EXKaNq)DhP?uhot4wgfW!xH@>Ar~#Ql@BnAbN{JimK?A{VkY(g6xpS2P2p2~ zf)KMMeb`K~Yo5Bk8sGu)q)3kM>g-3v>Ts4&(3n8Ui3*_a<4x8|SM%4_IkC6G!B{MX zz2TXYK#2MWPYpliq?wateUh)aQF_tVC?`Q7Yq2^<$NrMd<3@%bc+~-JOpQ1Tc2ZTG zHQolCa$pwxjJcbC7YT{Mr==)*T-WTfL?h78(Ovlks;CO81~OVVb;Xvip!5X%V#YvZ z$SD@%x4hB7C&gn>Fh^p`2xhOXdr(}`HeBI)lG&?5I6IvEGB<+ChC#HDb1_Clmlf&| z^ndfQ6SGzb5(e}v@2o^B0HM+DZRY>~LO=4|q#ULtfYtZGA*9&f{2wq$C%@ScE_@E- zaOMFI)|x=^gv{VX>o#>T*8w|@Q7_HCVtxThkv^OQbuY|b8tg2uHSf`+@@E2^QEDU6 zqouTHf1B>m+r1r*GqAZP;2D8;eOhl{;v#zY46I<D@7CW&6+4f?T{p4bFD5E66nBp# z6P@ynUnXot8AWMJ9d5B}O?uVLe3t#Z>hRFOEiCon?$(XSCH_&VoKUJ<Pu8RcPhgAV zgHGVN;r-yFugI07k!1500`HtDX{Y!r_ngP1E!P~vCtPU;I31*HNS_1}E3KZ%Y83*! zwxE0Mg(szS|F)3^81loPBH@H+(McxWP!V!cwDjgEv8!g`v@x+#9RZ`!l&MTUJ9T3A z31gU;IpEk{Eb!iWiMll?wR=h(;Q+~mv(|&3Yypo4yN>-TlBt!+qizJ&@}4D7<{Qle zDf(ed`#-+B>KR7$R14oy1kflLAMoo}XCZOLSi7<;GL&?Qn`VEl7F`y9Z+s<Y*!EOn z{i$>8na~p^JLt|tb+;y=f_imNWcomz?@-k=*mumm6ia&Q)Hj3b5)o6EvXArrQ}}0^ zk4&X~!Tln1(YCLJfBXt*tryla?j3SH&mvW=qtjdKIza;xbu4#GN@LG5V`98W7qwyG z7d`z)xj<myd98m^$5xSd+cvzn@@Tq*2i|>DZX3$}3oKWnWLt5%aA*JbD?s9?k?k=C zLc<zkpmlo`JT|lSX){rrFgM*HxzpAn*K>ZP$I^iOFsY3X-b+w(q21Nun_>QZ9HV)? z6LA=|Ya#{?<q`adN3E)k{rt^1{~;Pz{(W#i$b;y&kcZUw(ZJ>Ee2=1vim#)a1f;3~ z)%~aWhIt%280U!z6B%9FWS)5ezYGv>gfA)z6;XWc#9dHpwk1>Q#V(yq+){TZb+F)z zWX^JUD;mgASAkGgMzjXkR1@s4eJ#l&1??TtWI6{;ol^K~*El}j0t|xtl(;C8p~=+X zK!A@5r&skCJnnVH3Dt3bGaW+uQ92Cr5J37aXGu?n8x`IQzVJ6@?0;jMMQ0sWi_&D$ z24C=xT+O#j<OTT~=@%!Bn9-(ZVJcag5jOLd{s5kyVjgQn&2H9s^5RuZBnK_#{<*@i zs9pwNQ|Z4HBAoA%{iq87M6-*$h1XCnkepEkf%mZt#iRBZ5rX0L$agGfRkz$*^qy?$ zLm${{_jzZT?Oz}__Z%{JjhL5L!uDJXE%rU~drO17z0qw%u)P9Q`#tCeE@ec{Ql}ET z4y6xdb|#c@YNO*180@AQ#JD&Yb+PAUg%rDjx3;gj%j_SC!DO_GVkRwJjUKhBVP-^P z9pvs&Yuob8D}x0D;%|#j19VuDWLSNQ4BB{ss?36UtJk#l)|gU_^Y-W$n<hw4bGOqf zdk$J;Z$2VGd|iOd)5L?BWsmJyKY*HMSUWYTvD@XbgTnM>0uhT~md_;(&pX`xLu^Of zYL<OO7;+`*$j5qfh-#>l7F-GM@ZyOGR4u#QE`8<!_JYXU+vE%0#~^bUVmtA*tRuSS zaV`uOB*7H4YT8EfQBni`=xCl0q{)=626rn4_S6Nox5hC+&@WV7z`WhC-e$^<B$%l- zuP)=P*wlg+9#jSIFliUT*Q<T#>0hyaXQ6-$9|ILy3501e1i5c-vai}|MdT+(75Z%o z_Zmrj1Mn7&*%2IGj#Ax$L&n{YmvOP7i9u%Mo&ELO^YAYK;PWFt7G7FkmAg8$x7bfz zHKsEh%kMzv<#v}04Ysuk#krj&#}|}@n2Tk21ClTZ)s^R9llD!oC-2Q$AyfV>a%ygB zpZ}UZe4~Wcqkc+KM0{$1O3)cnp#`lnY`IYtNB@!pu#f|?gxQ}5`^>j4vI88px$V+q zOY;&MbZXX6X>8)+b}j}hCb%(?`tj*N^rRLi*r|5D6Wcqs^#XmU_&CuAe+)E<SZ1fW z;YO2;rOv<{hcVs~sY@Txq)ZPrQCbllO}+!<xT50J@8dQSiKCq1+{Nyi08uW_FptN5 zBiJ+K*oJLcL`z|PBu}?eShtE$1x<B$I^&m{TBK7@Q*6*Odef_K^TIcZ3+oj5d0=gd z%mnrBL5&zAsE8w-3*UbZo!yi!=bp>M0-=yZE)>O)T`}|cY34!5MJTol$|pw0`p_%E zTXizlxCg^*MFye<B=>Ivx00O|635l_(RMysIqXMw@nssci1nD4gPbX)%!)kh)ct7K z3}QCjKl-!3J(|LoT@JIs&N+L`&Q)5Y+<ZQ5!$0TdIN4=R)a7(jE4`7{9$eztj`Box zajHCnY91UxzNsV@%RcYv8H>bRfXW-zVz+UVM+)t!_Wi}hOo-icsHktHRErS8RQv}Z z{8XIN@$H+&dL_fEy1g=p=F01Wz(O08MaH4Et@(9q1;Re+U&m7ZW(#9DpwkqMI3x}; zMiWeB2D1M0t(E=q`w|AdWN|J7Y^Mz!g(0ncXEzHMN2R1Nd<F7Qc2QEQpu%=j<qfdA z1Q(7V;tk;_&}kIYg|FCn*q~!;KmA#}lujs65u779^#+VfNIs*p4tRQis5$EBxA(95 z3k~X`E@y5QAw8-O_r|waDg?t_*cDSlyzBn`IBWPzDe;vQTxMMN^8ADSISl5v^G`w& z5l~G}{E?w3eKNpHE2mxri1=bV#hX)g3H!oYh+rXXabyE)9&l(0FX*3{%0Se5ID3@K z2O*xPNN(!YUVO(0(TUg^{b6#$TT6u*9SP1h_n{g<tAK*y68OHzwydI~E~R0+MH9j& z%ho|I=ne3^Bl<d<es7Wbuyo<#cL-bmui?%gx(OS7+y45OU~>K6j7U)Q`Wq>HJ*8c! z=BXZl9uLO^dpN9mUoAzd3Z%TGvrb2kQ9eNpry+GiqfjGC--5vBBnMo=hEJA~tF5^+ z=2kv(dEP%B`JAIl3InowuM03}cN`duWVcgtvA&R?7)2ZW@8L~Cqwt#7UDb*TQ6S=K zBq|i~rzh`6Ew@A^9PmkRpk2wq>3;`bt1<viwzPN$RmYg}v78zi?1@+@W&krl%)hV{ zk8|d}wk+oCD&G{+x2=%g*3OvcU6+7S+C&`hxMvL<(`A;?mN-xD2MB2#N{09ur~|Z1 zn1ggEC`;M1NtecKNH^&b$|M@NtZ1vb?V=&3$(UuwLbqd*`AP@|Kf#kE6}fXQ7QdGu z^nWXR)X_TWzK=w}NUemc%QvG6Q<#<RfF$P<D`x1QMwl-Do%N)3bl7$_h$xmRvwUq} z07T(HLb>j4`a-Kgb6NmRHB8jEiA?q{*&03{m=wua3$D?n-dOx)IIpq$Xe!k@E(0g= z#7dkq*m>5%J3b7Bmv>=W+`nxM{um<z!3NP}f=XPtnkAHFxoo=NFbMW1z%>78mhnr# zzR+gBy#}^t;ch>pS}>wAYWaaYc7Y!H5#h@wea_pZH;_mMvGpm^*6AiAz+<uZL9B<S zrrtA)CyB45P*!IZ>kXu|qCFUIx6u=zJ2%m8h~s=k0n*a4_S1(Ct^RZ|-HhC4`%Puj zD=keDhHjC;x$id{!;hf&q`I%~?(DzEJBe*NRp6=lKFio4sE?Jb&(5+7%OT%ykDfZN zM_5fgh%%7Q-5SfLFv>=c7g!yMlZTx2GL;_~y~_L0ySG$mBGJVMzo{6To*bSDJNU=Q zBrl&PEy|UUoS?T#ar4JMESod^NaDIR3ng(<>IL&_31L64N{n+T7HCHm$vG}MfrmnN zxHEy@y}!Wx6*W2tW%9r{`kq&lY*PQH5?AQH_#xIn_rMyk(A(p1!G;*T^w|N#SPTT0 z{T{INI-1ESN<|bKD2VYE9$yXc{Aqpr)~ieM&!rQV-1KZ1Rb1JrJp(v5rg$0k4>8ph zr_k@6uq;Kaux)*;*?BrGJqw-I3vw6qQC(v<Q7hHzmr~-4z2Qx39_ULUTd)-a!J%#T zC}5bt^;mt!HLmnBN$WUfOw(GhLHE*vj$O8$&qj18>*z7tqDdc7hH>OV6O-G(5^8BX z&wE*|ulM()J3BMe+KG{5+kCB-JXF+D0&tc4bYT2cQGB)r((HuQqfb`SSWR@JzhIH^ z@K_~#OdYu0R7%I0;|6?wEO;zntxB|6FsZp})RyOqf(a(hE*Md<KfJ!9e&Gk}BTjO2 zcE+1>zKxuvl@X-w3yp-5I26BtD<)<C-u@Zt!%y_RUzii}kwphjqg@&0G57x<>Rr|n zZl?r2=N&3msnWtNP3VW~OyY_QyX=(iH6?3l&X>d{H6CcK;xd{PA}J04)@_L<<@ww~ zT!nv!E@=e|Wm~<=K~6v)ZL9g#nSmu&uW!fnKdw>)i637!LJIlYnd;+9yV6L}1y`!$ zoff^9SHQj5i1$3;l?F#0O4fS4RdsC6O!XG3WR`zQ;2Gu!Ro&W-=mX%PpWF^%DiL93 zhA3DO>ih?CrP0B^svh)s(*EMDX<neZ>|yl2;bh8!5FkfX6e$szySQyAZs6a2<eX%X z7;vw3M;5D!VVn}+!UgBRtVaDH5rtrnW}K{F(4!5)EJgYZ>(YTqncupZd153N=v0Uy zIcOmUqf{D6R-6^9zq{d%?dHjy^bXHL`o)s?V?UTIw9rY()5c+w>NcffAXmq2HwcRc zi7#3&0U_kr$~O5kTTil0lYMG5VKtj`lSwBhnp5!KI&Ht&$TqoRabB1$jFHKZ;T_Nv zX&4k95(_OPw|>oDfPOj;Gl@1j7EP~ZZX?z)*dZe@+Prb`%DNWiAs)%nBDMjA{Su4z z(E~`OPvqFptA1NfAi(uG)ba$PN?N5_yAJZS_%Y#!e~Qs4!n^{3QX-yc)|xY{bgy;; z5c@dWD{Ch%%5AUe4XDbiEnLLX26Vw@4Src<NyL40eDamS=;5{s{~S4#R4;Et0`F#P zYA7(wy3XLAyvekz_;4m0Y@I*kr}>P2>?#Uu8vy@e0v>P0nvcXrT~1iB$%1taG;YGH z16Mja9BSA^G!K(k?{`00o?>dOGc&j?2v%>BVcP-ph??Ph7Giq=G-Q+%UruIr{;L$1 zJt~j)@D&&ss~CZTT6wFvJ*cD!`=vG%m&{Ee(|vD_`(rhu0Wn45fM2Q==dM^*1k1zs zK~{GMYPIhMD?FH+xwj5`j`4XrLmhtz+e&iN&)OLy&&*~!TOTq=&UR6Ti2c;leAc(R zpu4R15@|i=Dd))mOkV)S@06;|7uVoDfz_i>B(9<10{z5!)ooFRw?}TZGw-thw6*ZQ zu2}zt${B3uZ5={bsBDZ;7w?Q!5zjcYOrn@%%_aTu^JtnR+mJ6bIjUkLX=pYIE@*9B z0kOqF<K9cc3~iceEovAWUE*)|az-)Vl}|?O0n0ITUpJYqrYi+l%0zxn5)~dwRxx{T zX&$pDTuHoRKq=^RaVLvPKTV4u8n^|wz&4&<$n5^*vD+FC&mY?@3c<OhpI54eS#X>p zt=7CxmPC+~#)k>qd32weu>3-_RkqrAn_J9`owCC1_Q-lcU)H>jxe&S^O4txo@7TOk zQ3azxZesdK4`TKl9tStXUik|YNHm|SlJf4HuTiIG6|UEr1Y%$QkS_m0VXguO>tZf= zonuqp)$Q{wP=uqe)g8uwYISzGyI7e9-kzg0@QlnXa{_2XkF=xFoaJ^44<iw)01kY& ztxtV}YGl=JrzflE_@_NxFefS}^e6%wubzl!8e0urCc<pA<R@XyhZ@vs_m6i?BJ!Fa z>afFxCpI7wm6;TA3u1h6MR4m#s!j}1ik)+UR$b!O5r~<=GDPqZo8sFU(C|mx1d(Ib zIN@Ze|3e;Pfd2@bEdtulNhE)fy7qO<YCo%Evdg9$I_2F#z^$?KvmBu!st0MKy6?a) zltQuMWZT|tSe^gTxlIyv54oC>z>vAK{8c8FAK!FTJ4yfuJ}I2zZS%|jq64qLBT+x$ z+own0XTzm!UheZ*hRRjELJ7dY0{CIS3A+FVl3!YWGEF1(@Q_Q^`IW)}x`}hHlZ-zc z=s=ZJW(UI`!+5Q(!vaq%GKePjE)F9Kwy^kA)uJ7RYcyZf+zWSj6=}#1P+cf5GXs*( zw3L(WA^^fEcW}jaR7!obmHAsDBglsnE>9Bo&<+39_o*PXmMg1o$*=8g82x-gtba5M z&DY<-uV6M_a%$}qyX+gpU5c=kty<$({q=cJxsCkWKCgpX=@`16r!89g)v~ZwtAPm( zy6^m1Ez1&asm}X%oIkI+y+tYIqyz3(xZ2_d65~j-5V^)u5Le*-!OIA!A^W7Ng%(_p z+oI&$M0^z%LZDw@1V=b9L#O7(fEPQB?q-=+DUfT9EsfQ>2l&i<1OE75#Z~~=AL9?* z#1oRbtPtD;bK$4hswTHC4lE>nwc!Ri)s31bK_baT%sbvbi)ZiR*eqMxRo$&6=b<XG zg0~Osz{p}4WinXDada@=QO~#UEgfdRFDs<t?!}P&Tmz3Ywc*&Ym}rvzNA{py2A{C} zL*!6uBS{>hV;<6a^C*21)<dO?o}c{J5nkzdRY3a4usK{>+46QaohBd3=>$qy_<x%| zzPjjaMA>gOVdHnaLQtFxnI6D0z-ppTB9|LW6sOalMCU-55vWY_!f~W+Y~OyE<=aBK z@$+NY*Ur7)$}Vethvw(?P&U7@p*yvm;n1|fwEiE&)4>>54vJlwHL}<%8^HdLHee{O zwvdtc$W|HKbmh69iK<j<9LnetzK6BDcrv$rZ-)xla>86{NJ`;iRpK@|Y?bzP@5cTl zEx^jkX8^|G!Y|0wX;n~k4&u77C>@kn2?;3>zb{_V<ZKY8-G}PZg<L0zT_Kne@}_G? z1qh~T0$%Wcc8kQllqh6<?xv|%&<Dy&B1;bHOIBs{Q0_!DIW_9y*8@7rU*U&wu|}4o zLlC+QsP1!gc?$K_kjfX)EPw5O;oWg63h8EHi9u0UPuj?ORyPGC(h8S3NA8xY2*e5h z!fnW}bu$=L;mZA4-IXh`8~0JyVHGk0Tl*(c5J`V|#EMZ;TvGm7F(Pbutpc61(DWw- zV`OA*;`m1z3ak4xI<7iZauHhN+27BfcZ|cvmqg}A#nkTfO=wFE9%uQhK(SXwaOp+U zaL(Ea*3Z`Ywp1y0sO4*)>>XQf=37*>mgmmy(T0soQ-HZWAN-W_-@;dZFPNnEWAn62 zK_Sork6o5o;{n)lUMiTi`}FJ(B_3QP5WQyA88&3oLBnJBTn+bMhMp^@QER!jma!QG zlCD7&w@_C@de2MmKuvFakQoUg^$b1&Ig!7XL}W?J=S~&dZpTQ|^~DM>ZxCut?!$8X zPL?EZX!>PRr6$VRG`IqW+X`^luI6$Yt?!?478;GwdHxkL2aImZoKw(j`CPNH`Yg+I zCjagmOHgnD&#KbWn4hX=?O&1a*$+AYRfur&RqrmN`M@|7dkVpQ1h5j`0UAYOhdQru z>C@RvCIx!(9nEeAN=+0TlPL9E;2oITbMK&NEK^jDhZ;?NbGnH@)SfFq59P9<NlSiE z*Y4RBS)RNwBnxNFoknK^`ASv0?nxl86%RhxurElCxhC>p!cV7k^5_NDz2<BR{Go3W z&(K7VylvnpdD5~o<76Uot$!*%g+So!em3ZTBLh#MDP0Nn-0ErUUc1*|pGyd03CtD5 zu`t=#6k4&FYpRn@eQ&$Ju1IPtb~6eixw;%~zUQ6B>GKbo*cq!=J2$(M<XO$)DAKAW zN$Dz>)L6+iJs=?DUnnCuTWW`13Y(Ts%OP5zKRG=|$=r2(?yRW@8iar_l^j6szAbSf zN>KU&xd<tJMHoK_WBP(su%15>!TE^b!mtvmRdZbdmXfn%l~t@SSyUdg*iAeus04J} zt>d~r;*(x*QUwolL)<g)h`I5`NRMk|m#4euZ|l_WpO9~c-iE&dZW+J+co*8TvvyK_ zNMg;CqC<@9n}GV*dL`Err5*KFs^n;zsW>UIJZ6YM3Sd&Z05x@Ui5gEv(RKke;b{4t z`mw)p<GIDjVjpc`S<g6h>4tX6m$^+p_vEf>zK(Eia?*apvp@j-KJn`Fpi17&(oP64 zErC)$^KRW76~}3!JVY}8wtlv8Ap33ElG?HdNTr3#>oiGXKZzDb+S<pLl=;pvB%D^W z%l(}N&EAqAM<ye=XJu>^+fY9Jpcdt3z0rFsbt1$~(mt0$qB=&)>d_k1O7RH5+i+*A zK)YiHZpg8c19VGt3=p7V?r>bx;3gqQ&s1{Q2;NZq(<WXY#A>1W9{(eft)%QJQVdu5 zr}BA>geno!7YfjQay!%>Cm@+HV8o{OVomspnN-E_E*{z?3dU<<0s+0X`w&_LJVT?Z zP~@v51?pe{@-9s9*g!+IzIjH7%d|arKrzB1H5S;{`QygyQXW73g(g9@S82p?n#&Un zs+=xsC^vgX+;Pxj7v?=>{kz=9fPz)d2;1v`)J&t}c7O%)wD0Ei<}6Rf$A<nVM}s;X zljT-JLNJJORwV8jZ2OHz4yl8m(mw-?`)QEB6HOSPaGH3xML0gt?Pb=r7iE@foL!(0 zmzy-oVi#bixSi9|3Bxf*ajEL-*jxxxNHCu!q<6X@MgCO0MGfeCK|8evN%RxB+5GlW z;Z{0`;DV2Rl+qd}DZnlJ#bP39UZ#;w(136?gIhN#DB7kkbusH>p}Nbx93((PBGb1- z31f;gT+?=B06IO}kDZ}Gl=AvvQ%UI)5h*Ydaa~xB8_(5_pWt;?7xLq6yfloGT`$~> zQK6YNQ3B6zo<7NK+2en<B4;&9(N~fp3wJMBUA%sgQ&WWJ)z1RJ8CzS%0}*#p_fH8& ze-IIUR_s7|?bs}Fw%{3~&HG8*%*Kji>WIC`ahP*YfXj$PA%c#vMH%Thsq_@dS3K(G zJTIu1c=vQZMR04O+Wszhov0ZZG2!Gdkl4NITdzW1!Co=<ap^kShuymF*MVkYOre_J zdvgQv8W{*Gcs`0M_J;R*(bzw4B}DWY3%y3F8{&H)sA_T!?PD7EhGONnf{F~;fKi>K zppkAJsz5WaN&%$CJ!%K{scxOD3E~RJuylZ9!mY)*2rZ%%KV_HT|3g>#zz9hUCzcl^ z;8=!okGJl34|u20>bkU^%oKLSDVrAYRkG183H%6a@`YefRUdZNcJ74<eO3B-mBP0C zo&-|x-|V`V_0buFEQ|JfSl`Cpfm*!C_fHgK(h3!`ee1RK5moMijq-a`dnnbEW0}vS zv|YGE|Ek@K)O1Jly=#h@b0s)qUGP+x{PE+0jSa<@u5}u><M&`jVi_To@zU9TgSm(G zo3!reiuu8i0WS5XjR)GaxFi094o<RSoNQlDwHDeH!f+6^Wjh&Vp(Jvd&@S`noR=zP z^WVYs1(<A=&n#ZLg=4~?CwieTIT?-UCJ##C^*olvC`1wmc7q1aIhR4A-UCrDgL_Tm zo85H+wx&e|CAq7SF(6QgqX7uT)^fN8aMRc(P-$u>VF3QTKdCl{p~Gw8k`0}`ln33X z?ikBcH~`WXihsjee&+tEWBk^@IvdQ_DC)d;O?9R_AN0H&o1vnB2RZ7CxtZTI4HWhf zV{<x*I9s%&!_66cGj+kS$(g+V9oqrt%6~q=oO~OCfa31ynw?~k>`~{uapAcf=?v87 z8$w8A!XM;KQLO>rTOig{J66W#qMd_-_Q}j}*SZiNjJ>CykA0aB$R9P?VIpX*m#mu$ zzduE^Yk+CL3i_q#l4D)xd}In)g~Y_Ip;%(Rdd73AE0qkhtK1$Rvg|Uc_h4ziG;r2B zN|-M?y>BW7L{i{{hP66>eROd|yjrq&1xIwC|LV@8er4nGJy~CV4U*r1`txZoigH0m z%Gtk30BnBn&c}vcT+o{cxrRUhE4CT!<m050C$AiP7^q7xKX;~aUlQqrj2d<@M*pIE ziJ;<B*BMzi$6}O`pIK1v!XedJBfRw7+wfnAz+?Sd8sNG1xvkbsN1Zr^Y!8>3hl(9* z=H)lE=33w2t+9<KEkMJ6n9q6`${5@Z4|jJ&Pvf7vxP;VxM<mNv!<t^R4&77f+0@(k zr2rI?&Z>)uNW=L>zDiT92NPcS(JFTM3T|}!h+!-@;Xnz7XN|dn|Go*BYmdk4OP7I! zOmEVm(JaR=mHnV=vBb@JN*kJ--^pNV@S9IWQJPZx^)tJ20$>poAmxA@&_N&?PFn5( z5mzn3R2{x*a-?*!<NEcx#`%T1g{j>sg5T+PK^MB}QY(CEn38TyQ6&OHDBNU-Cl0I0 zQ3}lmj?|hVGIYo0)iPp0(48d;T^fz%@Ae0$;!)2kUtn-M$cCW_#uwU(C{rI*ae#b$ zoXzKWH$wHp0W)<yMWS%NcbDT0!)x10A%Tsz(3CAjpH4QO&d1z8z>KYxosUsV!=$#T zpVdfT;~ie0pfxN1K4e31qe+>busQm`T*cZuUPEoXEEqbZ`Fa-=z!pwvQ2N}*0n5<l zg{Qnrb*;_u3=69o5$gF>R#?q$u7A;REXoGjc5G6)G<w$kTLJ?br#rcBiBz>uU(n}Y z=^g<OGhKa=HALXy%BJZSsa)5aeO$(ek}DK&YsdhidEdK!rMU@{H8M??bAHaty<UYf zoABx?SV=ko#st`G<4`Ff-Vnqn$#ZaZuOO>QWb`WO?~k(nj>Z?jjpIyMaNiY2Xh+o~ z@sQNxRvD>)_tM!Hpgla(*OE`!{0lk8OW740KpXd=Qw_$&P<eS5IkF$7Xm0-dQJ_RY zXWYZ-ALSsov4xAC1c#SzsNKr@9dxz$)O&?f)L^y2G9e`vkO|zfFdwn?KdkuPWhgy& zWEJ7HisgD0Dw@&O>2nr$fSL6;5rARni<1!7%Ftp~*Zc|HU(X^N*}8YFj|kZU_4Ig! z%H4`hdjDl$Z<yU+Hj6{d@b2)n?p3qlX@_zk){N%u0)fyms~Sh$a<WV28<MS{RFd}E z>}XSdrFFb5YUnlVSv_BxxlwGt;fcTkq7mqetneG(AiB#A$ZV4NU#tcPP~R;MXTjM4 z^NZla(xipS*%1WLYZGsrWe2;3ViV7HC^Q98e}0KqTAHgd5VBz1M%X)~Vq<r*2m2Sl zW9*iiX-7L(kF*Zi5l&bnxy*eiA3xVs8HJ%Lj6gDSL@<^n_qwy<`tdW>tsde%pWtGz zabHU;s*piTsi-pwpN<O|#Q;_w<5T<%8<ABjanQU<^pU;AYou3F(sSb=&=9m82+;lh zK5S^?flpTkcE(r|J;oJ-z956WM?(l2vBb*xbqE2Ht&vb9q7$mA*)VGKks^FVRq`hM zKI+}<**}3y0vD`|IPTRAbL%Gzsg&7g{sD1G?G80*lutaKeHV??yqJl0R6--H=GNR0 z#7ukSu=p(iz_u@yCe1ass#tQez1y_%b)P!gYtfETvm{tFZaQ%szJj75+PezyjL3P` zi(#O>hx#aW0pyT~0?_sTEPIQAnFZnSk=Us_rNFR)skH@9VC5Va3Fn3Zo!&Bd@J8zq zVe!4~k80Laf{%-5`Oh2B27Wj-!kb(q@@au$5Brl?Z}H57+>gmnP&0A3Q(nh>sjB9g zs}qb#sbfrCZ_z$pbVt2v?MK)LV19+0%31g2I#J4=Iy!MeJ~80)%NnBs<=g4N@n13R zA=WsW(v<dO!Vc(S?n^XB5<=2xIlkGux|(0jKJ}EPVzzh%s{Xu^fs>+NEUx&8MKIrn z1LY!=uKDDO)T+C<6uhQJc`xIz;ry`iR)UQEl?(+Kxh;P_<GwtN3q9wAq44tn^5oCv zrLEH)1>lgi9hSrYQlH<OZPh3(el*Ii(8t4;8`7vX%x{RDExnZL=Y`DSIL2mkA%4g} z6YwB<D5eZsN$!>L5zkA1-WrF8=K0<G@aBpmvIUk%Jmyi^2&M2XBJ6Pf2D5=rol&9% zc#6vzMua*ZrP-qD-u`6MJWf7}1sD+NIBcHg!7*DLDZja3KJ-bekDlr*eafvP)M{V6 z34NiuGm*3@J&#k0$3A>mVMQ+)B!4?zH^e@LXXGrvDDxIgYZp`Ie}~?#!Tbr3`H}$N zgGX49wx?P_2P?ES*0@-M+mFTj1)6N3q^jte>RNXe6>5m+{~4BEr<B?9*pt_JY9(Gu z8_d>j0=WVJFTCWAM{cd9!3%JMS}GKiqx4(XE2|>13*Jqm$S4GtCJm2ZPDrI=Q6a3D z@Nk~-(4<SNBM31eKTTXMACVJQYH>{ePp;O(pAYmdC}KcG|G`S0B60)??jIqr)7wNI zie3=k8*&{4xiY8~DImL;7!?}6Qv4tc)1)4(bN%!Cf&bcqtfvQe(M|G+AZ#dm&_g3( zpbs+?9#=oQpFLxHDS-o|Al$l}H))(1Pom5fj)Df{^qEoKJB5%)c{MCk><;&Q_nwj! zH6~dHV$q_eq0s%+j_!p1J)tHs7cugOJ_2g}VyDWxsdO+zU>em0WGI5b@H(inyi-Ba z*)y95`aKPYp{ysBIIF}nV=8_lY5n8#96kX7{)wj&@`yl`{O>;ydi1L)O>TubpkHeF zP9`fWbc!JsvSyM8p&W>_{F;;&VTzPW`4wK$Zr<Xrv<E#Pg_JZuw#o24k;Dz;)qG`~ zF_($*)yZ)|1?ZSA(X45hT~`x7-&dKan6;7e5A2aT>z-6lkk&EQ=iX#7m=!G5k6%z( z(h`Z26@c<3vV~I#PG7)gTor@PhB7kTsE;y6$Xpcs<-ilXKG&(MQ63#^t0|y=TgXld zq_O)KZX`22DhBs-6YR*TdLr|?g0W=H(y!-xMlXq?Fj$Z*`dSQ!cDtI5W_e$*zWdl7 z3VZ<=e4?0W<CL&!c+cV~7?Pe-CvTcp8Wj-ERgTHMv3-2Tl)-<E(ziv^Oo5mtR%{IT ze!rdim3oZ>t|EI(<6yw}o1D-R@ThDIgFo0gn?T8=$)Gg~+!8*8ViJ+QG)BjXtGR_a zs}(%YeyXZ&5*CK31V&teNZy&U*k@C$y99Tu1ZeOvKX)w12Vonw2c=yUHHg9Ur1n6k zW@);iVau)uec51O!zI@s)zN#sBeb1Fzhv>_(UPT5&2EF6Y%Hi(R)mtL&a42LZ*FOn zKW8*aS73=srF=*fs!2fhaIzEpL|5_x4hN6Q?_1SevSZnk?Erx%XG1oC34O|et{QR^ z97D#1xiu^=_;KE*j=}0tfj}?zYRoIFNe47?;Pdm|S-0w4(`+T@<wTh_5(1IO8NVoW z*ro~<<k?*M>a8AW8YW<o;|YdY5W+T)WUfe6d3e{%QNa*SEqZ2E>j1tOGJOTGif_9_ zPaH8M%y$fu?kYX(aQFOO73sA%+FH_hEgucJnF#{Fua`GZ)}`ZYWr1o-9*K^GKUZ6B z^ZMZf)<RmpqDoEKCS4r74M(<ty!VLb#lO_MM-gUiV04gHd&@l7NG%nun?kLd>Gmeh zpZ0AC(GoFB+4e{R_v@QdS7XF=I3C}uZp*~%mnfB>*HWIW0kc}0b(H=R1hu5Pzm55c z%X}^6Do&AWs^*{fH0C1JmUOZC%oYk|^k99liMr)+0lwa#RQyFQkz1a1RJ^uZ^XDGr zp1q(T-I;~~>Hi`q1{%xU<UNG&Yr=WjowR6@6q=u-n>l1+!nZg(?Z;)TS4k@m>2F>q zoGA!W$w9R{T0y>ctbp=xajq{vCcSZrXPCctLsJ1c4hd`1<e*jSa1D-E<>_|54drKE ziD_a-rQ`L3Ewp-f$$^&pGz}lX51iINMGO_-(t)KWJ?|*K`){Hu=<5QGdyxVe`9Pad zoGT5)wmUh~ZUpid)jhTZS_v_4-6Mn6C?jH)3!pp+?%0yJGU?kGdWB(_XV~B2oCmkw z+q8G=jgKh6!96(Pla#3_j0@c-GBb<M-T_7U)zE!)fKA<*RGV96u?Fyr@ZELC^LWJA z?1|#*n9?Gh3-{%pvHtA($p<I$D2XkA+217|c~at5Mf8W}Ec^vR1S&#`^+h*vop*hO zHc$(2<QQYG$wtSt;u%5q8jbK*WRKg=j!-bmQ8uMr*|$z<V5}Z`jU;{W(kd<Xr%Os& ziH?nm1gl0WkoR}Crd7XNP)+6Ta9&$;k1I3bqyq%xO?GqEmf%v+HfZLBL_ZI;Ah*uq zPSeugII`Ej&J5yY>$5hL5Py@-jtiW?fl0KA)hH{a;YZeufum3#XDQ6)<WMh#Mf8;d z9}DVxDgx@|($OTh@?U4{5htsL=6y8b!PeUE`s3NzCjYS)6^f3YuPH45M?{zDez49j z#MpbW2w?cQpxgH=52O6`z|Sg~E%3tb&ksuL8AW$0w$P`Yww@&7Z86AzL6gWvIrn%L zBEC#_B#rr7eGP+C@3c!gwSY!})5YKQ)hN-Zl{KU`eDOBz2Ij<bgd3!$;9{l$V@|cL z0gvP>o3ZuGM3&wvoLv0-SLaPhz++v07MyopeBimdPqF0X>Dyy#B?^DpI^WIXmCAsq zU`RFqL{wop;k0F?aaBe>C1PfB14fEy<t3Hm63GaQzc;zR?@Z`$GV^|``O5%zEz3KQ z#9wG-ep{BnkheYlz?r}MswoDS38IVp26si`m_Z@Xf}y}|QGJZBkTFUXW-W&VgvX}N zw6&6*o;Q~igDh}vb%3-IDMN+JR^y{>0_2N?<{40BhM09<R}F*^k{@SO%$$NW;U8aT zEP!ttFi5H`$TrlaF^ZTZ`As0mi3}!xt8Ic|Q22S5Ru*Ys^m69<DP=2&T+QLcjU}cA zY^a92l&Z2smU?MU(Jd%n`0IoVQkM+xms((qKL6e@9IX~;i!X_VX>ZXg?BSeKJpxi? zE}F#oHW_T@1AC5_qC0%hc0LJ3#yuPm$f!bPaScizWFqx-ppP*i(29(cN9f#bqu{LV zZ!X*{kv!Vb_u5F*<dRtWUwKxKNT7f+4N7k_NiV=IW8GV7Fnyz}42w3NZR~-rA0GF$ zKY`WM*XyZR&h{Ss7JGXm{cfzwLvOLw(t2@sUugqhT1__9X>`T5d*5Z8t}}`$p@x2B zs9)#wImo4k%|C8R(+`?^vjK-?pXwKvjXdSVo%4=8(_Q+3w7Wg$vEJc909a`fOx+<> zc)gv65^QtJ^jzY84F6d9cCaX?SKY$f{ZQD4J|DC-tQd?x^Wg+#uap~e^FaQ6!K$Pv z>T3+(H=sdPEacaUZvnXGl@$4o;lXE&RG||5%@i5lyO}AVJs&`^%0Sq;^dB-RhKRI0 zr&f=*S5xL4lWz8T6zfAttOf7ctkJ*emOjFJ{PyyM1m>?lwZ(lb*E4``evtQpaju#4 zi_`!SlXdlM71JI`&0!}`0!R6N5aEr5ogsc*jJFiD4ia7vWz~CMHr4tB>>wE)F*zSd zC~DEn_`M+~>*0WWQOOkzNOd>d@RR(RL9b(z^j^pP5z3ig@A@idQw4z7@Epw~SAvuC z9>3s<vfSaYAtY(AR@n>(gZS43-vxAuTNi4E_y(Hu+czNWGpmf07LbukEw8KAL?Wx6 zgW^+gvgR)Ur#GlTy$qu<2^;+|wu!@>Hv2n`h-2VA`n8Jaig?<1t6e^Y!x{c=v~6_5 zz_y-`I)+~O|A)_$&T19bn9}x&g;Hwzpf1}_O8?8tC%kbe#B7Rscf;vHzaI7iQoShR zQU5V95y8;~pO>sZe39?75Yl+^Qo&l82j-bPt1tJHz_E%>r=k(?DOS{*%5uJJr)r@x zDmCuQh^?r!`v+mIxN!DlOoJ0YpBIgJ`4VUdca(%1m*C0fT7%spMYu3}P@d19R43;^ z_T0(A)<P*V{b@P&8mS=5;FI9ujpTiI+n?JNn^{*zGt`!z?ellJGO;o#-r}!YkV*oq zVcxqgE>JcV_b?KPmL;@fVLJ69LXZ%Fn}0JkOinaAPu-f1(2q8}pF^=SzE5?O092H4 zx{2sy6{gl=bNup$)Mel*d}Ew`M9h#p<OsxBKg@v51Vfupo~&XU4qykTg1CPs9CYFx z7`+Gl3jO2=6!?seGu9Zc#fudz1)#_el=pi<E7AGVUcd)m20$moiZ~LjGE-f%>rm?x z!=)>Ofc<>%wX&+m4qg{)f*`{udY%HWw#o)PzEVOukN@^d-YZ>Oi5^1~7C$5e==JzS z|7qJs))9(yUYRS7e_be}R+6KZSt%B*1f+!D{)4-^y@rMTwyH<FU-86EUsIGCz^zmr z^g1-;3ZFpSE(^-(c?S>uVlT}J5YK`yoX)AwkaA%@5_=}><#27INV*kspBXmn;L&Br zxllv>wI?9QzBW#h)XxJ6GK<M&X>AcE9b-+P8zPp|plQ}CB&i)zdJG+yW4w>Efk$(e zp@-PLPXsngkhKs#PTnRUM4zQ1qO`~(p2DnBH7!2!04q-r8XL{a&$n-bP)<1&EgrGZ zoSZnUd?q#)0ex2U5AR|VBde$aQJa&1J_|~adr^$wx{YJAP>*OG=~O!wwwuh&X<`o6 z0kV$B7JgR}XB!z>;)^I*5FwcDO_6LY!PU%?Qe)WXH1oKvkvpv@Q}esjWsU>zUHB^$ z&p<Q8lfoer;*>_rgxwL>T6!Uw0`>@|UQ!YiD22<B(t*_w{(>!{NdXK|bkJltc5Pqm z=Sc&PeXe&ygR*4VLH-&C+YNz@|9O#6nFzLZztp_BfHHWY^~$2s&bOR>eCweZoFSsA zcph?xu!V3Gm0}`+YnfvMKM*j5QJ)I7sMQmKPnQa`j%nPZe?Z?p;QHnbjJKxLWb{Fd zr_s$CQ)F^xCYwn78zB$QA0mnKs9o(IytEJSp(odz_#qsodB?U4!5{UZXbT?i`VA60 zZKace7qwh(Mv(Td;M}L|ObBfG+HE+tsUm_K0dH@RIaqg%K{aKF2Hy+B4fGXxVy8?& zK~?!}$2mSWg*u*++9#X|qWfdFXQKlyJo_KAVtIgNAtk;G4i#L(3o*wO90LI@H?(k9 z15bPcCP7c|U&gigYY+cgrBO7D^nDCEKKc+;z)9S*keDTHGB|iK{zOCmbtkb|+6Ht0 zLXY;A{N+-G0{Bcqt-ybsz>dv44`#8jw5P%=vD)~}Vme6@;$==5vec)+cxp^l+LqOS zLtTq~*x9H;zRlp(0rhr%T~)S$FL#shRz!z9luL0coH_mhIa&en6!($%Z0fX0nHvRA zxjTzXrO?LZq27wu=9T;meGyz~Ps$frVht@Bpra|)D(xSDBYBi{uXW2d3RQr5r~3-= z0v1vH3nwo@oOfxvC_LiFoGhJ~8ZW^sR<@esPQvj!+QPdr7w~Mp6ylQ4C<=GxkF2#3 zA+U4#(+y&O>h^Ix@l{z?&az+Ptr|8v2wRk|MrJ+XX4IJ8n4;AHcJ(4y-tEE^5jgj( z4x<Y|-Fxao9s3RdBH&E~fLg8oqUb0BwYDMM`4}YY1KQu7$WKQxbth5ML-nJTFLn~q zMih!RANOIbKFX#}DD(PY-kH|KOJz_|o-h>c4h$M9k9Q^-y=EthlyDzjiR3eSl4%^N zEq>llfGCfY+8E-VvBLD!a@b-If@kFwU)oYjx>({PB{bQ8WJM}<ZM{Js`!ch^_|9Th zAw@yUrXI63m7CNz#7x~vSzI4fHNXacsNZz<N8yoNPyVNNGIy}4C6>#Ugi`C${&*Eo z`PgT!fZT&rBcLk%SUf5%;bx`|Xd~(5Yk;1&?cdr+6XpbPE)(vzk}f00L6@C=!>6Vw zfoK^B&1(L`rFV>BbrM#7)D~A6drz&?JuLA4KN8#GkfT`%&BRxM5L!JM!DuYg_oYJh zo`DY5-*Uz%?s(??yKj9BE}N#NNA=g$Kov)retYQh{gBrZ7x`ol8gY5DFA;gopvquO zFAv*H+(hq%DFl|KtffD8e?H~{v~u)Q!6p8WC4SSa?u*Sxg8TYElUMQEw}D$Mmp9zn zG6;UhDC&V%PnY1#!>eT}s|8*$clnDvlgS}g8&=tL_5?WFxf`LxrkQym5&k#MtWUA} z+sP!{t1s6&Y44Ubx0f&zbJ!1Py2MP7ZM~`h8P6=9dapo18yf>ZgRpoIhx1%}{^n_0 zmxbfbSlc+lfzx>{6ZNgPqT#>F@4{`Z@sp8bF_vux*F@x!&G<vI-xn<;f|yGv+n}zQ z+gi!!(tj#Obq$sKVITWB&ZB*!(;dOsoBsE_kEpCz>t0<P+LbarU&C9oFuENC(v&5i zRqf|8uHFrzgdcdf8b7UG8H5jT1-?1w=rMZ#re0$k1j__PX=;YJ$`d=#dFL_aT>ZzL zsRH_{6m~XM{SaJEp(;CKoO+mbd9y#aXpMkbB1xs|>ZqJqkeDX&+I&@w2Fv5XNh{Nb zj0W2hp(iuhI<8T~74QQNk0Bi3lI)=%>rC%6WjoVA+ARjkh^KCM*~)W)kscOdrblqZ z8s({6)(dCj^d*<Nw%T)DnN?U5Ul|r;a7G{kHc%>6&bovV^#|sdrV9G-2arWPVK8SO zbnG{6B;x6$q<vucs-Hx;^UDyg3EeF|@P+t1BV#S8wU7HBL{wUi!K32;^5!a7wb*`I zG)JP7SoQv*wl1*$Q4k{4(f?rGQ)pF!Z_AknA?u?T8;Orn`Xb;S7mEezKBy)Bd!lO_ z^=^O6UUA5Cu(1b+x$NCUR3%Eia3C@a9r;A|rh@Q6ygx-`kb|5cr<x`#>y0gCLWK04 zr!Udxt_3S`UGd>8P^T-!jUs)F^aJ!e6^rHle2Wpa6ze9>nF2+glTXKSa<^GeT@}pX zM339bR@TF6ijf<jM?OQ#$oB(f6lC|K!3M9NQ=ZZ+P}cNxXi(fm6fgnDw?576C%@#6 zko0^qC&%5@=U1g@v7!CF#?9@6618n?>saN2Y(H^1U{hvOW+b}k{iRRM1{PxaQok&} z)?~wCeaw{*yUop%yRJ^Td<icKUc4sMbY6gbHz~$mx}vG`f81G!c2A{aW18^*zvLfJ zU$0gN@Q)S*3?v_r^!Wn)py5gWuw%kax{ja98`xPqorgd#?vSC#9pzuAP1i>;XQ0jl zm(Fae26}A&7HP<r-0aP)f@XmY9hf5(ko7(Fk-NICd4!=BYSQ!WM$}kiTj%Z1UOiWK z(IE{AUc~z|gO3$LB1?_;4_<3RkWxgbvaF=dKD$IQSxb;(b$vH3z6x~UEh*ZPAZuqP z{zx6>l%+r%Rm0V6N0hdJiEPuv{Wd${QD~f$Dt_N&%xtd^28;V0CgRQC+P8!GoBV#i zJF!VlrPem;ni^Hbb`0YAiv1PaGgcCua!JM0n*6|`j5MC!J*$wGm~=2q^HCJqNs`ny z&Z8}Bb+;M|?`v{l{_?86=Hh_;{`B}+c8Uyd;hRF8xQ3>`f@ak=xJ`a25Z94At9qQv zF3bpG{&CCb%q>(TqxuH?MC^|NpC;?;J<`*!3O|`n3Ma~yI9y1vf|bJaXQrZbDK)@c zegAU?XkqC0^TEY^oSwxB94O=G9n5%S4(4j4!?Mv5InDWgU!jFHV8n>V{iXt|_hqJ~ z5vEF|+h|ihQ=BW(fbt3PnkH*1;SwV)Z?Fc@cVVs!3a?$Pp{wFr?cuCr^^eF1e0%Lg zUCT8fH#Aw%jn~A8Ad5twQIY#^380dp$qcW491qQ(<ZmP<=URX(4V~Fg6z(hly=uo? zG*aW0K~l@0`@~H+Sr~T5f|?zEupz`#`4oK2d*&WhP3`5o{=0_wr@3p6dJ~m8Uuime zM1yuUyE$BUEqKr>+xBy&sx+9VCb>qQ=88xM2f<=y`(-xW7}olb4nZQh+g^I<H9IUR zja=VVW8Suc7qsR6K`v%@QWO0}aYjl_i{B8m-6!ITgJpQ!zm|6#?QS*<*f+9jThPPD ze4+M@e${~^k!16J{SjQ;obf72a|UTUPwKC{CCY!D(+C4mG>K;~%m{5ge*$7A^#@&X zDpr0Ov$O426*=Dm*L(D@aif4>;w3wr;Gu<y)boTNDF+C%lR0YE*q%3>@c>yM1bZuI z5^v$ITVG_qFgL*S@a0n94i>4ci07Q5KlD6$=p6Ht#fEbJcEhL2UI}ZuVJFKtiw+}x z?F(<!lZP7na4i+`Is(X^8mqJJw<7Kr&hi&7Opn&95+`98L>U^!n7;oZu)Fd`8JQHi zhrf6?9n;`1H_?(CTCdzYK=S{CE)HYV)5#FB9-I2InlS2LC3osVaHFl@;ZLLClt$bH z&UGpBmZP$9y@!^29|TCGfT_=!8qIo#${GH4=<{Y9HudF@tnuT=mB+;@5}OH;fGZfl zs}^2TQVfTe2QOeAF9L-2PV;7AsAyjgK*n~q(>xRNijF|G={B8_qn!H8z=}L?mJ^Kz zZaCkom;o3(Xe;ykSe&esLRCz+u^h^;r;A6gOMJFe=Z@-=ji0S(p?s-5<W#NwP=2%C zAzXR~a;%%VO>_0D!~Kglzdz9=@x?&cjp3EX^X3U~;!s*s1Jo_lh0d^!I)11U%5VLU zi$W2)+#@O>$!N*pFyP#WFDHL!WqLIb(8Z7y$13;Tqc;z)dE(1Zu$XF6SG5E?hh=1O zis)U3QS-C!WeB8#?dN-Zhp&s%!<H~3+hRdrKQxkvQFF<0&0?&bhc{skvMCYc|ALWx zAUF<tfJuW^Helwc<>GU+<~cg#(x`8)+?Fq#yA3YT{TbUoB9zAZ%jF#32L8!ZSs&AH zJ;)mqs_m~Jvg*?2u5y+TMb90&g-iYnQXm}HP*i@S#7Km%oL>dHZN1I>pwsyvU_(c- zohuBsyhh2Ud$rg%Ri(a$v#}V;0%K0`lSfy0gwi)4bX!rI(9?SXRe{$So+U`Yr7`i% zeO$p8!xmdC$tEX?lA3Ig<hxM7aWlIy-Mj7YmQ9S>Bs#7|&1c5#T|CYO@=KOd1G4;E zbZU4&;dpaAs4buGbMH_^abjfp@k-k{dq2AC!#(fm2&$UVLJW2#JF!G-)CW>;T9KqX z3DG%Az6k)RFMGn{X^Uq=<oNRTJ^A@@K*}}JmXj06c)kgF>r9pe7D}2HLU+QX^{`SL zc~^IHlCWQvJH#oiP=ro*%BKdwVgjXgzr$lZ`OD3o<0Wo+F6<I@QTxIjksuay(CAzb z{wsCPAG8^(?k{C+F0flt2rr!^{;#JEA!59(ck9xYw)lHqj9>1E_VQ1W4k@r=K~~@{ z!6*CVC{iQpeM+ftF7_!#oCUt&&t5+(x7{#<svU1=p1-uUgt`hF;eWEjP8QmJ4vv7` zfrDIm6ei%HIc9@YA9m$igh?7L&2;>r7xMR59*wsZ)d33eP}bVyoz3BcN>)+%Izgoq zv@c`5+Yg`6^RNDnpZ8MReze}p4F6Jhpf~oP$4sT9<6u&X1ztqO*IUd>8K1ul>Mn32 zlY_bKmB`;LrJ8NKBb?ORm-;0u%5!PmODs8OvL7nTLTroB+1$OybjkITBKJ>ska*IM zG$wY%#_=mb;reTe{bFF{B-Cvgt`sZaBP`3#27^JraKEnuB_#4DNE1@5PVsUxqjJ9M zYd$IM1_Ezy>jH8LNwP<4#I!=yZX|V|*M)|i-uc0u#{OxAI19O&yod&Y-<h{hBo_H2 zvQ6XX%+rlOYQzh7cMyjAJP*sx8kZ>-i-<G*OsnfaVqZA1OJZpxOj-Q_#mca}`&#_K z--cZO{oZF#=&PZpzpn!J$^CPDl@XH<kP_`spy6&W!0a!Sm9^}y`)js%>V4vl%JtGF zTm;{BcDZlQwIO~|5VU>@TIx27m44j!s*-oT-%>lDv881m&>#;)<qjncpg8ZwF2`)( z@f|0`c3aw{yVFy2DEX%<EXv~)0&4Y@&gQ>vZ*f+3%F+fD`W{w@v>D^#ox8Fszr=)) zxYQFZ$J5-Etdjy_lLI1j4auF*$|wwdqOp4ij4j3=8#6G$6PFg9!(`{_Cg~-Qi>am= z<e9^;n$kBH9hEZ-X^ukTRFyy;pb1yN1pT#VT9$;a_WRe`-3NrOCF-m{oy5LT5-&k- zI<+hxiA5g{B723BCK;-w4|ij)Kbu!&RJqe>(mnsnha(KstcsLqZ65mJS`+u7Kk62R zXRSd^h^C@UB&6NKFQ#53n6Llf;+kx+yk<c1@$Ko!*?gdi(OKCK_hB`s9iWwGJaWlM z$)lBNIu^1~A|%bmoYuM~`fH5<O3ZbOLTjvNZAD3`-q=)Gy020fnEw-n{Ll|38Z=Ef z0Obs>%yowqM(^s7bcIX~#wVg%NUhe82g{fxpw+SDa8OKYbxBjd6C+8T?W0s-3&c-$ z00fz#nx@u<?OB`SpA6e0FkyP1G}sKwplLDO0%qwsN_6_sJVySvsIkkDC3%?k<p$~~ zqdE=m3mu%uL~GqHoSyPPpi1k4V%1o<HuaHDitdLL#rk`lfJn;C>km(^FO+*r$9A%J zG$O{Z7gmLU3c$=7229rDdu;QR3Cc0TYK*CRym_o88BT9>ryqZW8(^NPutIqhvjJWT z(j4bwgvu%L3yG-43eQ6u|Nm5AFf5*SeZ8#l^#qJvA_9?$K;|XnB!i(Opvd-g7GTsv zV9Z`asS*m%M6Ds2wPXZFtthnVw{w?7NHvq+cw4vd8mkG~Gx1@+s`4_Op^zem^VZ@U znxb?V3e`f)pNz{+Ki*wo9>rbfd3I1$dOGV>MgLx4UJPd}Q5XeW+4uI4y>l~|y&P2X zwTrtelV`e&>YLjy{>XH8Fn;)RU5)g1O-S&VU%Iljx<6L=K^5!cIkJWmjn-RF*0maj z$l3A`j~*sTjs*iX0DX@N8lfiY=fC}9hN>STkIX1OC5Bjn;yq43aW}IdYEKl<F_LNt zz{l>@`yxq7&n3|W(<Q2-Y~&*$sV(l>;ZJ1}r>JD26!zd7Tt9*h=+}>}05#(pP3W5V z>mxi<;cbxMzeJ}YC&{=~d*BIJl(9LhXyI6bTYO{+dl;B<VtN2V%}r+bSxs#wUwVZ^ z2`BNajQ4Fj>7j0<t&O`Q`sn^*m|LV#`ExckPm9#+jl#dgP+yie?-~Jw@gt4Q@y<E% zl?T!Du&qHHN54&yo=`HD)#C$EQ)ylc`u;zq9RX&(Y7RoI%V32N@$ANDq<2^svElao zK<qL;ebprwTOgP40bYf=_3V8`v8T#%$$+yuO6NOvHLpPc62LuvUg0am@Ge3&WLT5D zO4(eVp*X&B1s*|lqiEA6vG>7=4o&-#0X<^!L9)@;G47_|e_OtywyPi5Iq?*r1cCia zSD+34dUpp_JYv?p8H5%*()*RzuGTwn6a#2lQUY<lXpQt?$}!>BLe_5RN67jQaN&90 zV~EK?<D%#|OUR|}GNZVL3*TiQY+WT`1b}Z!`EV;!pB*}H#z<RESIiJ8>cH}PNiIC5 z>dH&w-6SZE+aeo%40UFR3H1;?`mWHSki$<sr+AP46Gx{pu^SJE2k{}-AAWHxEkN4> z_GEf%9|g$}3DEq}O0hO{)Y|J~Np#W(GNd|F3r@@Qd&k4r99hdb*NB5E5)OLuwf`{< z^}2oHgZ2u|CYL5Z6wE5q&8uKyt8US|X(1^$DJpN_I#l8hIJFCORCL*MFq}|JjY|*e zD}yKD%dk5LHTd>=SN{W!%Kyt4gT6vc11wSe+p6Wc74hOh&Cs5<aVvMW&$Htwx)J6i zU`ngF)H(~Si(2$+T6=Yzp-Ws%7d>@or1VGtRLsE#6}^9q-CT&PoeSQdJT59c226BA zE*yqo@S&v-+cs2OPyUvX@k+c)N)@DyvG?iuy!|>?x923d-FMj7A_Lg}gmt;@THum< z{lBO0#+6Po?T$AEWW7V2_+sD*(lCXtgokK2J-w>F^MumS)@B%dktppFt;C2rX5)h- zpUP!|g)R4(`j)IqI^%uy@Z&h<o9r{r{~RpdhVmsto-SD%R{<iVGFr0Y&oE&^+l1Oh zcAiByJaIQD(*%5H5IWEQef@~A8HSw0DHC|ykRiuwzN_P5AY>;+>OvNS0jo{iGg^<k zcuVjmA)5dNBB)~b+(1lPXC4lhNTCs+*6T3WHaZubkdJtz>L?$_^~CX^ff&;AS5sUV zFGzla09h)##Yfm!n{inKrq6G0MoEgn#CorQ6=5#X<ezNlkt{1*Ooz&jyo)?N<ZReN zng6_4<pbe-I$va7Y_aHES1jn_=0ZiEYF>~-bbde<)*HCL=#<n_?j<xo{U@J8Z#BPL zLFigZLezH*kv^2mM18(Km&<}g<TD$(4pn!oFc2`#7KXLEs4NLV(Wx_f{%UN8cE51; z&3Dh+*OHpsF%f|fo(WL^Sj=|KmEDmnMqK#_2c*Ejj-dNSmD$MWY|3t4kYJbA{N54q zB$`{L;FZ%J=&L-BL^mLhVmid~IQnN&KF>POv?8>|{h^-6M{J|%vq6;{jzS@ENZn6- z_5}Tt!q#0WhrDEzx_}Y)+a5xfMY&t13|HFRKkx~1XX~+w1Cy!Uw^GMnh9F<axq-+D z8j7z?S!%~*a$-2sC>BPmMJ6BJ6T%}cb(nxh!)LPwhN7(O)90wk&vJC3WtKmzRQCNx z!*#O5vr25Z7tv8mit(<zj&^r{7Mt4xk)-<k6pd3M9a)vwR-^yk9nOZK^E6JL)FQxF z4~)F#sxzd{c94WYP#uiO`WpzzRs&gW71gC$#j3UYSXh(gKl4{7SG^Eu$BzR|Cf!;~ z`(=wIgr$B|<<2H!>M#{yzjJonPBtq)zttCm%h*!8`c_v%dw&74-S42Di$5V`{=wK0 zN;}8$T6_0{TxWRuro=tTv$2Iio2zOg6OQSxgSF8^bGBlMg!!>fT3V4utb<&3J#8>e z_W{W;HqWG>IXkcI>peV%X`sdBnT}9GY?*e4`nwxmQVE3m-61$WTT?dJHIB&?AF-mE zSO$*`(Lmfr_I=VZ6W=f&QdgDbV7#a~EqMc5A}sTLgaLQ^bk1iYSvo;QpR?;)d;Z9$ zTyW0LAH2vB2U#otLJrGj^()gu<Bf(u@(O~%o)!B7RddncOg7NbG%TUQAKYIcXns2# z=1*i?wZE3Y5y)-o#D53?5Hs0uM^qIWHGO}uiIoUcs&Ay2Fq}!8Em;PT6`*wH5E7Uf zgx2RwAcO2ub(yS<tt0rI;MA9lIidt`ECJ&$Gpuj!DWChC3lrY?ZyhgO0EKDb_Y2@p zNL2(My4MR|5WS6%>G@B)OOe?$jcy))8>gkW?HU8SO88as{7TYclZFC(l#U%ga0%5& zZ5n-F4Nu=QZ9X9A4J)K9DLh}&y9=k?hy&p|65~~D)mmLTvpl3qpm@xH6^h@ky}|J1 zcy+|bFV^%bT41YcdWNt9ML$pFXyJJ}-==^+z4ce-6k*04>sp3EHEq3kp48?+l>E`j z*-GXSQ1q;lv}h;8s8$S{HVv{GYy@y}FEE2P`OV>OF~>Uqhji@>&>r!Z-2VFzI*!bY zybc~zr=wB!du&Zd(c4`N(pTb!1RL12hCPa~hDZ8*ZE=~Pl-@EPn^+`5!`j5)EIUAU zyddE>m=EB<7HOX9lj&*9=HR`zyQEF}kVZbiD+h=gMHon=>XKbNHD2eRtO7^%+EaG` zNhr-d=;3-3l>dSH0;U}{FK$13Z7n>e!o4WG$+@yp%0ny7v;cNY2~V?7aiy5+0`fOI ztX(mH={<{xzUg<tH)FY3sm6vq>lfvkWVY|Fq!pw(h}IbXty^!G0cWta#(G_U!^$P< zT(0go)NvG0nqU*`SbYyo9Z%co!eh{Nao=B7D-oi;0jO<+yYY{5kPGT<r@0mEe#LPZ zMZ72sEV$^>TA*`U7h)ZK(f&Qt1C%!NRSurW#6}3P)pm|hAT#J?E875G@}{puCWZ`p z?mnrRU>hLd1Y_?(FOBw7m%}t!RiU$X<9l+`xF*QXBxbH~tEhPl+=!3L9jVDLIF&0T z5bsl`i-_pJ_WTZDfwBOh9YA3aS?MKkFR-bR=~iC|h(+<6oQ{rfs!H3|Q^4afawW=D ze4}**+OpqIyiuo`I<%m-HhnX6ya)hd?xN~QwgnZmrNd!i!|4Sw(k;84Uo)*TPzysQ zj^VC?R&L}H>8Ey|JK5}<9@b^ZADG?uYq8%}(*fEjFHycBibhN-BCjLFNF^<=`FtVA zHu&zWMi)|4LN0B3o?DH7B>yS<;WbfAW+mWuTfSS`IAFmPCoHFp3o&xYF)djc3~17) z{XQQO+!G~?Z^{V0^Vtvv1(T1wNgO=sP$gA}vuk94V|2TVAi+R`J9{}F){Xn<l+ABx z>*l{TP^z3}%MUtoxAIZo2w(HyB&^ncMB*YL!=B(YCv}SpZ-<<ov1Ff3K9Bg3T7CDS zlN;}{R_j5asSU6R$Lw|dxQ%B@Kyv%Z4ohG2FkiOto~~it!acO)6Q*%3)|@CjZ&opz zcjS}PC&(3PQNV2~5xN|6G9~wH7-|8E+3=ae`*(OGcB28PpJO}pU35SkUJgMs|B3dx z_h&ic=n(=z;w#7Wn1>U=EmcDEVVIc$dq<ubCt?-7Q9*r*^P?2PxLc0){uYDMNDfb= zyXqs@Y4Y)ZnvAlZFr*J9iv7Vg3M4t!dw<ZKUJo0^3Y;5qFr({aV2+eYXu_^b;{c}p z)TukcfhW&iiA*P}Ad)Uy88Hbzvd&;Db)mNocWZidh7>PT%DYA(>2>V2tEEE=7eP&L z2-1+4WNZQl0E^T2TO6?1i%d+^Z9o#`9K-uIY{qxaa4Y9QaQbZ#JHPkiUntnJJd~QF zW*o%bZB0Wg<+>OkM%rfEFmyLc9_jmr2F3Q0JgA*nM-HK@0t(~^EPAF?Vs;>+A()+G zg9GAq$RjSnV&GQto$%Rc=5ZjxMeb`CCwRJ3_?BsRXEHR=M)1TNz-GyLPrR#FHw$CD zg%yO6c@ye{NcyCnLlJGc17;GZ15p~z+_Dq-h{QY0Qx1#+1x#Gf&rV!E_C#E*%ORVT zHTO_fu7$!4E-q-MaufJf4t>AK?x#a!PdOg1ri8Me!YVMBp)B_{2xgW#MPX4U-H`bK zXxbxpaO4#cU#zv@pH{(aRa|Q2b%gt<@Mg}f$^+!T?O^O8KrK|VNc->_&~nIxzS{Q# zAG5Qg#MvHBhL~gcRQ=3nkv4^YwnV?{)-*;#?3Yt9;%Uo^SQO^T+8}6mW3$4EW&py1 zN_~gc?H&Gt(&t(M+igUdv==P_Mzu2EW%a1S$jc2;b}||j!hUuYG+OV9d`ioks4D<1 z1(obp%X>>o)slHII>_3v*!?Pn1BfBD+dPd5r-#0z!;MrT(kMyJ@NrQ31GKipZ{e$C zL7Lr;pEMazh;rza+Q3=@WULaCNSczA7(MZwV3LPonHRz(>LAP;o;7GV;kV%i?T+Py z6dDs3t!tlJ1m_##sZC1@-=)$b_d6ZzYb;%hzIxB^b+BG>iqr;Md?H1?8ct}kVIFY6 zvqS%GW0_gk;e6mc%>4=mimy6Y%Lr1P6QW;=XQZtKFRsef$R5Mw=17umH{`pj4$WVC ze(UBFoy1dtlld`gE&z?!$f=CCXt~da5S}R+@2N|Ny0~hpGk;L`Pd(p~TP~(WK{Bvu z_SeSF<e8bwm|r>+!G_K7w2}I2i7@+P+E4$)V4R5;3G#oAN2^7}AhCt^2hvcG!swTl z{%p#!JGkCR$Bab$%GJ2eIETaF+H(3vyTtgH;gA#!TkSy7iz(>Ql)pgvPTED_+3O~S z2{c!lv{I->u8I<8-W7t}8kEepeekp^>DiYgF*(gGpdVJ~d8dBS>mNTChdD?ZulG;{ zd71m-Rn>@C;iB-O9gPCVBqN^|0NQ_{;JwHwJVK+22(ycg<iDgJhGhldm<|YO6(>|o zGi9IU>>D4nh-B{zf;ogwP$4>?bcX8K571Q-2I0K$UT%qUUE0S;#nA#Oq#qX(ro2E5 z69-_S+<U@(M0DB0w^20ued>Kc2uxU!a~3<%XVLm7serkdI!v2Ar3{^fKz;!&@qeIk z)LqANhF4o^&{0=Cx^R*GyS~)Z1I?rmM%C-MDU9@)bWLenkqKmMN57m}2R+;O>1A;* zi#@(1!K@U&P!^U@*nfU`WSGK2c$kv~Gr%Fm_M%twU^2Ky7!Zwoz?_-Tp5tqz$f7Xb zzG_1YE=Ug@qy%*8Fx%`YZ7tHA7kO4xql(s>BgUiGD0D#^Y?fwHC-fU-vk_;%)x9OP zRg4A7B7BTrLeCWC)1HTgaao#PjnK7DL=j&~3U|Bo3+x*d|BS^PjihmlZAua2pW80A z))2u`$9t|x>@=#2+|na8H(4N@1J__J7`(Tx=Gq)vz4vrYYREv3EJR17hKF*J;}l^# z?YM`wR%yKqLL$zdq!8Uf9@S-@I}{<M$Y6(g*SapolB}4l;557Gtw5(r*`9~^ql^Md zlhTlDDhllSH`74l&2!NLE(xmJK;G>q{jf<nu5Qk4^`|Pe6jz~qeYXP^If&i<pT(68 z&o3<$cU8q6Yc4sbZtUo=ozKO2ES86K^orAEFw6ZJTiAAbWYbcRtA&I@93uIYy*+KG zgeUZJa_ea;084Tt#Mk$1+V7RzFuG@s7PzmE?l$Zzl@d4$th~{UH6pK#hKidayY<HG zojy{6!3r6Om!8Mg62=Phs<@IuRL>Slfg?jy^kwTwlEC6#hF(H<9WJ!i#ev)DL$%G6 z3m@he^g}0r|LQ=q(R=DJuznr+_C3wlC)>{g8*4P3T6D#7x0dNI<Sf}Q;y_q%d33@9 zXbB`BFMP!(R1nnvrAgq-^8HNQ2kr91wX!ugntw1v;1bpDN@z2$@SQ9fC>qwAT3&3K zH|Z=UcnL&Rnn<Y})xqm@S8XiN-j(j*BwXPR{yK)(5!Q+27@|+|7$7Bm7>|8QjJEBY znthX|9~9i!GmSk+0tNj1Dr`f>hL>azj2gFU?MuNt=MRYy643Nw5Ws07!-l$}ZSti^ zfQ@h|{nxkUk9(fZ%WvA@7>;20j2Sk%tO|Hg;K?a!*8cvZp}Nx9Ou)X%8TdUnxEsn& zp_=!DP;dk-rHBz)p-)5nmJ735ymZVADRXvMa)8H{ep4`jP8nybIn^L+M|zw>wHV1? z?IUg+Zr*mikRcVmva*gHDj4u9y8Q=SD_nX!5)r*AO*dj>Ikj4lNEr??VA7_CFnv)q za_?YT9M;6x%<iS!tCk~01!dVJfwznIcoFWAOrU~}#F%pPk=7%nlvSr`j#H8Pl~}55 zF#BKLQ(XyVcsyRoBAW$Qc{5M4q>V%VmCP}Dvn;X4IL0~gvIA?hivvvaH#$z=g><$O zkku!I&y8CX1eZn#$$I<!n2v2>^@MplD#w51KYkr8r6q@;UB3YBN3l}@yfeK@7y3%< z4II++K#kE$mUC_8lo)Zv%GMfMbZuG>s#RMi8WHVyVPrN-{pzk>wu*v1$85<_zFe%0 zC%HJnTd!eVI)!t7a%Y!6Ps~a!2EIETKiv}$BP+5WAi$CP8|3rI`k&Cs<gXM_Ak2{Y zX{*R!2aBQxY7%^cWd_fRE(r63x;zsInp!8}M$CHuI=U&4qIB%`PG8oPdM*Lvsrw2v zrB}=p8zW>BAH%P{gd3()*kw{ZUqK0Nzsnot`cVLn-)_7dD#MN>j9lCdxo8EkBD(T& zBPxx|rv#NITMN)=J%k7zKn56(FcQBXh|Jpe&cjf-P}@LRFDS#{BJx$pY<gIB^kX#{ zh;W6<t#{aUk{{)6pOgMiX0q>a=L+iwzmyPyQ+^0?gEq=ron#bYlE|r+m4*BhcE+(7 zKl*#E<mOyU$5An<aGZeoowO;ke%{Y&RLhP-v*p6o5F*&J;)#&ttlLv<cc|tHa2WCe zO=u3Ux9wfcWAX;<c52F<43ueD2$rA(Xtkp#_#Zw&o{3W~7=O3TfK<#Zr?MJV7Z))> z8O{KPTBKM%Ll|2dpTS`nr{Df?Ta%<)eQQ<P8q^uGrxJ{(_^hsGj}3)VXz`&0Il{Mh zBC>f3Jhm23<o^n+5z#?3wO=p3PPVz2QPMi7tf)I>4*3Yo>T7Vh$styflJm&Aovqs^ z@zpfq?>^>xeDg9)%mDP%5A86Gj^aAGxl?g%j4tK^5*N(Ck)9bP43p6$9c(2GxL_zr z&6~lmwXd0Qb357>578<wMQ<&alhzLto~X3v#P|602-ZMKxKlO5dI9(+(P+J0yp78< zV+w<v+EnQVf#~m%=#a!(8gT8Coq^zeuLqwew)J(5WK5&DCCGJwAFC)Rb?xwQ18~M^ z9$+D#;^<bDjRf*UeENNx?Ka5*6qUajTKiVU!DUwx?+Y*kyxv!ePmkWc?5E`S=f7_Z zZcazfvD1X#Rb<9qXa%Vgt0rd5Qyxdw+R;SZ>Mq5XI=Lr46yAIMDg_Tf8XZg;_}9-y z2x!w8*b%DanB0EX-k<I`l~8r<L)lo2QJT|PWwZrcddUs<TLP`DKK(I2fC`Aj{2Pq3 zzUZLH?u31WSyo~m@Uri91x&Zdeh<7@s9Ck^9(6y0YCt!P9Aqd@i~YHNg+oe%QK9P| zMdrznhQ+msMe82j0!bzZ*>9$Zkjq4Af>Ej8CphgDIKt^{rBs4?<AU>!G9-3rb8kNt zsC<E2p&ApV;b`F$QM||m1`$ac1UNyHA<-us16a8NxcGSax7`DsuoRvZStqz2zO$2( zyT=q3EBwg?fJuZ9f{QQa5bZ$YxS!i62$A0&zW$C#aaW!8AeE}9^(ngl(Y0$K!0Q<z z0G*QjwQfZDnH@T&7QHpD%3707nH_{iA6F7sj3YRjXL33#9@><dt)L;6V3)+&OSI0g zH%R?cr*7R<Z2v%qCnPi~8gcqt!Y@EnI5!h}rJg>HXBJ_+(G(hH^@cLriDF49d7=)s zx3l%}^D7g)Sa;xmM=KDctzdyn5Fz-HZ;ng0Xw8z7oo$>$N4qm(us~NEbl7Z@JW9L@ zh!D9|E+u;=6gMIXbRf{H=s28xGK67<rKP3N)8PU3m98#U))<fUJx(-9C>pEYoY{~z z$!$?eCe*?-|0bt7<f5H9^ht)5cbYx5X6Ti$g5BLHswH=BNL`WB>0bTU$hE{m5e-@! zbFWByD(jSedSmTdrdeV9Td8Z{dl(b{3z)&HWqXUHL>*r!og({Y+$YtO1z7OVoK40Z ziZ{$XwC(obN}+Yb`UdcDlUc!Nw?d2^J1NUS?GnX=bo6^QT#Sk+XUF4D@MH;3ikXp4 ze@2@tCLN3P;LN)$Q`y|7dHW_&q0}#QZiMkG<6CntjL>d33kcwZE+NwtC#ZeZPmD%w zZv3QzawbDndMnWBlyQq|;6PsNzb1ei6IVYaP)y-Bqk(<tf^JN6(}X8u=yS^(8H_S4 zhrofRtaGrr4pSmsWJAa>b!vQ6LZb|?JRH;XnRRERy>UOv)&>kEjjb<_E&b`h;UP)7 z+hHjEq#G=Ql2qhtktm%|7d4?gn`ipwV&l`3Q&^CG{+OXk7X6HfRxwog-)l})d74@h zw^R03rirPgU#~FsWjt>r7(UHz6mUbwaN;8T5ncm~w7I<muJ_eS2ECpaN+}eA+&?cy z;MSquw~W52a@|Pd-o;AT5>}>6E<&7~6JtV!g~DpdE7Gn0SXIgjk)P@U&RmT-7bOfA zq}pBOg^@7|AZr=bt`DpJVw&}=<5C}z<+D{g1fP0?L5KhXm|zu|ipO`VjeAvR5|VXw zQ1fl>S6-=yZ;Z6P&m_$C2c3N$iu9iYn6Cz`;DI!7DQ+B{0X3HmSGSZuk|A2E;|t%q zItI=(Y14g$E1DsR<nbuFT?^<nR14d!!{tvz|9Gi2%c($rxAe&}4z;)+n5j^z4JU8& zS=CUzaA1Ho3eZOU$M{bhsaki|zD$`3LP#G^4^eL9I}I%myDt80HSv?jy`2E}(p5pS zgJQehh{!)10}?%79TWh?p1*K{;}h?Z*<CB|+!3JVt{KDR1eKsZm{3xR37rLv&iBZl zBaRB}0`@S5J{%|nl{H}Kj9zCMRxGh8hpiWu3ag+HEx=sC{(G$wNU-%>^~@aNM%uM* zj5_SSzW;@C76pv6Dp)OYcefV{UUL;o2A8(403Aer!Hl;OF=o{93mN?RMEn1O2kaX6 zGsazGv}VtC9a>Kp@@_;YFYEP)-xh6}br9Go<C5$DF!LW<5EdAI-j5Ncg5XcR9YZrh zzaX7{?d@}>+%svWAi=2tD+KEigo>rLg_pcc{#?!fcBD!OKqT~K;CTc|eb5J+2$?hp z@oGagH$%ks^lm^MUofU0h-M{L3fYzng2)BSZfJ<g*UGu(>gySDy?=W0zzl2DNLe$E zC_)`s^)#CEZ1X?WNtpAJxkd1uOInSbvGld1XZa318Q>CW?TZHpEbchC_5DDyA`scd zyjX>O!aYW0goST1zAF}piy~v?qZ-kszE(>JrhxYV+u?ap-y}{eM>iD~NWf0AMavs* z$pDG{vKc@IYe7kBn_V;+6;vEOxuA93wY)*u>PLWN)Toz<kuI3id!-_xA$$DP3th0A zq+IoSp(aE4Dt`nY0^yo0tr`TZza@#>KE{m!(a8j@C7?k|;AfD;eml9=K}%%Y9vRq5 zD}h#>gJ{J!oEUlS<$!-k!*w9TEQWvRsip2#2uAm`edT%}P&iui^c|5&D78>d!<6FE z9><DRHiwB^{yM9s`%y!HGsO^enU{_X&eHp13n*^{LTCcRGN(<Z5N9A4_@XONT7}hC zq;x2;$BL(O)+D|FP=H^|7k}DXHPx*ajD*3Cp7zMFII2efN$=Cj5T5b?4q|&*u`g85 z4DO0G$+WCN)s}?|H&_(xn_~kxIFTF8He^vU<PX@@+6_7EQmYh^HObCf@P*Kk|D^>) z|1%4-ge0lVTHCGQCj`LfDb_*Py@uR0dq1;#?<<u`fJtGu!$HE!PW{SaV;!Wc&0s*w z&+`{Zs+Q@&7Tp(I1lT|Ow3Dg#>*uyc_lur)JHI#$=)f<^z#wQ}wsWx`eI^3uOD6$3 zwVMkld|^w9#P&m==D&gEjnGucN&qJenXX0aM5?+m7u#c=tuvVPNDTS1h2T||5&sP< z{9;9=PxuYavU590S9L||(q@=k{c}QsB1jT5PxEDi2hWo4$CyaJ%M~&L&|FU<C^dpL zRf$<539a+(c2<saEx&SFb@53tj6U1nV(j6uCpj2rp|Jp6T*I8gn61c@6}z`%A1@lD z+3~6XJwg||8!CsrF(K!~F&K;p+NEk1g>2Vg%iV{un-0lfRVB{AX)U5$&x=DttWb!p zz(0(B%C85q-i9JRyqbM~Dk2$GhydDt%3<o16npxA7#~QyP*G7DZ(P$Gj*dxl?qeFU zcE(Qu`~}YBYxn?T^>e&l=QsOKV^epZKMV&-B~4lYOn6GejWx+iP#EWaE=;SsQEW?< z)5$7C8543#u$a0aP;uk>qMkgQk-ph2Rxp5L1axE!U{7w2mps4*B<b*c&7jb@gHe3- z5vUg7t1&}`$W;}P7k=Q9ka^lu!UgAYIS;bOa8t)%J@-j&U59h-L^k9K`lt|Oi6Dr~ zdk~4El>NtJkLK)^ab!D9k4^x|vr(zp`{ENp*qPB@v%B*KUhw&__-KQoOX6$_%WnM} zLTh{Nb{=gYK;}8Tt9Ze7LMi?vG>}#K+Z*yO4nb8ySM-DP$b-x0If^9FyoUG?pqFZ( z6L>OBK%PbllgVC{Se4C=w>zWKWk5p|J`JsP7xuAGXTbmArY;*SgRi_x9CQW<A1#6A zsE_P8TKSJsr<fVI^<Xlc!a@OtXX}@^jFzOp^1>B#A4=t?(4QZ%Vsq#iEg{Uv3$ksq z54(@!w*u4`88g$I-E8qWpfCV*tx0%88aI%JB=fLI+U%=Jo;TQ)@&mT}pgK<AhU-=E zsdfhRQ8>mJe-P&)L!AO$j_N@ty=bFiZS01ob>?9cN-ccR6g0mTs@g=`;W37k<J{91 zp0)Y)G)X4kQX+^MddNlJ{k}Y3-;TP}9kOhOs{>;Yh~d6Gh9*Fy<fJWrh_rD>j#_8g z#C^zMKZ9w?V1wMrZK+V4Oqwnv=d8vIU1ZpO)ecWG;p%>NkS#rKW#7vRr~jmotidfd zO6pyv`ljN)2eXxMS+as!cK^Zet^ucm1l|2TVVQidoU|(kzUnlt@a{;l*y1BR<<PA9 z?NOkt301)>0_!5g^kjV{KB0HD4F#N1(1ii)3?~;aDY7NJzz}~<*}c?E8huFvd{t!m zY*yrysi@L)MEWgY3r1-=ghQVxLE#g>?Z&b_3jp*WgL>Mii8(F_cw5{KgNFJE+|A6h zUtoNL0I@R_u1ZPD#<?TWS&|ppT_Mp`C7<WsiUGJ}tA%jJp@n-?i`t(OJd`BWlpsDA zLusGpK&dhpqQ!F>A%os!)!2?Zo%G26@n;pk*YOCf)bT|O0MoTED=oz}Es!6E2w6sL zk+B8OoygC>oW`@xd*vIpzZx*taV(B;l*Xe$#73J#Y<HuG8e&usiBh}~=ny}0-5*bn zj9omfB-(57<LzU?&x0}LHsCXfB7)9v!Qcw`CjBnmpOwo-Dp1}C`{H3^ob%hH(T3=r z%`vkQt6MY&R9Vy@A(V3tPr64}!<(`+l<{R9z`WBs=r5+VbwZB&jaeaT|Eo-*frFuh z=sgQTo`__*nBF89p~=+HR+Z*FoN!%^=ht`cGUFw6vzpa*a#%i(dek7x+mWU>23xtT zhI<f?d6fl@voKk4nd1w?AJhyn{1$6s+B~6&+vY9QPvV1-RbrNa3fKD;SFmKs>ME4{ z=wv{D-VMo`5Jf1}?novUj<3%Gi(EbixQp}48&$dLc!mV$q~2ZpU_K~~V)OAIRNr}s zCwz8hY?GP<5~0`8XXtOf?612N3-;GOq}+-R)EP_`&Q9JDV*2S@I!0F9|E}I^Sy#@@ z>V4HbGDd05pp^UA-)IyTFMHhXjMne=-49($ho96e#9zCD)jW&iklsljZZ|I?w+St& z+co%VF3!_&o%wMWxC2sl&KBS!4hOkt&&VA%x2wOBWaS6KS#~BH$*P&0vNFrsKiA1G zi@Y(;Z$phnQSe@h<#wW$W05iy*nl@;k~%^9W{3^HAxJZQhk6FVTz=K}44Okg_pPs* zq_PAzw|P$;W@?48Gh;|!V2({hEs{iV>`K~b{(Hs>h#+{7WGO1igl)K4A{7{lr-5Vq z&tFgKmT&r!j74pMSwf<o7o?YDhpz`rw0NkVZpL&O|G0Z|<bu3>rf{ZQv#cvme&H_v zvh{QFPf{#zqE>LwP<Ph%k~1BKpQ!i#l?x@T8}x%adebB2m8qaBiC@PvQOv9MVQGgi z!6Qpyg_7D%mZOXc#2QOB>oTW{?#h9R>yS#19~LPcYQbF=JsIFwxLU}=eT`K8{h)tC z0`LuwO%Sp6s`9B1T9ZcGGn)KTs0YzN2ABXPD;<pqN6+Zd%;m;#fCNX19w9yrwG^Pq z&i~5X|GIA{gdVE9xfeL{bE1uboV#Es=oI?lVvde#dUjV*VMOElM(T`?d>ZtSjddr1 z&m)LTU9s2LFkg*#6%Zsc$MG=STqiaZ4vCHNlf6eg0T*lJ$o$@cotLLk+^Pdd6q+&T ze!xQG%2r)Y=OqS~M19WxxsXsXxJu*5+h*tmQr<#q&H*fw=*buo9(SGy2(Gj-wgBoA z=7rz2?if8cegFnjCg#$OSXCmjMAheLTy*SEXj<2Yc0U>mdE-myD8hx)KhrIJOP+dO zK&^#HK1~qJW$9{)e<RPau_F}8YVMkez+M@8V(vH)j3z_07jrtxG*yN0-FAaM^#WdL z8%coTmG^p+C+~>(?>w(d6L}F4JM%f-ySWV~wof%xD8`D8xrj_HviK67lss{4eZ&da zdfDF_JUxg&<VyXCgz||AA@b#SQx93>UXta5j~WQ+u*!MKk|Z<}5<tf$y&0yFyMd%6 zQRGc}T_yca(SpzBP<H5}J7fPfghqNiyFB=na<s*bY)GUass{4eD;Z$5nk;-+rZvKm z-ug#j(=X1d$;ixhT`;HGx{M)}M;U9^23Kt13}o>@9;Qz+*~um7#Fz{Wls=^>*-gx- zZY%WS%60al?0&*EcxR=bN9y&p7yC1VaA@7L<*wtvsLDeSk$vmbAWcd%Z+a@D#=1H~ z*z`zUkKf3yf?izlxQ89=I~8$B(oA3<tZcGqRDFxl7n*W~@YBiqs`p7V><LgktEUwd zY4o!BxzEd)%I{qMARu_JB7uf4yM6<*25ePK%Imd_iv^90V+HVg)gjAZ?71On%EL-m z$nw|5AJDB{k)E;sX@nCI?hGlb8%Ef>PhS!TK5AFlhj45+pZ0S3?#lbB6R_s*^8OIV zewgh!ER0Epw4fjBYe+_L0w$kMNfqBHti&i}h6ao=Ad3+En>f02?;iuNq>159tZK$i z9+4@PfY}uV1<8gont(zcyEi6EjkL~8P#N`$sdSmnLf9Y2b^YP}ZVfhjq4~%Q51&m* zvVh|`=OeWP?Slkdv|M}zfK*Do(9>A}Jr5m3!T7*?58rn(naMB*?kWDvDTn#{1dhvl znltk`bXJ7!GiMl8#VJiKXojR$4nROY=KM=H+5T-2I)&o#BVW^(PPOUQ3!cU~SxHJ* zbkmspCA%<|vF0S-4;_|qBdfSViA9X8k<CuN0SicMw6UbF)Q+(zcPZgC?CW83_#R)} zm&Cz&r-d2=0j!x=Wba8roZy6XPk&AgTa*>T_Xw@MCoQ&U`-IC(>i1J*cLyd(SbpBU z50+a3S4briggUQ>Ix11rN1VIWXV}eN#I38al)n);TrKqeQTTpYWzM|ZEp7ttUr}{B zg$ytJsX9gi0Ace)&15gSXVF4Vmbae*_|+K!Fn)+~a=A7Z{rfUxBza&PKSq8@akgoV zkzfL&(;`sgCRxOsHu036Ug!?M)KFhnli9!5W9$8u*m`0UpH|8!-*ueNX6zPk1xTUr z&VTv?NA@50osO#sF_vx?>zN>OaF5b!uXG0}(*b|qva;)FwUU^pUwMZASf3yb0JsGY zj{lGyN&=>#<x6|pEm2sr<PiQ59z)iow=jxv?N)T)11@~R3Os8>GCOODcoEIiPN}|0 zAg{x<bHr8EF7$stcj6Y<Zuzt@7Kx)4w!xs1c~Q>;mQBH`;SM6!PgLDw1R4qbi+9-M zHi~F!llNz?*Um=NQirb9y%>69-^A1W;Lx7_*p{;**z&+)q28=Su?O8m^$mwbE@&oZ zMV>raNZkjs-Nj<zA5XYiWr`$M<G26;TjY8@xrL(E4pkXM7IAWfRWC*(>D}F<lsyEf zL9lFZgJ=sJ!t~m{#T27S98-oq)bd*@P47MCgE{VGLw;WZl&@7|hOBB@LcG|N=aFI^ zw;eRG3B&m4|1Vg(Y@+|ZIZ!lST;0l^iPJ?KGPO$S*$;q+&il@(bQ1Atq#ehuM!Knt z*hw<umWKhIO~x`?W=ZoR*1Dq@@*o)zbL&II+*p>26XB*XV9zIp4z6doDB4yGQNkP? zLFFloJ~1x<yb%{a)uX*vi?N?lDisbH0bs}030i`G-2V$f9nv!3x3;<)lk_k+T~`jv zjFG$OMb60j$?DzHvQwbSRjD`b!2qVUpCp!!tNgqL8mUj3B>CTDpk+#Ws%|Ir{j(e| zazqlC>PKF2uM-&Mzq)IW5}DZpFzVoB{w7J1+%xQz5#Z<x;-5z}<aeZrIYi?Yd34}s zSY+bwrd8IIaw0CgczE>VXcIShvE1N1@X(dcdi)V=rx^0=U)dZQO22$(WMQu?^eS28 zWwG~sfyh7Uc3x76vUSVLy{D_DVYSEoYZE=7ST4F`2I1FaN24!SHr_Ry7U5+y8Yh9O z>tlH_Yd{1)kSjeOhBC~Xrx%W@Wtj@73q?v#GaI_-cRD4nGI07{JQR~^{b`yWWnLev z>;hbtcLG=t+ja+O*+SH73LLtl(#j`qRy9(zFuUZ;Nvd1ewOXVn@ggXW9{OtO6myp_ zRrFF<tXuO73}0_D=VW%4;PqD4H`N~CN+Jyn)=lN$SLH@45nK>4cVSfY6^O4ely1RE zwVE-{AC>M$N@cIUudL-$3I+9`+Rrq)PwVa=2hTkBT)d?Z?D2lz>ilWnXpA=t4$&7Z zvQLB?k8dJOx{TTLrx&>Q*g5hQIX?T(z%2qQ*dGZyI_rY{Z2JRd5YBTKt&NQfcSb!; zK4Cj5N@jiWbg5>z=}VX8`X-qi=Kgu|1PkP5c2xh<azU?JgNDwWnY1hQGp?2BOa*c1 zR{xxS$LTBG9TEB@HfOykuRCi@6)tLL={P2{q3O>jjkNK3$ge;-taI(x4X0AQT~94s zxeAYA!{BcZnGn!X#8d&|V+GIDfEOil5M`b5I%6m*R1trj%%Je<^&>UUA*`hS+kieg zj>ob~{}!E1-Vh(H!jvhUp_rphi?pP!KL@~QIKJ4bRA^lV(|zzMub2dv+FusbTo4U7 zJf*QY(3U6*us!8oi(3wkFLz6b$D4e@TA~oE0uCSj(B`;X@p>^qTW`xPU{Kw!+2y@x zcW+l()Ul*P5u5mh(~B=&Xp4jLmGQobitQnuhI&$naV`<q5<Da+F2H3=eq)p>(^1_y zEGtpn51(fBFAJ1HX-w-<svn4ap86H}_t-VG6sMzX!09CVMBoXMk2C|@JYO2HWl2VJ zTa)N#I+Y|id7ws6+G#dvs~&!=WOk0VHNWByZ*8$ajE1Q1w_c!Y{vkvjzSnLgNVLju zTd$87ge<A<(!R2T?3i_`FCRU&Q5~U_vqfctwm1FAh}94si>(2+mwK<qGDY<Bh>FO5 z;;AAwZbn&m?<cyl_y29%6TP^#+Pp&g-sy7k!1J92{g$a$bv;5oZU{Eweewk{oxfN1 zGSoK&4@W2|xcbTn2DMn*M<DzpQ3pH8$Ms62#^?ZPEF;yW3E?^dQpB{{9X>2l;K83k zB)@)N>yNL<!pz%NgsD)ppac%8N|DGry+%9BCuP^P?SB$Afxkt1D2p!!npB8O7bd%Z zYt_0Z{C*pM6^I?8+az43yR$`td{{-QSgsK0G@W%4)A17j{js22mv4|IB!w4u(c~EM z#>XWiE*_Rq<a=CU-dJ5JDm~;zRTyS?j!k=1!Sns`4xu8d3evZN5k=N*q-}XV6;#qX zh4b^~tM3inbX(MGpIbK`d%LiEv)qkcBG_<)rp9<XwV(WxA>h@v;Tvy9l-1vZM&6;G zfML3|TQS2*Q-7aG0Jw1M!|r|?*|%zQpy4tza75<EAP1HQ^CO2ShK$4~aB9vDAqIyI z@B>%9$Ie(HYD0uvYnH7K68nEFVU`+s>i4`7JRoDcL?XhFS6E*$^0%N>Tdx|gZfPT3 zbkOY38Z6-pgsF+uu`4~g3e20{jOSAz2bzcB{!{@i8Tr#lM1uy&Bni<<B|&`&_Hp;T zz3G1R*5(Fjq$9~shDIrSY`H-^#yHqMc0g6P1v^ogn1t(M<gK5Rkog8jqeX!;ZIRcp zSXWEu*wAc@L@(_qu|b|QQ7sPX8KXTj@;FlFL!Gmr(mK~HNu%YhUJNdfGucUJ;B%ff z#faqLBsjH#**hCN$;2{S2TMbroR{oj@ZGgy<2_r?Xh&Lh{N)_GsK7RSaK*reTmV`5 zWO@eS^r6TGLur73XDPK4h@?A${=9jH%I}&6IU`5%peL=Xg~ZF9cpwHoxyQ5X7G`lk zJcWacFmyoBzZ@F@yKv~LtLu7C6rq;LdfUbWX46Cbtk{8%=ewRSB5x(WKpgc$YQHRP zoG)gFg(HvNZYXkSSOMaq1(00u^o>(U3^(&Q_k^uU6oG&*9_xtjAi8U@2>up%KP9+o zN(?R(lRP0E2L(a`)!Yb4^t@)VUN}drd0+%HrQ6*giUwvcx)CTwfB+q&Gc%j^h-Z+* zwg8~?CKsK<lAUHK5LYe-ebRnQB^(p5iE)%95O2S@%x2y_PD*L)v9vU-H;WNbGNbM_ z=p>f_V0b1WRfmj{Pzhc$!nK}GUmuW(P?m#4DMQh4NA^I>eXt9kV^cc#^Y$j!y=FE$ z7^DdmA-Z+?l)q%nis-nW7QDi+>^)a$lyCv-Lea&-7;%W&3PIL&45&eTUt~w!f+5U` zJ=QF|l>;D^q#lq;*a7sv7xb;opd;)C&J@Z%fEmHP{3i%ul!O*k#!lD-iL*!t`&JyK z`|^%q!Fm4Z4nzbiocfL}Q*QhOu4<5Di~wKwN(GFNJt;sq!m{dR$Q2v_;G7I^I1Rb_ zTt6JR@1v*({IE8!p0v8~zx@3CSeo{FF43HgZu$-IAMLScERpL?aq`3-6-|Axb~Bfl z?Abo18H#jX9$-o}BJl#1YwSq8i(^7=k_EmY`6$oAz(uF^6?i<P_tCvAXP*Ox8LBRX z-(wQdQC3Z<p;O#e04Tq{D7Pw_7B}ZOlZGe;Db4pHNXG3HsIJWN8LKD&0hB7ZL1<#y zQk1q9{7Z&UfZuogcpoAZ!n)>@h7sKRju9$SbTsK6#&D<wQkTN8$4CVO3KK%RJ9t_t z&Wff<vz#b@Fy%hTy<qlm=vzlnf1LDx-l$v=x!B+k9`%h;onB5apazF{ACUJPKF=8& zR7HX$&VMW@r$OOVUtr4HUmrB?a3|}r15O*bhJ_aA1C*wg7YYDv>heH^5?9USp>;iP z0MHg$D~DU|a@d&c1piwZKe|STV9Ml*H*b33<Z!yGRsCmnVrnc!bgeA^-OeqDZpFk7 zkCI&x<@Jz>s2V!B21cc#j5O~j+R!Fw`Rmd^<ocJ}?(-;b=T)8+0CU3)%?ba^NOh7C zAIw~$8vB5?dsMuG*@)w$-k~6}i$MfN7|w26mfD-hRl6VuzqDyfPRbB|`dh1tpaHRT zDfWcST<TaXir@HHrn~@h%s=xHzsJfdq}TSS1xL7hiLBoOKM8=g&?@B@J7ND}so|<d zEsP}%D2)0%X8^|{iPVNx7LSSDS<KWS*I#!W7=Q#oT)aG`YY(Z601J)TDZ|HRde)$J z8W^$WR+Gk}Ax@y5>KJ3Jc?2F52Gf?%NQ)8jFXpnl0!D@&gD6--pii-O?z$Ud*Qa#> z)EWS!#4u^_+KYEjaYu=x<o1LDJcX0S4!ZgQo&>R%qG3p?@<_wKkTz|XJtC}@0X7v~ z_fJN`n9DCH+(>qOZ_|%ecvH>!AJ`*^i`x5<TBe;rVikKYb_tXgKA{e#fCjlPU=jjw zAQO-u_EWkBBsRJGVHnX2S%8jPS0LO-1UY<0iQ~+N#qvAo&zgc;ZNoGs@uG6`zi+jh S6sV?}nb=ae?}Zcq0000R+bit= diff --git a/docs/benchmarks/deepswe/README.md b/docs/benchmarks/deepswe/README.md index 09a914889..9e5fed668 100644 --- a/docs/benchmarks/deepswe/README.md +++ b/docs/benchmarks/deepswe/README.md @@ -2,6 +2,29 @@ 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 | From 7c92173dbc40d932770cc21409df8f02f0ec5cc7 Mon Sep 17 00:00:00 2001 From: agentfield-bot <agentfield-bot@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:32:42 -0400 Subject: [PATCH 166/195] README: lead the benchmark line with #1 on DeepSWE Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7ShhY74oyWjYGB3SougdE --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8ccde4f36..1ce6ac09c 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ 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. -On DeepSWE its developer subharness solved the most issues of ten harnesses on -the same open model, at the lowest cost per solved issue ([benchmarks](#benchmarks)). +**#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). From b1ac028368e55f0c13733525207b3084c78eb6e0 Mon Sep 17 00:00:00 2001 From: agentfield-bot <agentfield-bot@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:33:41 -0400 Subject: [PATCH 167/195] README: fold version pinning and building from source into a collapse Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7ShhY74oyWjYGB3SougdE --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 1ce6ac09c..c555736d0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ curl -fsSL https://agentfield.ai/get/codeaf | bash codeaf ``` +<details> +<summary>Pin a version, or build from source</summary> + 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 @@ -67,6 +70,8 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION=<tag> bash To build it yourself: `git clone`, `make build`, `bin/codeaf` ([guide](docs/GUIDE.md#install)). +</details> + 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. From 53e9fd95385add2116dec2fc2fe98512de024baf Mon Sep 17 00:00:00 2001 From: agentfield-bot <agentfield-bot@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:36:17 -0400 Subject: [PATCH 168/195] installer: drop the telemetry notice The installer printed a three-line telemetry notice after the receipt. It prints none now; the binary's full notice still arrives before the first session's events are sent. The local install marker stays. The test asserts the installer prints no notice, and docs/TELEMETRY.md loses the installer's block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7ShhY74oyWjYGB3SougdE --- docs/TELEMETRY.md | 11 +--- .../1488-installer-no-telemetry-notice.md | 9 ++++ scripts/install.sh | 34 +----------- test/installer-telemetry.sh | 53 +++---------------- 4 files changed, 20 insertions(+), 87 deletions(-) create mode 100644 docs/changes/unreleased/1488-installer-no-telemetry-notice.md diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index a0d1e1a66..17bc05bd1 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/changes/unreleased/1488-installer-no-telemetry-notice.md b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md new file mode 100644 index 000000000..daf2c8304 --- /dev/null +++ b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md @@ -0,0 +1,9 @@ +--- +kind: removed +title: the installer no longer prints a telemetry notice +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." + - "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/scripts/install.sh b/scripts/install.sh index ee503684f..ac3f1caef 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,14 +7,6 @@ LEGACY_REPOSITORY="Agent-Field/aforge-v2" # Remove after the one-release reposit CHANNEL="${CHANNEL:-stable}" INSTALL_NAME="${CODEAF_INSTALL_NAME:-codeaf}" VERSION="${VERSION:-}" -# The installer's three-line telemetry notice, verbatim from docs/TELEMETRY.md. -# The binary prints the full notice before the first session's events leave; -# the installer says the fact, the inspector and the switch. It writes a local -# install marker and prints this text, never sends telemetry, and makes no -# request that the download steps did not already make. -TELEMETRY_NOTICE='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' VERBOSE="${VERBOSE:-0}" NO_MODIFY_PATH="${CODEAF_NO_MODIFY_PATH:-${AFORGE_NO_MODIFY_PATH:-0}}" # legacy-name INSTALL_DIR="${CODEAF_INSTALL_DIR:-${AFORGE_INSTALL_DIR:-${HOME}/.codeaf/bin}}" # legacy-name @@ -51,7 +43,6 @@ Environment: CODEAF_NO_MODIFY_PATH, VERBOSE GITHUB_TOKEN or GH_TOKEN: GitHub answers anonymous API calls sixty times an hour per address; a token raises that. CODEAF_GITHUB_API and CODEAF_GITHUB_DOWNLOAD for mirrors and tests - CODEAF_TELEMETRY=off, or DO_NOT_TRACK=1, turns the anonymous usage counts off EOF } @@ -66,18 +57,6 @@ usage_error() { exit 2 } -telemetry_off() { - local value - case "${DO_NOT_TRACK:-}" in - 1|[Tt]|[Tt][Rr][Uu][Ee]) return 0 ;; - esac - value=$(echo "${CODEAF_TELEMETRY:-}" | tr '[:upper:]' '[:lower:]') - case "$value" in - off|0|false) return 0 ;; - esac - return 1 -} - # The install marker is a local record only: it names how and when this # machine's codeaf was installed so the usage counts can bucket by channel, # never who installed it. Nothing here contacts the network. @@ -106,8 +85,6 @@ write_install_marker() { chmod 0600 "$file" } -# Printed once, at the very end of a successful install. The binary repeats it -# before the first session's counts are ever sent. # The one line a person still has to paste, printed last of all, between a # blank line above and a blank line below, bold green on a terminal. Bare # `export PATH=...` and nothing else, so it can be selected and pasted without @@ -124,14 +101,6 @@ print_path_hint() { printf '\n%s%s%s\n\n' "$on" "$hint" "$off" } -print_telemetry_notice() { - if telemetry_off; then - printf 'codeaf: anonymous usage counts are off (CODEAF_TELEMETRY=off or DO_NOT_TRACK=1)\n' >&2 - return 0 - fi - printf '\n%s\n' "$TELEMETRY_NOTICE" >&2 -} - while [[ $# -gt 0 ]]; do case "$1" in --stable) CHANNEL="stable"; shift ;; @@ -509,7 +478,7 @@ append_path_line() { } # The PATH line is not printed here. It is the last thing the installer says, -# after `codeaf version` and the telemetry notice, so the one line a person +# after `codeaf version`, so the one line a person # has to paste sits at the bottom of the screen where their eye already is. PATH_HINT="" if [[ "$OS" != "windows" ]] && ! path_has_dir; then @@ -552,5 +521,4 @@ printf 'installed %s\n' "$version_line" if [[ "$RUN_BOOT_ADOPTION" == "1" || -d "$STATE_ROOT" ]]; then write_install_marker "$STATE_ROOT" fi -print_telemetry_notice print_path_hint "$PATH_HINT" diff --git a/test/installer-telemetry.sh b/test/installer-telemetry.sh index 2a4bd12f0..ca03a320f 100755 --- a/test/installer-telemetry.sh +++ b/test/installer-telemetry.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # THE INSTALLER'S TELEMETRY DUTIES, PROVED WITHOUT A NETWORK. # -# The notice text lives once, byte for byte, in docs/TELEMETRY.md: the full -# form quoted by the binary and the README, and the three-line form the -# installer prints. Only a test notices when one of them drifts. The installer's main body +# The installer writes the local install marker and prints no telemetry notice: +# the binary shows the notice, quoted byte for byte in docs/TELEMETRY.md and the +# README, before the first session's events are sent. The installer's main body # downloads a release, so this test never sources it whole: it lifts out the -# three telemetry functions and runs them against a temporary state root. +# marker and PATH functions and runs them against a temporary state root. # Nothing here opens a socket. set -euo pipefail cd "$(git rev-parse --show-toplevel)" @@ -14,11 +14,8 @@ script=scripts/install.sh doc=docs/TELEMETRY.md test -f "$doc" || { echo "docs/TELEMETRY.md is missing; this test reads the notice from it"; exit 1; } -eval "$(awk '/^TELEMETRY_NOTICE=/{f=1} /^VERBOSE=/{f=0} f' "$script")" -eval "$(sed -n '/^telemetry_off()/,/^}/p; /^write_install_marker()/,/^}/p; /^print_telemetry_notice()/,/^}/p; /^print_path_hint()/,/^}/p' "$script")" -type telemetry_off >/dev/null +eval "$(sed -n '/^write_install_marker()/,/^}/p; /^print_path_hint()/,/^}/p' "$script")" type write_install_marker >/dev/null -type print_telemetry_notice >/dev/null type print_path_hint >/dev/null pass=0 @@ -79,55 +76,21 @@ ok "unexpected channel written as unknown" 'grep -q "\"channel\":\"unknown\"" "$ ok "unexpected-channel marker is valid JSON" 'json_valid' ok "unwritable state root does not fail the install" 'write_install_marker /proc/nonexistent-root' -# --- the notice ------------------------------------------------------------- +# --- no notice from the installer -------------------------------------------- -notice="$tmp/notice.txt" -print_telemetry_notice 2> "$notice" -# The first fenced block under "The notice" is the binary's full notice, the -# second is the installer's three-line form; awk counts fences to tell them apart. +# The binary shows the notice at the first session; the installer shows none. +ok "installer prints no telemetry notice" '! grep -qE "anonymous (performance data|usage counts)|TELEMETRY_NOTICE|print_telemetry_notice" "$script"' expected=$(awk ' /^## The notice$/ {f=1; next} f && /^```$/ {f++; next} f == 2 {print} ' "$doc") -installer_expected=$(awk ' - /^## The notice$/ {f=1; next} - f && /^```$/ {f++; next} - f == 4 {print} -' "$doc") -body=$(sed 1d "$notice") -ok "one blank line before the notice" '[ -z "$(head -n 1 "$notice")" ]' -ok "installer notice matches docs/TELEMETRY.md verbatim" '[ "$body" = "$installer_expected" ]' -ok "installer notice is three lines" '[ "$(printf "%s\n" "$body" | wc -l | tr -d " ")" = 3 ]' -ok "installer notice names the inspector and the switch" 'case "$body" in *"codeaf telemetry info"*CODEAF_TELEMETRY=off*) true;; *) false;; esac' -ok "installer notice names what is never shared" 'case "$body" in *"does NOT share your prompts, code, files"*) true;; *) false;; esac' readme_block=$(awk ' /^```text$/ {f = 1; buf = ""; next} /^```$/ {if (f && buf ~ /codeaf sends anonymous usage counts/) {print buf; exit} f = 0; next} f {buf = buf $0 "\n"} ' README.md) ok "README quotes the notice verbatim" '[ -n "$readme_block" ] && [ "$(printf "%s\n" "$expected")" = "$readme_block" ]' -ok "notice goes to stderr, nothing to stdout" '[ -z "$(print_telemetry_notice 2>/dev/null)" ]' - -for v in off 0 false OFF False; do - export CODEAF_TELEMETRY="$v" - out=$( print_telemetry_notice 2>&1 ) - ok "CODEAF_TELEMETRY=$v opts out" 'case "$out" in *"off"*) true;; *) false;; esac' - ok "opt-out prints no notice body" 'case "$out" in *"anonymous performance data"*) false;; *) true;; esac' - unset CODEAF_TELEMETRY -done -for v in 1 true TRUE; do - export DO_NOT_TRACK="$v" - out=$( print_telemetry_notice 2>&1 ) - ok "DO_NOT_TRACK=$v opts out" 'case "$out" in *"off"*) true;; *) false;; esac' - unset DO_NOT_TRACK -done -out=$( print_telemetry_notice 2>&1 ) -ok "unset prints the notice" 'case "$out" in *"anonymous performance data with AgentField"*) true;; *) false;; esac' -export CODEAF_TELEMETRY=1 -out=$( print_telemetry_notice 2>&1 ) -ok "CODEAF_TELEMETRY=1 prints the notice" 'case "$out" in *"anonymous performance data with AgentField"*) true;; *) false;; esac' -unset CODEAF_TELEMETRY # --- the PATH line comes last ------------------------------------------------- # The line a person has to paste is the installer's final word: bare, after a From df574992a0141c2eeaa31d18881b384842894296 Mon Sep 17 00:00:00 2001 From: agentfield-bot <agentfield-bot@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:41:17 -0400 Subject: [PATCH 169/195] installer: a failed install marker is silent Nothing the installer prints mentions telemetry now; the marker is still written when it can be. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7ShhY74oyWjYGB3SougdE --- docs/changes/unreleased/1488-installer-no-telemetry-notice.md | 3 ++- scripts/install.sh | 3 +-- test/installer-telemetry.sh | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/changes/unreleased/1488-installer-no-telemetry-notice.md b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md index daf2c8304..af4d6d533 100644 --- a/docs/changes/unreleased/1488-installer-no-telemetry-notice.md +++ b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md @@ -1,9 +1,10 @@ --- kind: removed -title: the installer no longer prints a telemetry notice +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/scripts/install.sh b/scripts/install.sh index ac3f1caef..84f690001 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -71,15 +71,14 @@ write_install_marker() { stable|rc|staging|dev) channel="$CHANNEL" ;; *) channel="unknown" ;; esac + # The marker is background bookkeeping: a failure skips it without a word. if ! mkdir -p "$directory/telemetry" 2>/dev/null; then - printf 'codeaf: could not create %s/telemetry; skipping the install marker\n' "$directory" >&2 return 0 fi chmod 0700 "$directory/telemetry" file="$directory/telemetry/install.json" if ! { printf '{"install_method":"script","channel":"%s","installed_at":"%s"}' \ "$channel" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"; } > "$file" 2>/dev/null; then - printf 'codeaf: could not write %s; skipping the install marker\n' "$file" >&2 return 0 fi chmod 0600 "$file" diff --git a/test/installer-telemetry.sh b/test/installer-telemetry.sh index ca03a320f..28a1e1742 100755 --- a/test/installer-telemetry.sh +++ b/test/installer-telemetry.sh @@ -75,11 +75,13 @@ CHANNEL='we"ird' write_install_marker "$tmp/state" ok "unexpected channel written as unknown" 'grep -q "\"channel\":\"unknown\"" "$f"' ok "unexpected-channel marker is valid JSON" 'json_valid' ok "unwritable state root does not fail the install" 'write_install_marker /proc/nonexistent-root' +ok "an unwritable state root says nothing" '[ -z "$(write_install_marker /proc/nonexistent-root 2>&1)" ]' # --- no notice from the installer -------------------------------------------- # The binary shows the notice at the first session; the installer shows none. ok "installer prints no telemetry notice" '! grep -qE "anonymous (performance data|usage counts)|TELEMETRY_NOTICE|print_telemetry_notice" "$script"' +ok "no printed line in the installer mentions telemetry" '! grep -E "printf|echo" "$script" | grep -qi telemetry' expected=$(awk ' /^## The notice$/ {f=1; next} f && /^```$/ {f++; next} From d2415a8710566f7d676efb00a3518e355479f7b9 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:54:31 -0400 Subject: [PATCH 170/195] tui3: keep senior-dev's ending outside the wake fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a senior-dev run landed, the engine lane delivered its "ended" card into the chat's wake turn, and once the reply settled the work fold covered it under "▸ worked · ctrl+e", so the person never saw that the run had ended unless they opened the fold. A program's landing is now a boundary between work folds: the card stands on the first frame after it lands, in the same place on reopen, with the task page open or not, while the chat's own work before and after it still folds. An ordinary /task's done card is unchanged. Review of #1488, lane 4 and lane 5 finding F5.5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 4 +- internal/tui3/senior_dev_landing_fold_test.go | 115 ++++++++++++++++++ internal/tui3/workfold.go | 8 +- 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 internal/tui3/senior_dev_landing_fold_test.go diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index ab7b3662d..bd73452d8 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -51,7 +51,9 @@ work, goes to codeaf's own worker, never to senior-dev. 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 +so far under it, and a card in the conversation lands when it ends. Its `ended` card +stands outside the chat's `worked` fold as soon as the run lands, including while its +task page is open; the chat's own work in the wake reply still folds. 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. diff --git a/internal/tui3/senior_dev_landing_fold_test.go b/internal/tui3/senior_dev_landing_fold_test.go new file mode 100644 index 000000000..8f5e37c03 --- /dev/null +++ b/internal/tui3/senior_dev_landing_fold_test.go @@ -0,0 +1,115 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +// The standing task lane is the engine road: its landing arrives while the +// chat's wake turn is being drawn. The first frame and the settled reply must +// both show the program's card without opening the wake turn's work fold. +func TestSeniorDevEngineLandingStandsOutsideWakeFold(t *testing.T) { + for _, roomOpen := range []bool{false, true} { + a := newTestApp(&fakeAgent{model: "m"}) + a.width, a.height = 110, 40 + a.workMode = config.WorkFold + a.entries = []entry{{kind: entryUser, text: "start senior-dev", turn: 1}, + {kind: entryAssistant, text: "I will report when it finishes.", turn: 1, settled: true}} + a.turn = 2 + a.Update(taskEventMsg{gen: a.taskGen, ev: update(7, "Repair the parser", session.TaskRunning, + session.TaskNotice{Program: "senior-dev"})}) + if roomOpen { + a.openRoom(7, "Repair the parser") + } + a.entries = append(a.entries, entry{kind: entryThinking, text: "reading the ending", turn: 2, settled: true}) + a.Update(taskEventMsg{gen: a.taskGen, ev: update(7, "Repair the parser", session.TaskDone, + session.TaskNotice{Program: "senior-dev", Report: "submitted a change"})}) + if roomOpen { + a.closeRoom() + } + first := taskText(a) + if !strings.Contains(first, "senior-dev's ending went to the chat") { + t.Fatalf("room open %t: the first conversation frame hid the landing:\n%s", roomOpen, first) + } + a.entries = append(a.entries, + entry{kind: entryTool, tool: "read", turn: 2, status: toolOK}, + entry{kind: entryAssistant, text: "The change is on its branch.", turn: 2, settled: true}) + a.touch() + settled := taskText(a) + card := strings.Index(settled, "senior-dev's ending went to the chat") + fold := strings.LastIndex(settled, "worked") + answer := strings.Index(settled, "The change is on its branch.") + if card < 0 || fold < 0 || answer < 0 || !(card < fold && fold < answer) { + t.Fatalf("room open %t: the card, folded wake work and answer are out of order:\n%s", roomOpen, settled) + } + if strings.Contains(settled, "reading the ending") || strings.Contains(settled, "read") { + t.Fatalf("room open %t: the wake work did not fold:\n%s", roomOpen, settled) + } + reopened := newTestApp(&fakeAgent{model: "m"}) + reopened.width, reopened.height = 110, 40 + reopened.workMode = config.WorkFold + reopened.entries = append([]entry(nil), a.entries...) + reopened.touch() + again := taskText(reopened) + if card, fold, answer := strings.Index(again, "senior-dev's ending went to the chat"), + strings.LastIndex(again, "worked"), strings.Index(again, "The change is on its branch."); card < 0 || fold < 0 || answer < 0 || !(card < fold && fold < answer) { + t.Fatalf("room open %t: reopened conversation changed the landing order:\n%s", roomOpen, again) + } + } +} + +// The direct session event lane used by --no-host carries the same program +// landing and must draw the card before the reply on that road too. +func TestSeniorDevNoHostLandingStandsOutsideWakeFold(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.width, a.height = 110, 40 + a.workMode = config.WorkFold + a.turn = 2 + a.entries = []entry{{kind: entryUser, text: "start senior-dev", turn: 1}, + {kind: entryAssistant, text: "I will report when it finishes.", turn: 1, settled: true}, + {kind: entryThinking, text: "reading the ending", turn: 2, settled: true}} + a.Update(streamEventMsg{gen: a.gen, ev: update(7, "Repair the parser", session.TaskRunning, + session.TaskNotice{Program: "senior-dev"})}) + a.Update(streamEventMsg{gen: a.gen, ev: update(7, "Repair the parser", session.TaskDone, + session.TaskNotice{Program: "senior-dev", Report: "submitted a change"})}) + a.entries = append(a.entries, + entry{kind: entryTool, tool: "read", turn: 2, status: toolOK}, + entry{kind: entryAssistant, text: "The change is on its branch.", turn: 2, settled: true}) + a.touch() + text := taskText(a) + if card, fold, answer := strings.Index(text, "senior-dev's ending went to the chat"), + strings.LastIndex(text, "worked"), strings.Index(text, "The change is on its branch."); card < 0 || fold < 0 || answer < 0 || !(card < fold && fold < answer) { + t.Fatalf("direct session lane hid the landing or wake fold:\n%s", text) + } +} + +// An ordinary task still writes its landing after the starting turn and before +// the later chat turn, as it did before the program card needed a fold boundary. +func TestOrdinaryTaskLandingKeepsItsConversationPosition(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.width, a.height = 110, 40 + a.workMode = config.WorkFold + a.entries = []entry{{kind: entryUser, text: "start task", turn: 1}, + {kind: entryAssistant, text: "It is running.", turn: 1, settled: true}} + a.turn = 1 + a.Update(taskEventMsg{gen: a.taskGen, ev: update(7, "Repair the parser", session.TaskRunning, session.TaskNotice{})}) + a.Update(taskEventMsg{gen: a.taskGen, ev: update(7, "Repair the parser", session.TaskDone, + session.TaskNotice{Report: "repaired"})}) + at := a.doneEntryFor(7) + if at != 2 || a.entries[at].turn != 1 { + t.Fatalf("ordinary landing moved from its turn: index %d, entries %+v", at, a.entries) + } + a.turn = 2 + a.entries = append(a.entries, entry{kind: entryThinking, text: "checking", turn: 2, settled: true}, + entry{kind: entryAssistant, text: "The task is done.", turn: 2, settled: true}) + a.touch() + text := taskText(a) + card := strings.Index(text, "Repair the parser") + answer := strings.Index(text, "The task is done.") + if card < 0 || answer < 0 || card >= answer { + t.Fatalf("ordinary card is not before the later answer:\n%s", text) + } +} diff --git a/internal/tui3/workfold.go b/internal/tui3/workfold.go index de33eed9b..af3f3af58 100644 --- a/internal/tui3/workfold.go +++ b/internal/tui3/workfold.go @@ -257,7 +257,13 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { if es[i].cut { stopped = true } - if es[i].kind == entryTask || es[i].kind == entryConnect || es[i].kind == entryStanding { + // A program's ending is news the person must see before the chat's + // wake reply. It lands through the standing task lane inside that + // reply's turn, so it is a boundary like an ask even though the + // program has already ended. Ordinary task cards keep their old + // placement and folding rules. + if es[i].kind == entryTask || es[i].kind == entryConnect || es[i].kind == entryStanding || + (es[i].kind == entryDone && es[i].done != nil && es[i].done.program != "") { asks = append(asks, i) } if es[i].kind == entryNote && (es[i].told || strings.HasPrefix(es[i].text, "cancel")) { From 91f6940e1e0cf3a62ac00f5cf94c7aeaf241ab9d Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 13:54:31 -0400 Subject: [PATCH 171/195] session: weigh the prompt prefix with a declared program, and bring it under its cap The prefix-budget tests filled the programs paragraph from builtin.All(), so a build that carries no program (Windows) weighed a page 550 bytes lighter, and the waivers #1488 recorded sat eight bytes below this Linux host's measurement from the commit that set them (57,132 and 49,598 against 57,124 and 49,590). The senior-dev guide now lives in internal/programguide, which the program and a declared budget fixture both read, so the measurement no longer follows the build's registry; a test pins the fixture to the carried program. Two phrases of the guide lose eight bytes between them ("a feature with tests", "a rewrite of a package"), and both arms sit exactly on their caps, which do not move. Review of #1488. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- PERF.md | 9 ++ docs/changes/unreleased/1488-senior-dev.md | 2 + internal/programguide/seniordev.go | 10 ++ internal/seniordev/seniordev.go | 12 +-- internal/session/prefixbudget_test.go | 102 ++++++++++++++++++++- 5 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 internal/programguide/seniordev.go diff --git a/PERF.md b/PERF.md index eb38a0e67..fc39e521e 100644 --- a/PERF.md +++ b/PERF.md @@ -1351,6 +1351,15 @@ costs another **27**. The first spelling of the full cap was 53,100, measured at weighs both varying sentences at their widest wherever it runs, so the cap no longer depends on who runs it. +**The senior-dev prefix is measured against one declared program shape +(2026-09-25).** The full cap is **57,124** bytes and the lean cap is **49,590**. +Both tests include senior-dev's guide even on Windows, where the program cannot +run, and the guide is shared with the Unix program instead of copied into the +test. The cap-setting commit already measured eight bytes over both caps on this +Linux machine; no byte changed when `HOME`, `USER`, `TMPDIR`, the launch directory +or `TZ` changed. Eight bytes were removed from two equivalent phrases in the +guide, so both caps remain at their measured values without raising a waiver. + ## Following through on a completion claim A turn may decline handoff once per request when its own continuation says no diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index f029585dd..b55351ca4 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -59,3 +59,5 @@ an offline machine with no cache uses conservative model limits and still runs. - C5: A hosted senior-dev run no longer offers `question` when nobody can answer it. - C6: The manual now names senior-dev's default web fetch, opt-in Exa and Parallel search, models.dev request, and macOS detached-process limit. - C7: Python unittest projects without pytest now run unittest discovery; projects that have or declare pytest keep it. +- E1: A senior-dev run's `ended` card now stands in the conversation outside the chat wake turn's `worked` fold, even when its task page was open at landing; the chat's own work still folds. +- E2: The fixed and lean prefix tests now weigh the same declared senior-dev guide and tool shape on every build platform. The guide says the same work in eight fewer bytes, keeping the existing 57,124 and 49,590 byte caps after the previously recorded measurement proved eight bytes short on Linux. diff --git a/internal/programguide/seniordev.go b/internal/programguide/seniordev.go new file mode 100644 index 000000000..785bddc46 --- /dev/null +++ b/internal/programguide/seniordev.go @@ -0,0 +1,10 @@ +// Package programguide holds the model-facing descriptions of programs codeaf +// carries. The prefix budget reads them on every build platform, even where a +// program's runnable engine is absent. +package programguide + +// SeniorDev is the guide senior-dev gives the conversation. The Unix program +// and the platform-independent prefix measurement share these exact bytes. +const SeniorDev = "For complex, multi-part coding work: fixing an issue in a mature codebase whose cause " + + "spans files, a feature with tests, a rewrite of a package, a migration. Its brief " + + "carries the issue or ask in full, what done means and how to check it, and what must not change." diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go index 27875812d..91a67ca11 100644 --- a/internal/seniordev/seniordev.go +++ b/internal/seniordev/seniordev.go @@ -17,9 +17,10 @@ // and no stdout it writes to but the host's records. // // ON WINDOWS IT IS ABSENT. Its engine leans on process groups, file locks and -// a bash shell it has never had a Windows form of, so every file under this -// tree carries a !windows constraint and the build's list carries nothing -// there (internal/delegate/builtin/carried_windows.go). +// a bash shell it has never had a Windows form of, so every runnable file +// under this tree carries a !windows constraint and the build's list carries +// nothing there (internal/delegate/builtin/carried_windows.go). The guide +// lives outside this Unix-only tree so the prefix gate can weigh it anywhere. package seniordev import ( @@ -32,6 +33,7 @@ import ( "strings" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/programguide" "github.com/Agent-Field/codeaf/internal/seniordev/app" ) @@ -82,9 +84,7 @@ var Program = delegate.Delegate{ // BRIEF CARRIES THE ISSUE IN FULL: a summary of a bug report is the one // thing senior-dev cannot check against, since there is nobody it can ask // what the report said. - Guide: "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. Its brief " + - "carries the issue or ask in full, what done means and how to check it, and what must not change.", + Guide: programguide.SeniorDev, Lands: delegate.LandsTree, // Its recorder is git unless it is told --in-place, which keeps its // checkpoints outside the folder and commits nothing. codeaf passes it for diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 06dbf1af9..55f2517d9 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -43,8 +43,10 @@ import ( "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/exec/bare" + "github.com/Agent-Field/codeaf/internal/programguide" ) // fixedPrefixBudget bounds the system prompt plus the marshalled tool block of @@ -531,6 +533,20 @@ const fixedPrefixTarget = 48_000 // the waivers are measured again on the merged page: fixed is 57,124 and lean // 49,590, which is dev's 56,146 and 48,814 plus what the senior-dev entries // above added (978 and 776 bytes on this page). Both sit on the measurement. +// +// 2026-09-25, fixes A–E and the missing eight bytes. On this Linux machine the +// cap-setting commit itself, untouched 9abc11099, and this tree all weighed +// 57,132 and 49,598 before the guide edit: 22,299 + 34,833 on the full arm, +// 21,799 + 27,799 on the lean arm. The earlier 57,124 and 49,590 record was +// eight bytes short; changing HOME, USER, TMPDIR, TZ and the launch directory +// changed neither page nor tool block. Fix B's conditional `propose_task` `via` +// schema omits that field only on a door with no program. Fix D's longer +// `program-outcome.md` is read on a wake turn, not in the fixed page; its +// ceilings are rendered on a card and start line. Fixes A, C and E1 added no +// fixed-prefix bytes. The Unix program guide's two equivalent shorter phrases +// pay the eight bytes back, and a declared fixture now weighs that same guide +// and tool shape even on Windows, whose runnable registry is empty. The measured +// caps and both waivers therefore stay at 57,124 and 49,590, with no increase. const ( fixedPrefixWaiver = 9_124 leanPrefixWaiver = 18_090 @@ -654,6 +670,13 @@ const leanPrefixTarget = 31_500 // is the shape a person on such a model actually gets. const leanWindow = 16_000 +// prefixPrograms is the declared widest program fixture. It uses the program's +// own guide but does not read the platform's carried registry: Windows carries +// none, while this budget must still weigh the Unix shipping maximum. +func prefixPrograms() []delegate.Delegate { + return []delegate.Delegate{{Name: "senior-dev", Guide: programguide.SeniorDev, Lands: delegate.LandsTree}} +} + // widestPage is the page at its heaviest: prompts/system.md with every one of // its tool-naming facts in the PRESENT case (beltfacts.go). // @@ -685,10 +708,10 @@ func widestPage() string { // frame whose body is each carried program's own guide // (delegate_door.go), and weighing the frame alone once let a // paragraph of a few hundred bytes ride every request unseen. It is - // filled with the programs this build carries, as the chat door - // hands them over. + // filled with the declared widest program fixture, whose guide is + // the same source the Unix chat door carries. if fact.fill != nil { - widest = fact.fill(Config{Delegates: builtin.All()}, widest) + widest = fact.fill(Config{Delegates: prefixPrograms()}, widest) } lines = append(lines, widest) } @@ -697,6 +720,62 @@ func widestPage() string { return page } +// The prefix budget weighs the same maximum on a build that carries no +// programs. Its program paragraph is a declared measurement fixture, not a +// reading of whichever registry this test process happens to have. +func TestPrefixBudgetDoesNotDependOnTheCarriedProgramRegistry(t *testing.T) { + want := widestPage() + weighedAt := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC) + wantLeanPage := pageAsWeighed(leanShapedAgent(t).config, weighedAt) + wantFixed, err := json.Marshal(widestBelt(t, fixedShapedAgent(t))) + if err != nil { + t.Fatal(err) + } + wantLean, err := json.Marshal(widestBelt(t, leanShapedAgent(t))) + if err != nil { + t.Fatal(err) + } + restore := builtin.Override(nil) + defer restore() + if got := widestPage(); got != want { + t.Fatalf("the fixed page changed when this build carried no program: %d bytes became %d", len(want), len(got)) + } + if got := pageAsWeighed(leanShapedAgent(t).config, weighedAt); got != wantLeanPage { + t.Errorf("the lean page changed when this build carried no program: %d bytes became %d", wantLeanPage, got) + } + for _, arm := range []struct { + name string + want []byte + new func(*testing.T) *Agent + }{{"full", wantFixed, fixedShapedAgent}, {"lean", wantLean, leanShapedAgent}} { + got, err := json.Marshal(widestBelt(t, arm.new(t))) + if err != nil { + t.Fatal(err) + } + if string(got) != string(arm.want) { + t.Errorf("the %s tool block changed when this build carried no program: %d bytes became %d", arm.name, len(arm.want), len(got)) + } + } +} + +// The stand-in stays tied to the real Unix program. A new carried program or a +// changed guide must change the declared widest fixture in the same edit. +func TestPrefixProgramFixtureMatchesTheCarriedProgram(t *testing.T) { + carried := builtin.All() + if len(carried) == 0 { + return // Windows has no runnable program, but weighs the Unix maximum. + } + standIn := prefixPrograms() + if len(carried) != len(standIn) { + t.Fatalf("the prefix fixture has %d programs, this build carries %d", len(standIn), len(carried)) + } + for i, program := range carried { + if program.Name != standIn[i].Name || program.Guide != standIn[i].Guide || program.Lands != standIn[i].Lands { + t.Fatalf("prefix fixture %d does not match carried program %s", i, program.Name) + } + } +} + // atAFixedPlace is a config whose WORKING DIRECTORY is a constant. // // THE PAGE INTERPOLATES WHERE YOU ARE (prompt.go's `- Working directory: %s`), @@ -812,12 +891,26 @@ func widestLoadCapability(config Config) string { return loadCapabilityDescription(func(group string) []string { return members[group] }, order) } +// fixedShapedAgent is the shipping conversation with the declared widest +// program fixture, so the belt measurement and the page weigh one shape on +// Linux, macOS and Windows alike. +func fixedShapedAgent(t *testing.T) *Agent { + t.Helper() + shape := beltShapeNamed(t, shippedBeltShape) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.System = "" + shape.build(t, config) + config.Delegates = prefixPrograms() + }) + return agent +} + // TestTheFixedPrefixStaysUnderItsBudget weighs what every request carries before // anybody has said anything. func TestTheFixedPrefixStaysUnderItsBudget(t *testing.T) { reportPrefix(t, prefixArm{ what: "the fixed prefix", - definitions: widestBelt(t, shippedShapeAgent(t)), + definitions: widestBelt(t, fixedShapedAgent(t)), page: len(widestPage()), budget: fixedPrefixBudget, target: fixedPrefixTarget, @@ -924,6 +1017,7 @@ func leanShapedAgent(t *testing.T) *Agent { agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.System = "" shape.build(t, config) + config.Delegates = prefixPrograms() config.ContextWindow = leanWindow }) return agent From e54fb9b0596e53ca77242c45516e114164edb1c6 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 14:26:29 -0400 Subject: [PATCH 172/195] session: a run in a gitignored folder says at its start why it has no branch A senior-dev run in a folder git ignores inside a repository takes the plain folder road, and its ending said why, but its start receipt treated every enclosing repository as one that holds the home folder and said so. The receipt now names the enclosing repository and says the folder is ignored there, so the run works in place without a branch; a repository that does hold the home folder keeps its own sentence. Review of #1488, verification finding V1.5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/session/delegate_door.go | 5 +- .../programfolder_ignored_receipt_test.go | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 internal/session/programfolder_ignored_receipt_test.go diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go index b2c0d090e..0e7d61e85 100644 --- a/internal/session/delegate_door.go +++ b/internal/session/delegate_door.go @@ -212,7 +212,10 @@ func delegateReceipt(ground string, via delegate.Delegate, record *TaskCopyRecor // way the run's ending does ([ProgramFolderEnd.Sentence]). func delegateFolderReceipt(ground string, via delegate.Delegate, record *TaskCopyRecord) string { if record == nil || record.Branch == "" { - if _, _, outer, _ := programFolderOf(ground); outer != "" { + if _, _, outer, _ := programFolderOf(ground); outer != "" && !holdsHomeFolder(outer) { + return "It is " + via.Name + "'s: it works alone in " + ground + " itself; git ignores this folder inside " + outer + + ", so codeaf cuts no branch there and commits nothing; its changes are there as it makes them." + } else if outer != "" { return "It is " + via.Name + "'s: it works alone in " + ground + " itself, inside the git repository at " + outer + ", which holds your home folder, so codeaf cuts no branch there and commits nothing; its changes are there as it makes them." } diff --git a/internal/session/programfolder_ignored_receipt_test.go b/internal/session/programfolder_ignored_receipt_test.go new file mode 100644 index 000000000..dd59c9205 --- /dev/null +++ b/internal/session/programfolder_ignored_receipt_test.go @@ -0,0 +1,57 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// An ignored subfolder uses the plain-folder road, and its first receipt +// names the enclosing repository's ignore rule as the reason for no branch. +func TestIgnoredFolderStartReceiptNamesWhyItHasNoBranch(t *testing.T) { + repo := newTestRepo(t) + gitOut(t, repo, "config", "user.name", "Fixture") + gitOut(t, repo, "config", "user.email", "fixture@example.invalid") + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("scratch/\n"), 0o644); err != nil { + t.Fatal(err) + } + gitOut(t, repo, "add", ".gitignore") + gitOut(t, repo, "commit", "-m", "ignore scratch") + ignored := filepath.Join(repo, "scratch") + if err := os.Mkdir(ignored, 0o755); err != nil { + t.Fatal(err) + } + program := testPrograms("senior-dev")[0] + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: program, Dir: ignored, Title: "Task", Holder: "test", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + defer folder.Finish("") + receipt := delegateFolderReceipt(ignored, program, nil) + if strings.Contains(receipt, "holds your home folder") || !strings.Contains(receipt, "git ignores this folder inside "+repo) { + t.Fatalf("ignored folder start receipt = %q", receipt) + } +} + +// A dotfiles repository at the home folder has a different reason for +// working in place, and its start receipt keeps that reason distinct. +func TestHomeRepositoryStartReceiptNamesWhyItHasNoBranch(t *testing.T) { + repo := newTestRepo(t) + t.Setenv("HOME", repo) + t.Setenv("CODEAF_HOME", t.TempDir()) + folderDir := filepath.Join(repo, "project") + if err := os.Mkdir(folderDir, 0o755); err != nil { + t.Fatal(err) + } + program := testPrograms("senior-dev")[0] + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: program, Dir: folderDir, Title: "Task", Holder: "test", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + defer folder.Finish("") + receipt := delegateFolderReceipt(folderDir, program, nil) + if !strings.Contains(receipt, "which holds your home folder") || strings.Contains(receipt, "git ignores") { + t.Fatalf("home repository start receipt = %q", receipt) + } +} From 475a76ea5ff5db68f2b62ca3c22dc2d88007b6e1 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 14:26:29 -0400 Subject: [PATCH 173/195] run: a call refused at the estimated ceiling ends the run on its cost limit The loopback API refuses a call whose reserved estimate would cross the ceiling, before any money is spent on it. The delegate worker returned that as a plain error and the supervisor marked a cost limit only once actual spend reached the number, so the run landed as a crash: no model-free "stopped at the limit" line, and the chat was free to hand the work back again. The worker now returns a typed limit for any ceiling refusal, and the supervisor carries it as the run's cost (or time) limit whatever the metered spend, which is what the landing line and the re-hand-off refusal read. The proof runs a real child, the loopback API, the worker, the supervisor and the session landing. Review of #1488, verification finding V1.3. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/run/delegate_child_test.go | 6 +- internal/run/delegate_estimate_limit_test.go | 148 +++++++++++++++++++ internal/run/delegateworker.go | 13 +- internal/run/run.go | 8 + 4 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 internal/run/delegate_estimate_limit_test.go diff --git a/internal/run/delegate_child_test.go b/internal/run/delegate_child_test.go index ba06b8154..771c2b712 100644 --- a/internal/run/delegate_child_test.go +++ b/internal/run/delegate_child_test.go @@ -36,8 +36,12 @@ const delegateChildEnv = "RUN_TEST_DELEGATE_CHILD" // 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 { + name := os.Getenv("FAKE_PROGRAM_NAME") + if name == "" { + name = "fake" + } return delegate.Delegate{ - Name: "fake", Summary: "a fake program", Default: "run", Page: "fake", + Name: name, Summary: "a fake program", Default: "run", Page: name, Commands: []delegate.Command{{ Name: "run", Usage: "[flags] -- <brief>", Summary: "does the whole task", Bind: func(*flag.FlagSet) delegate.Body { return childBody }, diff --git a/internal/run/delegate_estimate_limit_test.go b/internal/run/delegate_estimate_limit_test.go new file mode 100644 index 000000000..03c3920c8 --- /dev/null +++ b/internal/run/delegate_estimate_limit_test.go @@ -0,0 +1,148 @@ +package run_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A call whose estimated reservation would cross the ceiling is refused +// before upstream billing; the real child, model API, worker and supervisor +// carry that refusal as a cost limit even below the metered ceiling. +func TestEstimatedModelRefusalEndsRunOnCostLimit(t *testing.T) { + store := runOpenStore(t) + program, setup, calling, _ := realChild(t, 0.06, "3") + t.Setenv("FAKE_ENDING", "crash") + setup.ModelPrice = func(string) (float64, float64, bool) { return 0, 0.00001, true } + limits := run.Limits{CostUSD: 0.10} + factory := run.DelegateFactory(store, t.TempDir(), program, setup, limits, nil) + outcome, summary := run.Start(runContext(t), run.Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Limits: limits, Factory: factory}) + if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost || summary.USD != 0.06 || len(calling.seen()) != 1 { + t.Fatalf("estimated refusal: outcome=%q limit=%q spent=%.2f upstream calls=%d; want cost limit at $0.06", outcome, summary.Limit, summary.USD, len(calling.seen())) + } +} + +// The conversation's real program door receives the supervisor's own summary +// after the child is refused; its model-free landing line names the limit and +// the row records the cost ending instead of a crash. +func TestEstimatedRefusalLandsAsConversationCostLimit(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(delegateChildEnv, "1") + t.Setenv("FAKE_CALLS", "3") + t.Setenv("FAKE_ENDING", "crash") + t.Setenv("FAKE_PROGRAM_NAME", "senior-dev") + var upstream atomic.Int32 + var retryOffered atomic.Bool + providerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstream.Add(1) + request, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + if strings.Contains(string(request), "ask whether to spend more") && !retryOffered.Swap(true) { + args, _ := json.Marshal(map[string]string{ + "title": "Repair the parser", "summary": "Finish the parser repair", + "brief": "Repair the parser and run its checks", "deliverable": "The parser repair", + "acceptance": "The parser checks pass", "via": "senior-dev", + }) + payload, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{ + "index": 0, "finish_reason": "tool_calls", "delta": map[string]any{ + "role": "assistant", "tool_calls": []any{map[string]any{ + "index": 0, "id": "retry-1", "type": "function", "function": map[string]any{ + "name": "propose_task", "arguments": string(args), + }, + }}, + }, + }}}) + _, _ = io.WriteString(w, "data: "+string(payload)+"\n\ndata: [DONE]\n\n") + return + } + _, _ = io.WriteString(w, `data: {"id":"x","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}`+"\n\n") + _, _ = io.WriteString(w, `data: {"id":"x","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,"cost":0.06}}`+"\n\n") + _, _ = io.WriteString(w, "data: [DONE]\n\n") + })) + defer providerServer.Close() + workspace := t.TempDir() + place := session.Place{Dir: t.TempDir()} + agent, err := session.New(session.Config{ + Workspace: workspace, Place: place, SessionFile: place.Transcript(), + Model: "test/model", APIKey: "fixture-key", BaseURL: providerServer.URL, + System: "Answer briefly.", Delegates: []delegate.Delegate{childProgram()}, + SpendRailUSD: 0.10, + ModelPrice: func(string) (float64, float64, bool) { return 0, 0.00001, true }, + }) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + updates, stop := agent.WatchTaskUpdates() + defer stop() + if _, _, _, err := agent.StartDelegate(context.Background(), "senior-dev", "repair the parser"); err != nil { + t.Fatal(err) + } + deadline := time.After(10 * time.Second) + for { + select { + case event := <-updates: + if event.Kind != session.EventNotice || !strings.Contains(event.Text, "stopped at the conversation's $0.10 limit") { + continue + } + if !strings.Contains(event.Text, "spent $0.06") { + t.Fatalf("limit line does not carry metered spend: %q", event.Text) + } + settled := false + for until := time.Now().Add(3 * time.Second); time.Now().Before(until); { + for _, row := range agent.TaskIndex() { + if row.Program == "senior-dev" && !row.Live() { + if row.Ending != session.TaskEndingCostLimit { + t.Fatalf("landed program row ending = %q, want cost limit", row.Ending) + } + settled = true + } + } + if settled { + break + } + time.Sleep(10 * time.Millisecond) + } + if !settled { + t.Fatal("the program row did not settle") + } + if upstream.Load() < 1 { + t.Fatal("no model call reached the upstream fixture") + } + var hold []byte + var err error + for until := time.Now().Add(3 * time.Second); time.Now().Before(until); { + hold, err = os.ReadFile(place.Transcript() + ".program-handoff.json") + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil || !strings.Contains(string(hold), `"verdict":"limit"`) { + t.Fatalf("automatic hand-off hold = %q, %v; want a durable limit refusal", hold, err) + } + for until := time.Now().Add(3 * time.Second); time.Now().Before(until); { + for _, entry := range agent.Transcript() { + if entry.Tool == "propose_task" && strings.Contains(entry.Output, "ask the person first") { + return + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("the model's automatic retry was not refused: offered=%v transcript=%+v", retryOffered.Load(), agent.Transcript()) + case <-deadline: + t.Fatalf("the real refusal produced no model-free cost-limit line: upstream=%d rows=%+v transcript=%+v", upstream.Load(), agent.TaskIndex(), agent.Transcript()) + } + } +} diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go index 676c09ed4..7efab78ec 100644 --- a/internal/run/delegateworker.go +++ b/internal/run/delegateworker.go @@ -536,12 +536,9 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err 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. + // THE CEILING, NOT A CRASH. An estimated reservation can be refused before + // metered spend reaches the ceiling, so the worker carries the limit as a + // fact instead of asking the supervisor to infer it from dollars spent. 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 { @@ -551,7 +548,7 @@ func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, err } } end(sink.steps, reason, report.Result) - return report, errors.New(reason) + return report, &ProgramEndedError{Status: delegate.StatusBudget, Reason: reason, Result: report.Result, Limit: LimitCost} } if errors.Is(err, delegate.ErrNoTerminal) { reason := fmt.Sprintf("%s exited %d without a terminal record", w.program.Name, result.ExitCode) @@ -602,6 +599,8 @@ type ProgramEndedError struct { // Result is the program's account: its message, what its model claimed // and what it observed ([delegateResult]). Result string + // Limit names a refusal made before actual spend reached the ceiling. + Limit Limit } func (e *ProgramEndedError) Error() string { return e.Reason } diff --git a/internal/run/run.go b/internal/run/run.go index 4b78fb89a..e55268802 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -886,6 +886,14 @@ func (s *Supervisor) absorb(ret workerReturn) { var ended *ProgramEndedError if errors.As(ret.err, &ended) { s.rootProgram = ended + if ended.Limit != "" && s.limitHit == "" { + s.limitHit = ended.Limit + // A refusal at the estimated ceiling ends peer work too, + // even when metered spend has not reached the figure. + for _, cancel := range s.cancels { + cancel() + } + } } } } else { From 7fb4a47d40a4f396be03d789e544dff3599def47 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 14:26:29 -0400 Subject: [PATCH 174/195] seniordev, delegate: the restore accounts for every change the person made, and the engine reaps its whole process tree Two gaps an adversarial pass found in the first round of fixes. The rescue before a restore listed untracked files under the CURRENT ignore rules, so a person who added an ignore line and created the file mid-run lost it; and it skipped a tracked file the person deleted, which the restore then silently recreated. Every path changed after submission is now accounted for against the rules recorded at the run's start: modified and new files are copied as before, deletions are listed in a plain manifest in the rescue folder, paths ignored at the start are still never touched, and no rescue folder is made or named when there is nothing in it. Processes were found at the end only by an environment marker a command could drop (setsid env -u ..., env -i), so such a process outlived the run. On Linux the senior-dev engine, and only it (never the codeaf process), is a child subreaper and walks /proc parent links to kill and reap its descendants before it exits, on a normal ending, a stop and a deadline; the host's marker sweep remains only as the best effort after the engine itself was killed. A person's own processes and codeaf are never touched. Review of #1488, verification findings V1.1, V1.2, V1.4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/delegate/child_processes_test.go | 5 + internal/delegate/descendants_linux_test.go | 165 ++++++++++ internal/delegate/launch.go | 22 +- .../processgroup/run_descendants_linux.go | 94 +++++- .../processgroup/run_descendants_other.go | 3 +- internal/seniordev/app/ignore.go | 44 +++ internal/seniordev/app/pipeline.go | 6 +- internal/seniordev/app/run.go | 14 +- internal/seniordev/app/solo_finalize.go | 69 +++- .../app/solo_restore_contract_test.go | 305 ++++++++++++++++++ internal/seniordev/app/solo_ship.go | 4 + .../seniordev/app/workspace_recorder_git.go | 63 +++- .../app/workspace_recorder_snapshot.go | 74 ++++- 13 files changed, 820 insertions(+), 48 deletions(-) create mode 100644 internal/delegate/descendants_linux_test.go create mode 100644 internal/seniordev/app/solo_restore_contract_test.go diff --git a/internal/delegate/child_processes_test.go b/internal/delegate/child_processes_test.go index d474ac2d0..6766352eb 100644 --- a/internal/delegate/child_processes_test.go +++ b/internal/delegate/child_processes_test.go @@ -16,12 +16,17 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/processgroup" "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" "github.com/Agent-Field/codeaf/internal/seniordev/tool" ) func TestRunEndsItsBashBackgroundProcesses(t *testing.T) { if mode := os.Getenv("FC_PROCESS_CHILD"); mode != "" { + if err := processgroup.EnableSubreaper(); err != nil { + t.Fatal(err) + } + defer processgroup.CleanupDescendants() ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() if err := os.WriteFile(os.Getenv("FC_PROCESS_PIDFILE")+".engine", []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil { diff --git a/internal/delegate/descendants_linux_test.go b/internal/delegate/descendants_linux_test.go new file mode 100644 index 000000000..b702c99b6 --- /dev/null +++ b/internal/delegate/descendants_linux_test.go @@ -0,0 +1,165 @@ +//go:build linux + +package delegate_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/processgroup" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +// The engine alone becomes a subreaper, then ends every descendant of its +// model-written bash, including children that detached and discarded the +// marker. An unrelated sleep and the codeaf process remain alive. +func TestEngineEndsAllOfItsBackgroundProcesses(t *testing.T) { + if os.Getenv("DESCENDANT_TEST_CHILD") == "1" { + if err := processgroup.EnableSubreaper(); err != nil { + t.Fatal(err) + } + defer processgroup.CleanupDescendants() + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) + defer stop() + input, _ := json.Marshal(map[string]any{"command": os.Getenv("DESCENDANT_TEST_COMMAND")}) + if _, err := tool.New(os.Getenv("DESCENDANT_TEST_WORKSPACE")).Execute(ctx, steploop.ToolCall{ID: "background", Name: "bash", Input: input}); err != nil { + t.Fatal(err) + } + if os.Getenv("DESCENDANT_TEST_WAIT") == "1" { + <-ctx.Done() + } else { + time.Sleep(300 * time.Millisecond) + } + _, _ = os.Stdout.WriteString("{\"type\":\"hello\",\"protocol\":2,\"delegate\":\"fake\"}\n{\"type\":\"terminal\",\"status\":\"pass\"}\n") + return + } + outsider := exec.Command("sleep", "301") + if err := outsider.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = outsider.Process.Kill(); _, _ = outsider.Process.Wait() }) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + cmd func(string) string + }{ + {"background", func(file string) string { return "sleep 300 >/dev/null 2>&1 & echo $! > " + file }}, + {"setsid", func(file string) string { return "setsid sleep 300 >/dev/null 2>&1 & echo $! > " + file }}, + {"marker removed", func(file string) string { + return "setsid env -u CODEAF_DELEGATE_RUN sleep 300 >/dev/null 2>&1 & echo $! > " + file + }}, + {"empty environment", func(file string) string { return "env -i setsid sleep 300 >/dev/null 2>&1 & echo $! > " + file }}, + {"double fork", func(file string) string { + return "sh -c 'sleep 300 >/dev/null 2>&1 & echo $! > " + file + "' >/dev/null 2>&1 &" + }}, + } { + t.Run(tc.name, func(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "pid") + workspace := t.TempDir() + t.Setenv("DESCENDANT_TEST_CHILD", "1") + t.Setenv("DESCENDANT_TEST_COMMAND", tc.cmd(pidFile)) + t.Setenv("DESCENDANT_TEST_WORKSPACE", workspace) + _, err := delegate.Run(context.Background(), delegate.Launch{Name: "fake", Bin: self, + Args: []string{"-test.run=^TestEngineEndsAllOfItsBackgroundProcesses$"}, + Env: delegate.ChildEnv(delegate.ModelAPI{}), Dir: workspace, Grace: time.Second}, nil) + if err != nil { + t.Logf("run result: %v", err) + } + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + if processStillRunning(pid) { + t.Fatalf("background descendant %d survived run cleanup", pid) + } + if err := outsider.Process.Signal(syscall.Signal(0)); err != nil { + t.Fatalf("unrelated sleep was killed: %v", err) + } + if err := syscall.Kill(os.Getpid(), 0); err != nil { + t.Fatalf("codeaf test process was killed: %v", err) + } + }) + } +} + +// The same parent-link cleanup runs after SIGTERM from a person's stop or a +// run deadline, even when the detached child has an empty environment. +func TestSanitizedDescendantEndsOnStopAndDeadline(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, mode := range []string{"stop", "deadline"} { + t.Run(mode, func(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "pid") + workspace := t.TempDir() + t.Setenv("DESCENDANT_TEST_CHILD", "1") + t.Setenv("DESCENDANT_TEST_WAIT", "1") + t.Setenv("DESCENDANT_TEST_COMMAND", "env -i setsid sleep 300 >/dev/null 2>&1 & echo $! > "+pidFile) + t.Setenv("DESCENDANT_TEST_WORKSPACE", workspace) + var ctx context.Context + var cancel context.CancelFunc + if mode == "deadline" { + ctx, cancel = context.WithTimeout(context.Background(), time.Second) + } else { + ctx, cancel = context.WithCancel(context.Background()) + } + defer cancel() + finished := make(chan error, 1) + go func() { + _, err := delegate.Run(ctx, delegate.Launch{Name: "fake", Bin: self, + Args: []string{"-test.run=^TestEngineEndsAllOfItsBackgroundProcesses$"}, + Env: delegate.ChildEnv(delegate.ModelAPI{}), Dir: workspace, Grace: time.Second}, nil) + finished <- err + }() + var pid int + for until := time.Now().Add(3 * time.Second); time.Now().Before(until); { + data, readErr := os.ReadFile(pidFile) + if readErr == nil { + pid, _ = strconv.Atoi(strings.TrimSpace(string(data))) + break + } + time.Sleep(10 * time.Millisecond) + } + if pid == 0 { + cancel() + <-finished + t.Fatal("the detached process never started") + } + t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) + if mode == "stop" { + cancel() + } + select { + case err := <-finished: + if err != ctx.Err() { + t.Fatalf("run ended with %v, want %v", err, ctx.Err()) + } + case <-time.After(3 * time.Second): + t.Fatal("the stopped run did not return") + } + if processStillRunning(pid) { + t.Fatalf("detached process %d survived the %s ending", pid, mode) + } + }) + } +} diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go index 0fbea8004..136f66028 100644 --- a/internal/delegate/launch.go +++ b/internal/delegate/launch.go @@ -103,8 +103,8 @@ var ErrNoTerminal = errors.New("the program exited without a terminal record") // 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...) - // A run marker survives a plain background job and a setsid escape, so a - // Linux host can find both after the engine exits or crashes. + // The marker is a last-resort Linux sweep after SIGKILL stops the engine + // before its own subreaper can clean up its descendants. markerBytes := make([]byte, 16) if _, err := rand.Read(markerBytes); err != nil { return Result{ExitCode: -1}, fmt.Errorf("mark %s's descendants: %w", launch.Name, err) @@ -176,10 +176,11 @@ func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { } } result.Elapsed = time.Since(started) - // The engine's own group does not contain bash commands, which each start - // their own group, or a command that called setsid. Clean those descendants - // before draining stdout or releasing the folder, including on a crash. - processgroup.CleanupRun(marker) + // A SIGKILL engine cannot run its own parent-link cleanup. The marker is + // only a best-effort fallback for that ending. + if result.Killed || killedBySignal(waitErr) { + processgroup.CleanupRun(marker) + } if waitErr == nil { result.ExitCode = 0 } else { @@ -216,6 +217,15 @@ func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { return result, nil } +func killedBySignal(err error) bool { + var exit *exec.ExitError + if !errors.As(err, &exit) { + return false + } + status, ok := exit.Sys().(syscall.WaitStatus) + return ok && status.Signaled() && status.Signal() == syscall.SIGKILL +} + // openStderr opens the stderr file for append, creating it, or a sink when // no path was given. func openStderr(path string) (io.WriteCloser, error) { diff --git a/internal/processgroup/run_descendants_linux.go b/internal/processgroup/run_descendants_linux.go index 8f86fd5a0..96fca77f4 100644 --- a/internal/processgroup/run_descendants_linux.go +++ b/internal/processgroup/run_descendants_linux.go @@ -4,6 +4,7 @@ package processgroup import ( "bytes" + "fmt" "os" "path/filepath" "strconv" @@ -15,13 +16,46 @@ import ( const RunMarkerEnv = "CODEAF_DELEGATE_RUN" -// EnableSubreaper keeps an orphaned descendant with codeaf if its engine -// exits first, so this run can kill and reap it before the folder is released. -func EnableSubreaper() { _ = unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) } +// EnableSubreaper keeps an orphaned shell descendant with the engine after +// the shell exits, so this run can kill and reap it before releasing its folder. +func EnableSubreaper() error { + if _, err := os.ReadFile("/proc/self/stat"); err != nil { + return fmt.Errorf("read process tree: %w", err) + } + return unix.Prctl(unix.PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) +} -// CleanupRun kills processes that inherited the launch's private marker. -// Unlike a process-group signal, this also reaches a setsid grandchild after -// the engine has exited or crashed. +// CleanupDescendants runs only inside the senior-dev engine, which is this +// run's child subreaper. Its process tree includes detached and environment- +// sanitized shell children, but no process started by the chat or a sibling run. +func CleanupDescendants() { + self := os.Getpid() + empty := 0 + for pass := 0; pass < 40; pass++ { + pids := descendantProcesses(self) + if len(pids) == 0 { + empty++ + } else { + empty = 0 + } + for _, pid := range pids { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + for _, pid := range pids { + var status syscall.WaitStatus + _, _ = syscall.Wait4(pid, &status, syscall.WNOHANG, nil) + } + if empty >= 5 { + return + } + time.Sleep(10 * time.Millisecond) + } +} + +// CleanupRun is only the host's best-effort sweep after the engine itself was +// killed with SIGKILL. Normal endings use the engine's parent-link walk above; +// after SIGKILL the engine cannot do that walk, so the private launch marker +// can find only descendants that kept it in their environment. func CleanupRun(marker string) { // A setsid wrapper can fork just as the engine exits. A short settle // window lets its final exec inherit the marker before declaring it gone. @@ -52,6 +86,54 @@ func CleanupRun(marker string) { } } +func descendantProcesses(root int) []int { + entries, err := os.ReadDir("/proc") + if err != nil { + return nil + } + parents := make(map[int]int, len(entries)) + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil || pid == root { + continue + } + stat, err := os.ReadFile(filepath.Join("/proc", entry.Name(), "stat")) + if err != nil { + continue + } + // The command name is parenthesized and may itself contain spaces or + // parentheses; the parent pid is the second field after its last ')'. + end := bytes.LastIndexByte(stat, ')') + if end < 0 { + continue + } + fields := bytes.Fields(stat[end+1:]) + if len(fields) < 2 { + continue + } + if parent, err := strconv.Atoi(string(fields[1])); err == nil { + parents[pid] = parent + } + } + owned := map[int]bool{root: true} + for changed := true; changed; { + changed = false + for pid, parent := range parents { + if owned[parent] && !owned[pid] { + owned[pid] = true + changed = true + } + } + } + var pids []int + for pid := range owned { + if pid != root { + pids = append(pids, pid) + } + } + return pids +} + func runProcesses(marker string) []int { entries, err := os.ReadDir("/proc") if err != nil { diff --git a/internal/processgroup/run_descendants_other.go b/internal/processgroup/run_descendants_other.go index 3963e763f..dad1e4986 100644 --- a/internal/processgroup/run_descendants_other.go +++ b/internal/processgroup/run_descendants_other.go @@ -4,6 +4,7 @@ package processgroup const RunMarkerEnv = "CODEAF_DELEGATE_RUN" -func EnableSubreaper() {} +func EnableSubreaper() error { return nil } +func CleanupDescendants() {} func CleanupRun(string) {} diff --git a/internal/seniordev/app/ignore.go b/internal/seniordev/app/ignore.go index 25dcdb662..a023beeff 100644 --- a/internal/seniordev/app/ignore.go +++ b/internal/seniordev/app/ignore.go @@ -9,6 +9,8 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/util" ) // A .gitignore reader for the snapshot recorder. Under the git recorder this @@ -47,6 +49,48 @@ func newIgnoreRules() *ignoreRules { return &ignoreRules{byDir: map[string][]ignoreRule{}} } +// startIgnoreRules freezes the folder's ignore files before the run changes +// them, so a later file still follows the rules that existed at submission. +func startIgnoreRules(workspace string) *ignoreRules { + rules := newIgnoreRules() + _ = filepath.WalkDir(workspace, func(name string, entry os.DirEntry, err error) error { + if err != nil || !entry.IsDir() { + return nil + } + rel, err := filepath.Rel(workspace, name) + if err != nil { + return nil + } + if entry.Name() == ".git" || entry.Name() == ".senior-dev" { + return filepath.SkipDir + } + if rel == "." { + rel = "" + } + rel = filepath.ToSlash(rel) + if rel != "" && rules.ignored(rel, true) { + return filepath.SkipDir + } + rules.load(workspace, rel) + return nil + }) + return rules +} + +// ignoredAtStart also honors the exact list recorded before the engine +// launched, including Git's global and info/exclude rules. +func ignoredAtStart(relative string, rules *ignoreRules, paths []string) bool { + if util.PathIgnoredAtStart(relative, paths) || rules.ignored(relative, false) { + return true + } + for dir := path.Dir(relative); dir != "." && dir != ""; dir = path.Dir(dir) { + if rules.ignored(dir, true) { + return true + } + } + return false +} + // 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) { diff --git a/internal/seniordev/app/pipeline.go b/internal/seniordev/app/pipeline.go index 35820672d..a8d9fe4dc 100644 --- a/internal/seniordev/app/pipeline.go +++ b/internal/seniordev/app/pipeline.go @@ -52,8 +52,10 @@ type pipeline struct { budgetCost float64 // rescuePath is the durable place later edits are copied before a restore. - rescuePath string - rescueCount int + rescuePath string + rescueCount int + rescueDeleted bool + rescueManifest string fingerprintMu sync.Mutex fingerprintFiles map[string]worktreeFileFingerprint diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go index 9436ce6f7..27d2e6165 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -96,10 +96,12 @@ func Run(ctx context.Context, host delegate.Host, options Options, notes io.Writ // 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 marker := env.Get(processgroup.RunMarkerEnv); marker != "" { - // The engine owns its orphaned shell descendants while it is alive; - // the host repeats cleanup if this process crashes before this defer. - processgroup.EnableSubreaper() - defer processgroup.CleanupRun(marker) + // Only the engine is a subreaper. It reaps its own shell descendants + // before it exits; the host's marker sweep is for a SIGKILL ending. + if err := processgroup.EnableSubreaper(); err != nil { + return refused("senior-dev cannot contain its shell processes on this machine: " + err.Error()) + } + defer processgroup.CleanupDescendants() } if notes == nil { notes = io.Discard @@ -309,6 +311,10 @@ func endingOf(result pipelineResult) delegate.Ending { } if rescue, _ := extra["rescue_path"].(string); rescue != "" { ending.Message += ". Files that changed in the folder before senior-dev restored its checkpoint were set aside in " + rescue + if deleted, _ := extra["rescue_deletions"].(bool); deleted { + manifest, _ := extra["rescue_manifest"].(string) + ending.Message += "; files deleted during the run are listed in " + manifest + " there" + } } if reason, _ := extra["reason"].(string); reason != "" && reason != ending.Message { ending.Reason = reason diff --git a/internal/seniordev/app/solo_finalize.go b/internal/seniordev/app/solo_finalize.go index eb11d6733..2568e702d 100644 --- a/internal/seniordev/app/solo_finalize.go +++ b/internal/seniordev/app/solo_finalize.go @@ -8,6 +8,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strconv" "strings" "time" @@ -254,6 +256,22 @@ func (runner *pipeline) soloRestoreTree(commitSHA, wantTree string) error { // a recorder's forceful restore. The state root is durable and separate from // the person's tracked tree; a copy failure refuses the destructive restore. func (runner *pipeline) rescueBeforeRestore(paths []string) error { + var deleted []string + var existing []string + for _, path := range paths { + _, err := os.Lstat(filepath.Join(runner.workspace, filepath.FromSlash(path))) + switch { + case os.IsNotExist(err): + deleted = append(deleted, path) + case err != nil: + return err + default: + existing = append(existing, path) + } + } + if len(deleted) == 0 && len(existing) == 0 { + return nil + } root := home.Join("v3", "carried", "senior-dev", "rescued") if relative, err := filepath.Rel(runner.workspace, root); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) { root = filepath.Join(os.TempDir(), "codeaf-rescued") @@ -272,12 +290,9 @@ func (runner *pipeline) rescueBeforeRestore(paths []string) error { if runner.rescueCount > 0 { destination = filepath.Join(destination, fmt.Sprintf("later-%d", runner.rescueCount+1)) } - for _, path := range paths { + for _, path := range existing { from := filepath.Join(runner.workspace, filepath.FromSlash(path)) info, err := os.Lstat(from) - if os.IsNotExist(err) { - continue - } if err != nil { return err } @@ -302,6 +317,52 @@ func (runner *pipeline) rescueBeforeRestore(paths []string) error { return fmt.Errorf("cannot preserve %s before restore", from) } } + if len(deleted) > 0 { + if err := os.MkdirAll(runner.rescuePath, 0o700); err != nil { + return err + } + if runner.rescueManifest == "" { + runner.rescueManifest = "deleted-files.txt" + for number := 2; ; number++ { + _, err := os.Lstat(filepath.Join(runner.rescuePath, runner.rescueManifest)) + if os.IsNotExist(err) { + break + } + if err != nil { + return err + } + runner.rescueManifest = fmt.Sprintf("deleted-files-%d.txt", number) + } + } + manifest := filepath.Join(runner.rescuePath, runner.rescueManifest) + prior, err := os.ReadFile(manifest) + if err != nil && !os.IsNotExist(err) { + return err + } + listed := map[string]bool{} + for _, path := range strings.Split(string(prior), "\n") { + if path != "" { + listed[path] = true + } + } + for _, path := range deleted { + // A filename can contain a newline. Quote only that exceptional + // spelling so the manifest still has one readable line per path. + if strings.ContainsAny(path, "\r\n") { + path = strconv.Quote(path) + } + listed[path] = true + } + all := make([]string, 0, len(listed)) + for path := range listed { + all = append(all, path) + } + sort.Strings(all) + if err := os.WriteFile(manifest, []byte(strings.Join(all, "\n")+"\n"), 0o600); err != nil { + return err + } + runner.rescueDeleted = true + } runner.rescueCount++ return nil } diff --git a/internal/seniordev/app/solo_restore_contract_test.go b/internal/seniordev/app/solo_restore_contract_test.go new file mode 100644 index 000000000..a92407095 --- /dev/null +++ b/internal/seniordev/app/solo_restore_contract_test.go @@ -0,0 +1,305 @@ +//go:build !windows + +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A file hidden by an ignore rule the person added after submission belongs +// to their later work, so the git restore must copy it before resetting rules. +func TestNewlyIgnoredPersonalFileIsRescuedBeforeGitRestore(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace, _ := guardWorkspace(t) + if err := os.WriteFile(filepath.Join(workspace, ".gitignore"), []byte("# baseline\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "add", ".gitignore"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "baseline ignore"); err != nil { + t.Fatal(err) + } + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, ".gitignore"), []byte("personal.txt\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "personal.txt"), []byte("person's data\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(workspace, "personal.txt")); !os.IsNotExist(err) { + t.Fatalf("later file remained in restored candidate: %v", err) + } + body, err := os.ReadFile(filepath.Join(runner.rescuePath, "personal.txt")) + if err != nil || string(body) != "person's data\n" { + t.Fatalf("newly ignored file was not rescued: %q, %v", body, err) + } +} + +// A removed candidate file is a person's later deletion. The candidate may +// restore the file only after a plain manifest records that deletion outside it. +func TestPersonalDeletionIsListedBeforeGitRestore(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace, _ := guardWorkspace(t) + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "README.md")); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(workspace, "README.md")); err != nil { + t.Fatalf("candidate file was not restored: %v", err) + } + body, err := os.ReadFile(filepath.Join(runner.rescuePath, "deleted-files.txt")) + if err != nil || string(body) != "README.md\n" { + t.Fatalf("later deletion manifest = %q, %v", body, err) + } + outcome := soloOutcome{Status: "pass"} + runner.soloTerminal(&outcome, "submitted") + ending := endingOf(pipelineResult{Status: "pass", Terminal: outcome.TerminalData}) + if !strings.Contains(ending.Message, "deleted-files.txt") { + t.Fatalf("ending did not name the deletion manifest: %s", ending.Message) + } +} + +// A restore with no intervening edit leaves no rescue folder or rescue claim. +func TestUnchangedGitRestoreCreatesNoRescue(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace, _ := guardWorkspace(t) + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + if runner.rescuePath != "" { + entries, _ := os.ReadDir(runner.rescuePath) + t.Fatalf("unchanged tree named a rescue: %s, entries %v", runner.rescuePath, entries) + } + if _, err := os.Stat(filepath.Join(state, "v3", "carried", "senior-dev", "rescued")); !os.IsNotExist(err) { + t.Fatalf("unchanged tree created a rescue root: %v", err) + } + outcome := soloOutcome{Status: "pass"} + runner.soloTerminal(&outcome, "submitted") + if ending := endingOf(pipelineResult{Status: "pass", Terminal: outcome.TerminalData}); strings.Contains(ending.Message, "set aside") { + t.Fatalf("unchanged tree named a rescue in its ending: %s", ending.Message) + } +} + +// The plain-folder recorder compares both sides of the saved manifest, so a +// file the person deleted after submission is recorded before restoration. +func TestPersonalDeletionIsListedBeforePlainFolderRestore(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("candidate\n"), 0o644); err != nil { + t.Fatal(err) + } + runner := newPipeline(cliArgs{InPlace: true}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "README.md")); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + if body, err := os.ReadFile(filepath.Join(runner.rescuePath, "deleted-files.txt")); err != nil || string(body) != "README.md\n" { + t.Fatalf("plain-folder deletion manifest = %q, %v", body, err) + } +} + +// A plain folder can also hide a new personal file by changing .gitignore; +// the snapshot restore must rescue it before restoring the candidate rules. +func TestNewlyIgnoredPersonalFileIsRescuedBeforePlainFolderRestore(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, ".gitignore"), []byte("# baseline\n"), 0o644); err != nil { + t.Fatal(err) + } + runner := newPipeline(cliArgs{InPlace: true}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, ".gitignore"), []byte("personal.txt\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "personal.txt"), []byte("person's data\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(workspace, "personal.txt")); !os.IsNotExist(err) { + t.Fatalf("later file remained in restored candidate: %v", err) + } + body, err := os.ReadFile(filepath.Join(runner.rescuePath, "personal.txt")) + if err != nil || string(body) != "person's data\n" { + t.Fatalf("newly ignored plain-folder file was not rescued: %q, %v", body, err) + } +} + +// The snapshot road also leaves no rescue when nothing changed after the +// candidate was recorded. +func TestUnchangedPlainFolderRestoreCreatesNoRescue(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("candidate\n"), 0o644); err != nil { + t.Fatal(err) + } + runner := newPipeline(cliArgs{InPlace: true}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + if runner.rescuePath != "" { + t.Fatalf("unchanged plain folder named a rescue: %s", runner.rescuePath) + } + if _, err := os.Stat(filepath.Join(state, "v3", "carried", "senior-dev", "rescued")); !os.IsNotExist(err) { + t.Fatalf("unchanged plain folder created a rescue root: %v", err) + } +} + +// A person's file may have the manifest's usual name. Both that file and a +// deletion remain readable in the rescue rather than overwriting each other. +func TestDeletionManifestDoesNotOverwriteARescuedFile(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace := t.TempDir() + for name, body := range map[string]string{"README.md": "base\n", "deleted-files.txt": "candidate\n"} { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + runner := newPipeline(cliArgs{InPlace: true}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "README.md")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "deleted-files.txt"), []byte("person's file\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + for name, want := range map[string]string{"deleted-files.txt": "person's file\n", "deleted-files-2.txt": "README.md\n"} { + body, err := os.ReadFile(filepath.Join(runner.rescuePath, name)) + if err != nil || string(body) != want { + t.Fatalf("rescued %s = %q, %v", name, body, err) + } + } + outcome := soloOutcome{Status: "pass"} + runner.soloTerminal(&outcome, "submitted") + ending := endingOf(pipelineResult{Status: "pass", Terminal: outcome.TerminalData}) + if !strings.Contains(ending.Message, "deleted-files-2.txt") { + t.Fatalf("ending did not name the chosen manifest: %s", ending.Message) + } +} + +// Git keeps tracking a file after an ignore rule starts matching its name. +// Its candidate edit and a later personal edit both remain accounted for. +func TestTrackedFileMatchingIgnoreRuleKeepsCandidateAndRescue(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace, _ := guardWorkspace(t) + if err := os.WriteFile(filepath.Join(workspace, ".gitignore"), []byte("README.md\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "add", ".gitignore"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "ignore tracked readme"); err != nil { + t.Fatal(err) + } + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("candidate\n"), 0o644); err != nil { + t.Fatal(err) + } + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "README.md"), []byte("person's edit\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + if body, err := os.ReadFile(filepath.Join(workspace, "README.md")); err != nil || string(body) != "candidate\n" { + t.Fatalf("tracked candidate edit = %q, %v", body, err) + } + if body, err := os.ReadFile(filepath.Join(runner.rescuePath, "README.md")); err != nil || string(body) != "person's edit\n" { + t.Fatalf("tracked later edit rescue = %q, %v", body, err) + } +} diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go index b3647755b..1da770ffe 100644 --- a/internal/seniordev/app/solo_ship.go +++ b/internal/seniordev/app/solo_ship.go @@ -188,6 +188,10 @@ func (runner *pipeline) soloTerminal(outcome *soloOutcome, reason string) { } if runner.rescuePath != "" { data["rescue_path"] = runner.rescuePath + if runner.rescueDeleted { + data["rescue_deletions"] = true + data["rescue_manifest"] = runner.rescueManifest + } } if candidate := outcome.Frozen; candidate != nil { data["submission_reason"] = candidate.Reason diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go index 529d57193..af6df5a84 100644 --- a/internal/seniordev/app/workspace_recorder_git.go +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -22,12 +22,28 @@ import ( // and the only recorder that leaves the run's work in the repository's own // history. type gitRecorder struct { - workspace string - note func(string) + workspace string + note func(string) + startRules *ignoreRules + startTracked map[string]bool } func newGitRecorder(workspace string, note func(string)) *gitRecorder { - return &gitRecorder{workspace: workspace, note: note} + recorder := &gitRecorder{workspace: workspace, note: note, startRules: startIgnoreRules(workspace), startTracked: map[string]bool{}} + if tracked, err := recorder.git("ls-files", "--cached", "-z"); err == nil { + for _, path := range strings.Split(tracked, "\x00") { + if path != "" { + recorder.startTracked[path] = true + } + } + } + return recorder +} + +// Git still tracks a path after an ignore rule matches it. Such a path is +// part of the candidate and must never be filtered as ignored-at-start. +func (recorder *gitRecorder) ignoredAtStart(path string, paths []string) bool { + return !recorder.startTracked[path] && ignoredAtStart(path, recorder.startRules, paths) } func (recorder *gitRecorder) Kind() string { return "git" } @@ -135,13 +151,13 @@ func (recorder *gitRecorder) Snapshot() (string, error) { if err != nil { return "", fmt.Errorf("git ls-files in temporary index: %v: %s", err, strings.TrimSpace(string(staged))) } - ignoredAtStart, err := util.InitialIgnoredPaths() + startPaths, err := util.InitialIgnoredPaths() if err != nil { return "", err } var excluded []string for _, path := range strings.Split(string(staged), "\x00") { - if path != "" && (util.PathIgnoredAtStart(path, ignoredAtStart) || util.GeneratedRunPath(path)) { + if path != "" && (recorder.ignoredAtStart(path, startPaths) || gitRunArtifact(path)) { excluded = append(excluded, path) } } @@ -194,16 +210,16 @@ func (recorder *gitRecorder) Restore(handle, wantTree string) error { if _, err := recorder.git("reset", "-q", handle, "--", "."); err != nil { return err } - newFiles, err := recorder.git("ls-files", "--others", "--exclude-standard", "-z") + newFiles, err := recorder.git("ls-files", "--others", "-z") if err != nil { return err } - ignoredAtStart, err := util.InitialIgnoredPaths() + startPaths, err := util.InitialIgnoredPaths() if err != nil { return err } for _, path := range strings.Split(newFiles, "\x00") { - if path == "" || util.PathIgnoredAtStart(path, ignoredAtStart) { + if path == "" || recorder.ignoredAtStart(path, startPaths) || gitRunArtifact(path) { continue } if err := os.Remove(filepath.Join(recorder.workspace, filepath.FromSlash(path))); err != nil && !os.IsNotExist(err) { @@ -222,24 +238,31 @@ func (recorder *gitRecorder) Restore(handle, wantTree string) error { return nil } -// DifferentPaths includes edits to tracked files and new non-ignored files. -// Both can be removed by Restore, including a file already eagerly committed -// after the checkpoint, whose change is measured against the checkpoint. +// DifferentPaths includes tracked edits and deletions, plus every new file +// the current ignore rules could hide from Restore. Start-time ignored paths +// and the run's own generated files do not belong to the candidate. func (recorder *gitRecorder) DifferentPaths(handle string) ([]string, error) { changed, err := recorder.git("diff", "--name-only", "-z", handle, "--") if err != nil { return nil, err } - newFiles, err := recorder.git("ls-files", "--others", "--exclude-standard", "-z") + newFiles, err := recorder.git("ls-files", "--others", "-z") + if err != nil { + return nil, err + } + startPaths, err := util.InitialIgnoredPaths() if err != nil { return nil, err } seen := map[string]bool{} - for _, listing := range []string{changed, newFiles} { - for _, path := range strings.Split(listing, "\x00") { - if path != "" { - seen[path] = true - } + for _, path := range strings.Split(changed, "\x00") { + if path != "" { + seen[path] = true + } + } + for _, path := range strings.Split(newFiles, "\x00") { + if path != "" && !recorder.ignoredAtStart(path, startPaths) && !gitRunArtifact(path) { + seen[path] = true } } paths := make([]string, 0, len(seen)) @@ -250,6 +273,12 @@ func (recorder *gitRecorder) DifferentPaths(handle string) ([]string, error) { return paths, nil } +// Git's untracked listing includes ignored paths deliberately; the engine's +// own notes are not a person's later edit or part of a candidate tree. +func gitRunArtifact(path string) bool { + return util.GeneratedRunPath(path) || path == ".senior-dev" || strings.HasPrefix(path, ".senior-dev/") +} + func (recorder *gitRecorder) BaseTree(base string) (string, bool) { tree, err := recorder.git("rev-parse", base+"^{tree}") if err != nil || tree == "" { diff --git a/internal/seniordev/app/workspace_recorder_snapshot.go b/internal/seniordev/app/workspace_recorder_snapshot.go index fea227b21..4abe06fda 100644 --- a/internal/seniordev/app/workspace_recorder_snapshot.go +++ b/internal/seniordev/app/workspace_recorder_snapshot.go @@ -15,6 +15,8 @@ import ( "sort" "strings" "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/util" ) // snapshotRecorder keeps the workspaceRecorder promises without git. It edits @@ -26,8 +28,9 @@ import ( // 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) + workspace string + note func(string) + startRules *ignoreRules mu sync.Mutex store string // lazily created; "" until the first snapshot is kept @@ -38,7 +41,7 @@ type snapshotRecorder struct { func newSnapshotRecorder(workspace string, note func(string)) *snapshotRecorder { return &snapshotRecorder{ - workspace: workspace, note: note, published: map[string]string{}, + workspace: workspace, note: note, startRules: startIgnoreRules(workspace), published: map[string]string{}, } } @@ -157,13 +160,20 @@ func (recorder *snapshotRecorder) Restore(handle, wantTree string) error { for _, entry := range wanted { wantedPaths[entry.path] = struct{}{} } - current, err := recorder.walk() + current, err := walkWorkspaceAll(recorder.workspace) + if err != nil { + return err + } + startPaths, err := util.InitialIgnoredPaths() 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 ignoredAtStart(entry.path, recorder.startRules, startPaths) { + continue + } if _, keep := wantedPaths[entry.path]; keep { continue } @@ -210,7 +220,11 @@ func (recorder *snapshotRecorder) DifferentPaths(handle string) ([]string, error if err != nil { return nil, err } - current, err := recorder.walk() + current, err := walkWorkspaceAll(recorder.workspace) + if err != nil { + return nil, err + } + startPaths, err := util.InitialIgnoredPaths() if err != nil { return nil, err } @@ -220,11 +234,24 @@ func (recorder *snapshotRecorder) DifferentPaths(handle string) ([]string, error } var paths []string for _, entry := range current { + if ignoredAtStart(entry.path, recorder.startRules, startPaths) { + continue + } old, found := before[entry.path] if !found || old.hash != entry.hash || old.mode != entry.mode { paths = append(paths, entry.path) } } + currentPaths := make(map[string]bool, len(current)) + for _, entry := range current { + currentPaths[entry.path] = true + } + for _, entry := range wanted { + if !currentPaths[entry.path] { + paths = append(paths, entry.path) + } + } + sort.Strings(paths) return paths, nil } @@ -340,7 +367,21 @@ type treeEntry struct { } func (recorder *snapshotRecorder) walk() ([]treeEntry, error) { - return walkTree(recorder.workspace, true) + entries, err := walkTree(recorder.workspace, true) + if err != nil { + return nil, err + } + startPaths, err := util.InitialIgnoredPaths() + if err != nil { + return nil, err + } + kept := entries[:0] + for _, entry := range entries { + if !ignoredAtStart(entry.path, recorder.startRules, startPaths) { + kept = append(kept, entry) + } + } + return kept, nil } // walkTree lists every regular file in root, sorted, with its content hash. @@ -355,6 +396,16 @@ func (recorder *snapshotRecorder) walk() ([]treeEntry, error) { // 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) { + return walkTreeWithOptions(root, honourIgnores, honourIgnores) +} + +// walkWorkspaceAll sees newly ignored files without treating a protected +// folder as a destructive restore failure; the snapshot store stays strict. +func walkWorkspaceAll(root string) ([]treeEntry, error) { + return walkTreeWithOptions(root, false, true) +} + +func walkTreeWithOptions(root string, honourIgnores, maySkipUnreadable bool) ([]treeEntry, error) { rules := newIgnoreRules() if honourIgnores { rules.load(root, "") @@ -362,7 +413,7 @@ func walkTree(root string, honourIgnores bool) ([]treeEntry, error) { 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) + return skipUnreadable(err, name != root && maySkipUnreadable, info) } relative, relErr := filepath.Rel(root, name) if relErr != nil { @@ -401,7 +452,7 @@ func walkTree(root string, honourIgnores bool) ([]treeEntry, error) { } hash, hashErr := hashFile(name) if hashErr != nil { - return skipUnreadable(hashErr, honourIgnores, info) + return skipUnreadable(hashErr, maySkipUnreadable, info) } entries = append(entries, treeEntry{ path: relative, mode: info.Mode().Perm(), @@ -498,6 +549,10 @@ func copyFile(source, destination string, mode os.FileMode) error { // 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() { + startPaths, err := util.InitialIgnoredPaths() + if err != nil { + return + } var dirs []string _ = filepath.Walk(recorder.workspace, func(name string, info os.FileInfo, err error) error { if err != nil || !info.IsDir() { @@ -511,6 +566,9 @@ func (recorder *snapshotRecorder) pruneEmptyDirs() { if relative == ".git" || relative == ".senior-dev" { return filepath.SkipDir } + if util.PathIgnoredAtStart(relative, startPaths) || recorder.startRules.ignored(relative, true) { + return filepath.SkipDir + } dirs = append(dirs, name) return nil }) From cee995f11e1a95601447553c2c4814532fd5aac0 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 14:26:30 -0400 Subject: [PATCH 175/195] manual: the general task pages stop denying senior-dev's own ceiling tasks.md said a task has no dollar limit of its own and models-and-cost.md that a task is never stopped on its own dollar count; both now say that of an ordinary /task and point to senior-dev's run ceiling (0 and 3h by default). The senior-dev page states the restore's deletion manifest, the estimated-cost refusal as a limit, and each platform's boundary on background processes. Review of #1488, verification finding V1.7. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 1 + internal/manual/chat/commands.md | 6 ++++-- internal/manual/chat/models-and-cost.md | 9 +++++---- internal/manual/chat/senior-dev.md | 22 ++++++++++++++++++---- internal/manual/chat/tasks.md | 4 +++- internal/manual/chat_test.go | 13 +++++++++++++ 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index b55351ca4..ae326aaf5 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -61,3 +61,4 @@ an offline machine with no cache uses conservative model limits and still runs. - C7: Python unittest projects without pytest now run unittest discovery; projects that have or declare pytest keep it. - E1: A senior-dev run's `ended` card now stands in the conversation outside the chat wake turn's `worked` fold, even when its task page was open at landing; the chat's own work still folds. - E2: The fixed and lean prefix tests now weigh the same declared senior-dev guide and tool shape on every build platform. The guide says the same work in eight fewer bytes, keeping the existing 57,124 and 49,590 byte caps after the previously recorded measurement proved eight bytes short on Linux. +- F1–F5: Restores now rescue newly ignored files and record later deletions, estimated model-call refusals land as cost limits, Linux engine descendants end even after clearing their environment, ignored-folder start receipts name the reason for no branch, and the general task manual distinguishes senior-dev's run ceiling. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index e5d339cb1..30eac94d4 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -825,8 +825,10 @@ the same row the Spending tab writes through. The row names it takes are **`day`** (`daily`, `today`), **`conversation`** (`chat`, `session`), **`plan`** (`plans`, `ask`) and **`practice`** — the four rows that can be -edited. There is deliberately **no `/budget task`**: a task has no dollar limit of its -own, so a command that accepted one would be writing a number nothing reads. +edited. There is deliberately **no `/budget task`**: an ordinary `/task` has no dollar +limit of its own, so a command that accepted one would write a number nothing reads. +senior-dev has a separate ceiling for each run; see its page for the shell flags and +conversation limits that can lower it. A write says back what it landed, in the tab's own words for that row — `per day · $50`, or `per day · no limit`. A figure it cannot read is refused in the row's own words with diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 0d54fd00d..8597df404 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2807,7 +2807,7 @@ They live on **one tab**: `/settings` → **Spending**, which `/budget` opens di | **per day** | `$500` | new work waits for midnight or for you to raise it here | | **per conversation** | `no limit` | this conversation stops starting new turns; the turn in flight always finishes | | **per plan** | `asks first above $100` | a planned job estimated above it quotes its step count and its price and waits for your go-ahead — it asks, it does not stop | -| **per task** | `no limit of its own` | nothing of its own; a task spends against the day and this conversation | +| **per task** | `no limit of its own` | an ordinary `/task` spends against the day and this conversation; senior-dev has its own run ceiling | | **per standing run** | `$5 a firing` | that one firing stops there; each order may name its own | | **practice** | `$50 of the day` | codeaf's practice on itself stops until tomorrow, and your own work is untouched | @@ -2948,9 +2948,9 @@ The row was called `ask before spending` when it lived on the Workspace tab, and setting key behind it is still `plan_consent_usd` — the panel's search matches the key as well as the label, so typing either finds it. -## What may a task spend — a task has no dollar limit of its own +## What may an ordinary /task spend — senior-dev has its own run ceiling -**A task carries no dollar cap of its own.** The Spending tab says so on the `per task` +**An ordinary `/task` run carries no dollar cap of its own.** The Spending tab says so on the `per task` row, in those words: `no limit of its own`, with the dim receipt `it spends against the day and this conversation`. @@ -2963,7 +2963,8 @@ on the same tab. **What you get instead of a per-task limit is seeing it happen.** The `$` on the status line counts what the tasks are spending while they are spending it, and `/cost` splits that figure into `conversation` and `tasks`. A task is bounded by the wallet and watched on the -row — it is never stopped on its own dollar count. +row — an ordinary `/task` is never stopped on its own dollar count. A senior-dev +run has a separate dollar and time ceiling; its own page names the defaults and flags. So **there is no per-task money row to edit**, and `/budget task 20` is not a shape this command takes. Where you *can* put a figure on one piece of work is the **composer layer**: diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index bd73452d8..4bcea731b 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -275,8 +275,14 @@ model limits and still calls models through codeaf's loopback API. ## What happens to background commands after senior-dev ends — stop and detached processes -codeaf ends background processes that senior-dev's shell started when the run ends or -you stop it. On macOS, a process that detaches itself may outlive the run. +On Linux, senior-dev's engine ends every process its shell started when the run ends, +you stop it, or it reaches a ceiling, including detached processes that clear their +environment; the engine follows its own process tree. If the engine itself +is killed with SIGKILL, codeaf makes a best-effort sweep for children that kept its +private launch marker. On macOS, a process that detaches itself may outlive the run. +On Windows, senior-dev is unavailable. +If Linux cannot enable the engine's subreaper, the run refuses before starting +work: `senior-dev cannot contain its shell processes on this machine: <error>`. ## How does senior-dev run Python tests — pytest, unittest, missing pytest @@ -320,6 +326,8 @@ plain folder. codeaf does not widen it to the enclosing repository, cut or delete a branch there, or commit any of its files. What senior-dev writes stays in the folder. The ending says `its work is in <folder>; git ignores this folder inside <repo>, so codeaf cut no branch and nothing was committed`. +The start receipt says `git ignores this folder inside <repo>, so codeaf cuts no +branch there and commits nothing`. ## 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 @@ -351,11 +359,14 @@ Yes. In a git repository, edits saved before senior-dev submits can join its tas branch's commits; the ending commit includes non-ignored files left in the folder. In a plain folder they stay in place, with no commit. If a submitted change or an earlier checkpoint has to be restored, codeaf first copies every changed tracked -file and new non-ignored file that restore would replace into a rescue folder under +file and new file not ignored when the run started that restore would replace into a rescue folder under codeaf's state root, outside your project. The submitted candidate is then put back. The ending says exactly: `Files that changed in the folder before senior-dev restored its checkpoint were set aside in <path>`. The path holds the bytes as they -were before the restore; a later restore in the same run has its own subfolder. +were before the restore. A tracked file deleted after submission is named in +`deleted-files.txt` there, and the ending names that manifest. A later restore in +the same run has its own subfolder. With nothing to rescue, no folder is created +or named. Files git ignored when the run started are not committed even if senior-dev changes `.gitignore`. Python `__pycache__/`, `.pytest_cache/` and `*.pyc` files made by its checks are not committed either. Those files stay in your folder. @@ -495,6 +506,9 @@ 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`. +An estimated call may be refused while the metered spend is still below the ceiling; +that ending is still a dollar limit, its line gives the metered spend, and codeaf +refuses an automatic re-hand-off until you ask for one. When a dollar or time limit ends the run, the conversation also gets a line naming the limit, what the run spent and the branch or folder holding its work, even if that limit prevents the chat from making a wake call. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 99c173d1c..3cf27fba7 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2263,11 +2263,13 @@ proposal card and the model's own proposals run under the conversation's own lim you set it — and under the day's limit above it. An adaptive run they start opens on the $100.00 default. -**A task has no dollar limit of its own**, which the Spending tab says on its `per task` +**An ordinary `/task` run has no dollar limit of its own**, which the Spending tab says on its `per task` row in those words: `no limit of its own · it spends against the day and this conversation`. Its own bounds are steps and time. The composer layer's third line is the one place a figure is put on a single piece of work, and there is no per-task money row to edit anywhere in settings. +**senior-dev has its own dollar and time ceilings for each run**, separate from that +`per task` row; see the senior-dev page for its defaults and how to change them. Changing the engine's default changes the figure the composer layer opens on; the two are meant to be one number and are stated in both places on purpose. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index ab643900b..0ab5a5e78 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -939,6 +939,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"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"}, + {"does a senior-dev run have its own dollar limit", "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"}, @@ -2942,6 +2943,18 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { } } +// The ordinary task's Spending row stays as written, but each general page +// must point to the separate ceiling before it can answer for senior-dev. +func TestGeneralTaskCostPagesNameSeniorDevCeiling(t *testing.T) { + pages := flatChatPages(t) + for _, name := range []string{"tasks", "models-and-cost", "commands"} { + page := pages[name] + if !strings.Contains(page, "ordinary `/task`") || !strings.Contains(page, "senior-dev") || !strings.Contains(page, "ceiling") { + t.Errorf("%s does not distinguish the ordinary task row from senior-dev's ceiling", name) + } + } +} + // TestC13UpdateQuestionsReachTheNewManualSection proves C13. func TestC13UpdateQuestionsReachTheNewManualSection(t *testing.T) { for _, asked := range []string{ From 7f9f56637b5ea26a51020c26d61cf8ca6fc95759 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 14:27:25 -0400 Subject: [PATCH 176/195] changes: write the review round's fixes as what was true and what is true now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix round had left a "Fix round" section keyed by review labels (C1, E2, F1–F5) under the entry. Each item is now an invalidates line in the entry's own shape, saying what somebody may still believe and what is true instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 25 +++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index ae326aaf5..f2bdc72ea 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -44,21 +44,20 @@ invalidates: - "A senior-dev run stopped on a limit could leave only a silent ended row when the same limit blocked the chat's wake turn. The conversation now receives an authored line naming the limit, spend and branch or folder before any model wake; an open window paints the same line from the standing lane." - "The two automatic senior-dev re-hand-offs were counted only in the wake turn. The cap and the refusal after a limit now persist through later non-person turns and reopening the conversation, until the person speaks; silence approval counts as an automatic hand-off." - "The run's commit could credit the configured worker model even when another model answered every call. Chat and shell commits now credit only models recorded as answering in the run's loopback log, and a run with no answered call adds no model trailer." + - "The commands senior-dev's model ran inherited the host's `TMUX`, every provider key but the default one, and the loopback model API's token. They now get the chat bash's scrubbed shell (`exec.JobShellEnv`), with every key variable codeaf knows and the token removed; only the engine process holds the token." + - "A process senior-dev's bash started with `&` or `setsid` kept running after the run ended or was stopped. On Linux the engine is now a child subreaper that kills and reaps its whole process tree before it exits, however a command changed its session or environment; macOS kills the groups the bash tool tracked, and the manual states that platform's limit." + - "A child speaking another protocol version still had its steps written to the task trajectory. It is now refused, in one sentence naming both versions, before any of its records land." + - "A machine with no cached catalog that could not reach models.dev had every senior-dev run refused with `model catalog: context deadline exceeded`. The run now starts with conservative model limits, and the manual names the models.dev request, web fetch (on by default) and search (opt-in)." + - "senior-dev offered the model a `question` tool whose every call was rejected. It is absent from a hosted run's tools." + - "senior-dev's verifier always ran `python3 -m pytest`, so a correct fix in a unittest project on a machine without pytest ended as not finished. It now runs unittest discovery unless pytest is importable or declared." + - "A senior-dev run's `ended` card landed inside the chat's wake-turn `worked` fold and could be seen only by opening it. It now stands in the conversation on the first frame, in the same place on reopen." + - "The prompt-size tests filled the programs paragraph from the build's registry, so a build with no program weighed 550 bytes less, and #1488's recorded caps sat eight bytes under this measurement on Linux. They now weigh a declared senior-dev guide, the guide is eight bytes shorter, and the caps (57,124 and 49,590) did not move." + - "The rescue before a restore missed a file the person had just added an ignore line for, and silently recreated a tracked file the person deleted. It now accounts for every path changed since submission against the ignore rules recorded at the start, copies changed and new files, lists deletions in a manifest, and makes no rescue folder when there is nothing to keep." + - "A call the loopback API refused at the estimated ceiling ended the run as a crash, so no limit line was written and the chat could hand the work back. It now ends the run on its cost (or time) limit whatever the metered spend." + - "A run in a gitignored folder inside a repository said at its start that the repository holds the home folder. The start receipt now says the folder is ignored there, so the run works in place without a branch." + - "The general task pages said a task has no dollar limit of its own and is never stopped on its own dollar count. They now say that of an ordinary `/task` and point to senior-dev's run ceiling." --- `docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate` is its specification in Go. senior-dev reads models.dev for its catalog when available; an offline machine with no cache uses conservative model limits and still runs. - -## Fix round - -- C1: Provider-key environment variables, configured custom-service key variables and the loopback token are stripped from senior-dev's model-written shell; its commands share the chat bash's tmux isolation. -- C2: Background commands, including Linux processes detached with setsid, are killed when a run ends, stops or its engine crashes. -- C3: A child speaking another record protocol version is refused before any of its steps enter the trajectory. -- C4: An unavailable models.dev catalog no longer refuses a run; conservative model limits keep the loopback model API usable. -- C5: A hosted senior-dev run no longer offers `question` when nobody can answer it. -- C6: The manual now names senior-dev's default web fetch, opt-in Exa and Parallel search, models.dev request, and macOS detached-process limit. -- C7: Python unittest projects without pytest now run unittest discovery; projects that have or declare pytest keep it. -- E1: A senior-dev run's `ended` card now stands in the conversation outside the chat wake turn's `worked` fold, even when its task page was open at landing; the chat's own work still folds. -- E2: The fixed and lean prefix tests now weigh the same declared senior-dev guide and tool shape on every build platform. The guide says the same work in eight fewer bytes, keeping the existing 57,124 and 49,590 byte caps after the previously recorded measurement proved eight bytes short on Linux. -- F1–F5: Restores now rescue newly ignored files and record later deletions, estimated model-call refusals land as cost limits, Linux engine descendants end even after clearing their environment, ignored-folder start receipts name the reason for no branch, and the general task manual distinguishes senior-dev's run ceiling. From be9ee9f1fd53a0a2e6950a1fee548946a3780e65 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:02:03 -0400 Subject: [PATCH 177/195] senior-dev: a commit the model makes itself is the run's, and carries its credit In a real run the model ran git reset --soft main and git commit through its own bash. That commit took the person's git identity from the repository and had no Assisted-by, because codeaf signs only the finishing commit it makes itself and there was nothing left to stage. The model's shell commands now carry the same author and committer identity codeaf's run commits use (one identity, now in internal/gitidentity), and when the finish has nothing to stage and the tip of the run's branch is a commit the run made without the credit, codeaf amends that tip's message to add it, keeping its tree. A commit that existed before the run is never amended. Review of #1488, verification finding V2.3. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/gitidentity/identity.go | 19 +++++ internal/seniordev/tool/shell_scratch.go | 4 + internal/session/program_model_commit_test.go | 79 +++++++++++++++++++ internal/session/programfolder.go | 41 +++++++++- internal/session/task_branch_protection.go | 6 +- 5 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 internal/gitidentity/identity.go create mode 100644 internal/session/program_model_commit_test.go diff --git a/internal/gitidentity/identity.go b/internal/gitidentity/identity.go new file mode 100644 index 000000000..dc0217956 --- /dev/null +++ b/internal/gitidentity/identity.go @@ -0,0 +1,19 @@ +// Package gitidentity holds the signature shared by codeaf's own commits and +// commands a program runs on its behalf. +package gitidentity + +const ( + Name = "codeaf" + Email = "agentfield-bot@users.noreply.github.com" +) + +// Environment makes Git attribute a model-written commit to the run even +// when the repository carries the person's user.name and user.email. +func Environment() []string { + return []string{ + "GIT_AUTHOR_NAME=" + Name, + "GIT_AUTHOR_EMAIL=" + Email, + "GIT_COMMITTER_NAME=" + Name, + "GIT_COMMITTER_EMAIL=" + Email, + } +} diff --git a/internal/seniordev/tool/shell_scratch.go b/internal/seniordev/tool/shell_scratch.go index 083ef2203..1922948f2 100644 --- a/internal/seniordev/tool/shell_scratch.go +++ b/internal/seniordev/tool/shell_scratch.go @@ -17,6 +17,7 @@ import ( "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/env" jobexec "github.com/Agent-Field/codeaf/internal/exec" + "github.com/Agent-Field/codeaf/internal/gitidentity" "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" ) @@ -216,6 +217,9 @@ func shellEnvironment(sessionID string) []string { // The engine needs this run's loopback token, but a model command does not. // The chat's shared shell policy also isolates this command's tmux socket. environment := jobexec.JobShellEnv(env.EnvironWithout(delegate.EnvModelToken, delegate.EnvModelAPI)) + // A model's own git commit belongs to the run even when the repository + // has the person's identity configured for their separate commits. + environment = append(environment, gitidentity.Environment()...) // Appended after os.Environ() so exec's last-entry-wins dedup overrides // any proxy the parent carries; independent of the shared-cache early // return below, which must not open the network gate. diff --git a/internal/session/program_model_commit_test.go b/internal/session/program_model_commit_test.go new file mode 100644 index 000000000..145cc58fd --- /dev/null +++ b/internal/session/program_model_commit_test.go @@ -0,0 +1,79 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +// A command the model writes and the finishing hand must give the same run +// credit, without rewriting a commit that was present before the run. +func TestProgramModelCommitHasRunIdentityAndFinishingCredit(t *testing.T) { + repo := newTestRepo(t) + mustGit(t, repo, "config", "user.name", "Person") + mustGit(t, repo, "config", "user.email", "person@example.test") + base := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + before := gitOut(t, repo, "show", "-s", "--format=%an <%ae>|%cn <%ce>|%B", base) + folder, err := PrepareProgramFolder(ProgramFolderOrder{ + Program: testPrograms("fake")[0], Dir: repo, Title: "Model's work", + Holder: "task 9 (Model's work)", Keep: t.TempDir(), SignModel: "z-ai/glm-5.3-flash", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(folder.release) + registry := tool.New(repo) + defer registry.CloseShellProcesses() + input, _ := json.Marshal(map[string]any{"command": "printf 'model work\\n' > model.txt && git add model.txt && git commit -m 'model work'"}) + result, err := registry.Execute(context.Background(), steploop.ToolCall{ID: "model-commit", Name: "bash", Input: input}) + if err != nil { + t.Fatalf("model bash commit: %v, %+v", err, result) + } + tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + if tip == base { + t.Fatalf("the model command made no commit: %s", result.Output) + } + identity := strings.TrimSpace(gitOut(t, repo, "show", "-s", "--format=%an <%ae>|%cn <%ce>", tip)) + wantIdentity := codeafGitName + " <" + codeafGitEmail + ">|" + codeafGitName + " <" + codeafGitEmail + ">" + if identity != wantIdentity { + t.Fatalf("model commit identity = %q, want %q", identity, wantIdentity) + } + tree := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")) + folder.Finish("completed the change") + message := gitOut(t, repo, "show", "-s", "--format=%B", "HEAD") + if !strings.Contains(message, "Assisted-by:") || !strings.Contains(message, "glm-5.3-flash") { + t.Fatalf("model commit lacks answered-model credit: %q", message) + } + if after := gitOut(t, repo, "show", "-s", "--format=%an <%ae>|%cn <%ce>|%B", base); after != before { + t.Fatalf("pre-run commit changed:\nbefore %q\nafter %q", before, after) + } + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")); after != tree { + t.Fatalf("finishing credit changed the model's tree from %s to %s", tree, after) + } + if _, err := git(repo, "merge-base", "--is-ancestor", base, "HEAD"); err != nil { + t.Fatal("finishing credit lost the person's pre-run commit from history") + } +} + +// A run with nothing to commit cannot attach its credit to the person's +// commit that was already at the tip when the run began. +func TestProgramFinishDoesNotAmendThePreRunTip(t *testing.T) { + repo := newTestRepo(t) + base := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + folder, err := PrepareProgramFolder(ProgramFolderOrder{ + Program: testPrograms("fake")[0], Dir: repo, Title: "No change", + Holder: "task 9 (No change)", Keep: t.TempDir(), SignModel: "z-ai/glm-5.3-flash", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(folder.release) + folder.Finish("no change") + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); tip != base { + t.Fatalf("finishing an empty run amended the pre-run tip: %s → %s", base, tip) + } +} diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index d677a61bb..eebb01063 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -816,7 +816,7 @@ func (f *ProgramFolder) commitLeftovers(result string) string { } } if _, err := git(f.Dir, "diff", "--cached", "--quiet"); err == nil { - return "" + return f.creditModelCommit() } message := clip(firstLine(f.Title), 72) if strings.TrimSpace(message) == "" { @@ -838,6 +838,45 @@ func (f *ProgramFolder) commitLeftovers(result string) string { return "" } +// creditModelCommit signs a model's own final commit when there are no loose +// changes for a finishing commit. It leaves the run's base and any commit the +// person authored on the run branch alone. +func (f *ProgramFolder) creditModelCommit() string { + if f.NoAttribution || f.Start == "" { + return "" + } + tip := branchCommit(f.Dir, f.Branch) + if tip == "" || tip == f.Start { + return "" + } + if _, err := git(f.Dir, "merge-base", "--is-ancestor", f.Start, tip); err != nil { + return "" + } + identity, err := git(f.Dir, "show", "-s", "--format=%an%x00%ae%x00%cn%x00%ce", tip) + if err != nil { + return "git identity: " + firstLine(identity) + } + parts := strings.Split(strings.TrimSpace(identity), "\x00") + if len(parts) != 4 || parts[0] != codeafGitName || parts[1] != codeafGitEmail || + parts[2] != codeafGitName || parts[3] != codeafGitEmail { + return "" + } + message, err := git(f.Dir, "show", "-s", "--format=%B", tip) + if err != nil { + return "git message: " + firstLine(message) + } + if strings.Contains(message, "Assisted-by:") { + return "" + } + message = signed(strings.TrimRight(message, "\n"), gitSignature{named: f.SignModel != "", model: f.SignModel}) + args := append([]string{"-c", "commit.gpgsign=false"}, codeafGitIdentity()...) + args = append(args, "commit", "--amend", "-q", "--no-verify", "-m", message) + if out, err := git(f.Dir, args...); err != nil { + return "git amend: " + firstLine(out) + } + return "" +} + func (f *ProgramFolder) excludedFromCommit(path string) bool { if f.Notes != "" && (path == f.Notes || strings.HasPrefix(path, strings.TrimSuffix(f.Notes, "/")+"/")) { return true diff --git a/internal/session/task_branch_protection.go b/internal/session/task_branch_protection.go index ad36d52ec..6d1d6ae14 100644 --- a/internal/session/task_branch_protection.go +++ b/internal/session/task_branch_protection.go @@ -5,11 +5,13 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/Agent-Field/codeaf/internal/gitidentity" ) const ( - codeafGitName = "codeaf" - codeafGitEmail = "agentfield-bot@users.noreply.github.com" + codeafGitName = gitidentity.Name + codeafGitEmail = gitidentity.Email // Legacy identities codeaf's task commits were once authored with. Both stay // recognised by taskCommitIdentity so older work still lands as the task From 91e6d17efde87689ddfaa4729a710f722b85677d Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:02:03 -0400 Subject: [PATCH 178/195] tui3: pin a senior-dev landing that arrives after the chat's wake reply The missing ended card seen on one real engine-road run did not reproduce once the landing stood outside the work fold (d2415a871): through the proposal road with a stub model the card showed before and after the wake, with the task page open or not, and after reopening. This pins the order that test did not cover, a landing delivered after the chat has already answered, and that it survives a reopen. Review of #1488, verification finding V2.4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/tui3/senior_dev_landing_fold_test.go | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/internal/tui3/senior_dev_landing_fold_test.go b/internal/tui3/senior_dev_landing_fold_test.go index 8f5e37c03..9c6a229ae 100644 --- a/internal/tui3/senior_dev_landing_fold_test.go +++ b/internal/tui3/senior_dev_landing_fold_test.go @@ -86,6 +86,36 @@ func TestSeniorDevNoHostLandingStandsOutsideWakeFold(t *testing.T) { } } +// The engine can deliver the settled wake reply before its task subscription +// sends the landing. That order must still leave a visible, durable card. +func TestSeniorDevLandingAfterWakeReplyStaysVisible(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.width, a.height = 110, 40 + a.workMode = config.WorkFold + a.entries = []entry{ + {kind: entryUser, text: "start senior-dev", turn: 1}, + {kind: entryAssistant, text: "I will report when it finishes.", turn: 1, settled: true}, + {kind: entryThinking, text: "reading the ending", turn: 2, settled: true}, + {kind: entryAssistant, text: "The run is done.", turn: 2, settled: true}, + } + a.turn = 2 + a.Update(taskEventMsg{gen: a.taskGen, ev: update(7, "Repair the parser", session.TaskRunning, + session.TaskNotice{Program: "senior-dev"})}) + a.Update(taskEventMsg{gen: a.taskGen, ev: update(7, "Repair the parser", session.TaskDone, + session.TaskNotice{Program: "senior-dev", Report: "submitted a change"})}) + if got := taskText(a); !strings.Contains(got, "senior-dev's ending went to the chat") { + t.Fatalf("landing behind the settled wake disappeared:\n%s", got) + } + reopened := newTestApp(&fakeAgent{model: "m"}) + reopened.width, reopened.height = 110, 40 + reopened.workMode = config.WorkFold + reopened.entries = append([]entry(nil), a.entries...) + reopened.touch() + if got := taskText(reopened); !strings.Contains(got, "senior-dev's ending went to the chat") { + t.Fatalf("reopening hid a landing delivered after the wake:\n%s", got) + } +} + // An ordinary task still writes its landing after the starting turn and before // the later chat turn, as it did before the program card needed a fold boundary. func TestOrdinaryTaskLandingKeepsItsConversationPosition(t *testing.T) { From 44444f7601951ff2e871e13e851ef05945112ff7 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:02:03 -0400 Subject: [PATCH 179/195] session, tui3, remote: /budget binds the open conversation before it says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /budget conversation 1.5 wrote the profile and answered "per conversation · $1.50", but the open agent kept the spend rail it was launched with, so the next chat turn, ordinary task and senior-dev run in that conversation used the old figure; a senior-dev card showed $10 beside the chat's $1.50. The agent now publishes a live override that every rail reader uses, the engine road carries it over a new remote call, and /budget and the settings panel wait for the bind before they show the new reading. When the bind fails (an older engine host that does not know the call), the receipt says the saved figure applies to the next conversation. /budget has no time row, and the manual no longer says it changes a run's wall-clock limit. Review of #1488, verification finding V2.2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/config/settings.go | 2 +- internal/manual/chat/delegates.md | 5 +- internal/manual/chat/senior-dev.md | 12 +++- internal/remote/client.go | 7 ++ internal/remote/server.go | 11 ++++ internal/remote/spend_rail_test.go | 34 ++++++++++ internal/remote/wire.go | 1 + internal/session/agent.go | 2 +- internal/session/rail.go | 23 ++++++- internal/session/senior_dev_limits_test.go | 56 ++++++++++++++++ internal/session/session.go | 7 +- internal/tui3/app.go | 2 + internal/tui3/budget.go | 46 +++++++++++++ internal/tui3/budget_live_test.go | 75 ++++++++++++++++++++++ internal/tui3/moneydoor.go | 9 ++- internal/tui3/settings.go | 38 ++++++++--- 16 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 internal/remote/spend_rail_test.go create mode 100644 internal/tui3/budget_live_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index 76b25d399..6e646b49e 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2406,7 +2406,7 @@ func (s *Settings) build() []Setting { Hint: "what one conversation may spend before it stops starting new turns. " + "When it is reached the next turn is refused and your message is still " + "yours to send again once you raise it; the turn in flight always " + - "finishes. Say none for no limit. A change lands on the next session.", + "finishes. Say none for no limit. A change binds this conversation before the row confirms it.", read: func() string { return moneyValue(SpendRailUSDAt(dir)) }, write: func(raw string) error { return writeDollars(dir, KeySpendRail, raw) }, receipt: s.spentThisSessionReceipt, diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md index 0e4797acb..b5b671f9d 100644 --- a/internal/manual/chat/delegates.md +++ b/internal/manual/chat/delegates.md @@ -141,7 +141,10 @@ everything it would stop and ask is already settled. The model is told the same it proposes one. **It has no step cap.** senior-dev has finite dollar and wall-clock ceilings even when -the conversation sets none; `/budget` can lower them, and shell flags set them directly. +the conversation sets none; `/budget conversation` can lower the dollar ceiling, +and shell flags set either ceiling directly. +An open chat's `/budget conversation` change binds its next proposal, run and turn +as soon as the setting receipt appears. Before forwarding a call, codeaf reserves the larger estimate from the requested model and its possible fallback seat when both have known prices, using input size and output cap; if either price is unknown, it uses the unpriced bound. It refuses a call whose diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 4bcea731b..f1aae11ff 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -237,10 +237,12 @@ cross) and `senior-dev's ending went to the chat`; the chat's own reply is where what came of the work. `ctrl+o` on the card still shows senior-dev's own words. **It has no step cap.** Every run has finite dollar and wall-clock ceilings: by default, -**up to $10.00 and 3h**. A conversation's `/budget` limits can lower either ceiling to +**up to $10.00 and 3h**. `/budget conversation` can lower the dollar ceiling to what remains. At a shell, `--max-cost` and `--max-hours` set either ceiling explicitly. The proposal card, typed command's start note and shell run's first line say which ceiling applies. These ceilings are enforced outside senior-dev whatever it does. +`/budget conversation 1.5` in an open chat binds $1.50 to that conversation before +its receipt appears, so the next approval card and run use $1.50 or what remains. **It reaches a model only through codeaf.** Its engine receives a short-lived token for codeaf's loopback model API, but its model-written shell commands inherit neither that @@ -407,6 +409,10 @@ The finishing commit's model credit names only models recorded as answering a ca that run, including a model that answered in place of the one asked for. If no model answered, there is no `Assisted-by` trailer. The attribution setting still decides whether answered model names are shown. +If senior-dev runs `git commit` itself, the commit uses codeaf's run identity rather +than your Git identity. When that commit is the branch tip and there is nothing +left to stage, codeaf adds the answered model's `Assisted-by` credit to its message +without changing its files. It never rewrites a commit from before the run. 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 @@ -541,8 +547,8 @@ price does not mean the service charged nothing. codeaf reserves half the run's dollar ceiling for a call with no known model price and admits at most one such call in flight once the recorded spend reaches half the ceiling. This limits simultaneous calls but cannot say what an unpriced service actually charged. -The run's wall-clock ceiling still ends it; set a different one with `/budget` or -`--max-hours` if you need a shorter or longer run. +The run's wall-clock ceiling still ends it; use `--max-hours` at the shell +if you need a shorter or longer run. ## Why a stopped senior-dev run takes a moment to end — the price of the call it was in the middle of diff --git a/internal/remote/client.go b/internal/remote/client.go index 81a375900..fd21daf73 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1782,6 +1782,13 @@ func (a *Agent) SetModel(model string) { _, _ = a.c.call(nil, MethodSetModel, model) } +// SetSpendRail waits for the engine to bind the new conversation limit before +// a setting receipt can claim that the open chat has it. +func (a *Agent) SetSpendRail(usd float64) error { + _, err := a.c.call(nil, MethodSetSpendRail, usd) + return err +} + // SetContextWindow is deliberately a no-op here. The surface's catalog belongs // to the laptop; SetModel makes the engine consult its own catalog and move its // own compaction point. The method remains on the interface for local agents diff --git a/internal/remote/server.go b/internal/remote/server.go index 9ea7c4965..8aa46121e 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2548,6 +2548,17 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { s.session.announce() return nil, nil + case MethodSetSpendRail: + usd, err := arg[float64](call) + if err != nil { + return nil, err + } + binder, ok := agent.(interface{ SetSpendRail(float64) error }) + if !ok { + return nil, errors.New("conversation limit cannot be changed here") + } + return nil, binder.SetSpendRail(usd) + case MethodSetContext: tokens, err := arg[int](call) if err != nil { diff --git a/internal/remote/spend_rail_test.go b/internal/remote/spend_rail_test.go new file mode 100644 index 000000000..05038d96d --- /dev/null +++ b/internal/remote/spend_rail_test.go @@ -0,0 +1,34 @@ +package remote + +import ( + "testing" +) + +type spendRailAgent struct { + *fakeAgent + rail float64 +} + +func (a *spendRailAgent) SetSpendRail(usd float64) error { + a.rail = usd + return nil +} + +// The normal engine road sends the live limit through its real protocol before +// the surface can report that the conversation now holds it. +func TestOpenEngineRoadBindsConversationSpendRail(t *testing.T) { + far := &spendRailAgent{fakeAgent: &fakeAgent{model: "m"}} + 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() }) + if err := loop.Client.Agent().SetSpendRail(1.5); err != nil { + t.Fatal(err) + } + if far.rail != 1.5 { + t.Fatalf("engine holds $%.2f, want $1.50", far.rail) + } +} diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 96c17e2b4..071de7a31 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -469,6 +469,7 @@ const ( MethodClose = "Close" // nothing → nothing MethodModel = "Model" // nothing → string MethodSetModel = "SetModel" // string → nothing + MethodSetSpendRail = "SetSpendRail" // dollars → nothing MethodSetContext = "SetContextWindow" // legacy version-5 hint; current remote surfaces do not send it MethodReasoningFor = "ReasoningFor" // string → string MethodSetReasoningFor = "SetReasoningFor" // ReasoningArgs → nothing diff --git a/internal/session/agent.go b/internal/session/agent.go index 3fa674444..b15dd4b4f 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2919,7 +2919,7 @@ func (a *Agent) bashBeltFrame(hub *eventHub) string { rail := 0.0 if home := graph.home; home != nil { home.mu.Lock() - rail = home.config.SpendRailUSD + rail = home.spendRailUSD() home.mu.Unlock() } runSpend := graph.planRunSpend() diff --git a/internal/session/rail.go b/internal/session/rail.go index bb6b27b1f..c99571e24 100644 --- a/internal/session/rail.go +++ b/internal/session/rail.go @@ -28,8 +28,27 @@ package session import ( "errors" "fmt" + "math" ) +// SetSpendRail binds a setting written in an open chat before the next turn +// or delegated run reads the ceiling. In-flight work keeps its admitted limit. +func (a *Agent) SetSpendRail(usd float64) error { + if usd < 0 || math.IsNaN(usd) || math.IsInf(usd, 0) { + return fmt.Errorf("conversation limit must be a finite non-negative amount") + } + a.liveSpendRail.Store(math.Float64bits(usd)) + a.liveSpendRailSet.Store(true) + return nil +} + +func (a *Agent) spendRailUSD() float64 { + if a.liveSpendRailSet.Load() { + return math.Float64frombits(a.liveSpendRail.Load()) + } + return a.config.SpendRailUSD +} + // ErrSpendRail is what a refused turn carries in its EventError. It is a named // sentinel so a surface can match it with errors.Is and say the one thing worth // saying — the rail, not a provider fault — instead of matching on words. @@ -41,7 +60,7 @@ func (a *Agent) railBlockLocked() error { if err := a.launchBudgetBlockLocked(); err != nil { return err } - rail := a.config.SpendRailUSD + rail := a.spendRailUSD() if rail <= 0 { return nil } @@ -105,7 +124,7 @@ func railMoney(usd float64) string { // A session with no rail changes nothing: the caller's tank is the caller's, and // zero there still means the run nobody bounded. func (a *Agent) railCap(asked float64) float64 { - rail := a.config.SpendRailUSD + rail := a.spendRailUSD() if launch := a.interactiveBudget().USD; launch > 0 && (rail <= 0 || launch < rail) { rail = launch } diff --git a/internal/session/senior_dev_limits_test.go b/internal/session/senior_dev_limits_test.go index fa0f5ee63..b62f3fa7e 100644 --- a/internal/session/senior_dev_limits_test.go +++ b/internal/session/senior_dev_limits_test.go @@ -36,6 +36,62 @@ func TestSeniorDevRunUsesRemainingConversationLimitsBelowDefaults(t *testing.T) } } +// A limit set after the agent opened bounds the next run and the next turn +// from the same conversation, rather than only the next conversation. +func TestAnOpenConversationUsesItsNewSpendRailForRunsAndTurns(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + if err := a.SetSpendRail(1.5); err != nil { + t.Fatal(err) + } + program := testPrograms("senior-dev")[0] + spec := a.beltRunSpec(&beltRun{delegate: &program}, "repair it") + if spec.CostUSD != 1.5 { + t.Fatalf("next senior-dev run has $%.2f, want $1.50", spec.CostUSD) + } + if got := a.railCap(4); got != 1.5 { + t.Fatalf("ordinary task tank has $%.2f, want $1.50", got) + } + a.mu.Lock() + a.usage.CostUSD = 1.5 + err := a.railBlockLocked() + a.mu.Unlock() + if err == nil || !strings.Contains(err.Error(), "$1.50") { + t.Fatalf("next chat turn was not stopped at the new limit: %v", err) + } +} + +// The approval card is built while the conversation lock is held. A live +// limit must be readable there without waiting for that same lock again. +func TestSeniorDevApprovalCardUsesLiveLimitWithoutWaitingOnItsOwnLock(t *testing.T) { + a := programConversation(t, nil) + if err := a.SetSpendRail(1.5); err != nil { + t.Fatal(err) + } + type opened struct { + wait *taskWait + err error + } + result := make(chan opened, 1) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + go func() { + wait, err := a.openTask(ctx, 7, taskSpec{via: "senior-dev", title: "Repair parser"}, "") + result <- opened{wait: wait, err: err} + }() + select { + case got := <-result: + if got.err != nil { + t.Fatal(got.err) + } + defer got.wait.withdraw() + if got.wait.question.notice.Ceiling != "up to $1.50 and 3h" { + t.Fatalf("approval card says %q", got.wait.question.notice.Ceiling) + } + case <-time.After(time.Second): + t.Fatal("approval card waited on the conversation lock it already holds") + } +} + func TestTypedSeniorDevStartSaysItsEffectiveCeiling(t *testing.T) { double := newBeltRunDouble("submitted and verified") registerBeltRunEngine(t, double) diff --git a/internal/session/session.go b/internal/session/session.go index 816a6f23e..800d7ef01 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2254,7 +2254,12 @@ type Agent struct { discussionPending []string config Config - client Completer + // An open conversation's changed rail is separate from its launch config: + // proposal cards read it while holding mu, while runs also read it outside + // that lock. Atomic publication keeps both roads on the same figure. + liveSpendRail atomic.Uint64 + liveSpendRailSet atomic.Bool + client Completer // managedClient distinguishes the provider adapter built by New from a test // completer handed to newAgent. clientAccount is the resolved account the // adapter holds, so a service-set change can replace it before another call. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index ca3dce85b..c7edb69be 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1124,6 +1124,8 @@ type app struct { // (moneydoor.go's [app.moneyNearRail]). spendRail float64 railRead bool + // The settings panel takes this command with its key or mouse response. + spendRailBindCmd tea.Cmd // ctxWindow is the model's context in tokens as this surface last set it, // and ctxTokens what the conversation currently weighs. The pair is the // meter in the status line. The window is TRACKED rather than asked for diff --git a/internal/tui3/budget.go b/internal/tui3/budget.go index 613bdc518..e2120a936 100644 --- a/internal/tui3/budget.go +++ b/internal/tui3/budget.go @@ -1,6 +1,7 @@ package tui3 import ( + "errors" "strings" tea "charm.land/bubbletea/v2" @@ -93,6 +94,11 @@ func (a *app) budget(rest string) tea.Cmd { a.note("that limit is not on this machine") return nil } + if key == config.KeySpendRail && !a.railRead { + // Keep the active status reading on the old value until the engine + // acknowledges the newly written profile value. + a.readSpendRail() + } if err := row.Apply(amount); err != nil { // THE REFUSAL IS THE ROW'S OWN WORDS and never a second sentence about // the same rule (internal/config's writers refuse in plain language), @@ -102,12 +108,52 @@ func (a *app) budget(rest string) tea.Cmd { return nil } a.refreshSettings() + if key == config.KeySpendRail { + return a.bindSpendRail(func(err error) { + if err != nil { + a.note("saved for the next conversation · this one still has its previous limit") + return + } + a.note(budgetWord(a.registry(), key)) + }) + } // AND IT SAYS WHAT IT LANDED, in the words the tab uses for that row, because // a command that writes silently is a command a person runs twice. a.note(budgetWord(a.registry(), key)) return nil } +// bindSpendRail asks the open engine after the profile write and folds its +// answer before claiming that this conversation has the new ceiling. +func (a *app) bindSpendRail(receipt func(error)) tea.Cmd { + usd := config.SpendRailUSDAt(a.profileDir) + binder, ok := a.agent.(interface{ SetSpendRail(float64) error }) + if !ok { + err := errors.New("this conversation cannot bind a changed limit") + receipt(err) + return nil + } + return a.offLoop(func() func(bool) tea.Cmd { + err := binder.SetSpendRail(usd) + return func(here bool) tea.Cmd { + if !here { + return nil + } + if err == nil { + a.readSpendRail() + } + receipt(err) + return nil + } + }) +} + +func (a *app) takeSpendRailBindCmd() tea.Cmd { + cmd := a.spendRailBindCmd + a.spendRailBindCmd = nil + return cmd +} + // budgetWord is one row as a receipt: its label on the Spending tab, and what it // now reads. Both are read back through the registry rather than composed from // what was typed, so `/budget 0`, `/budget none` and `/budget ∞` all answer with diff --git a/internal/tui3/budget_live_test.go b/internal/tui3/budget_live_test.go new file mode 100644 index 000000000..4a2d48629 --- /dev/null +++ b/internal/tui3/budget_live_test.go @@ -0,0 +1,75 @@ +package tui3 + +import ( + "errors" + "strings" + "testing" +) + +type budgetLiveAgent struct { + *fakeAgent + rail float64 + err error +} + +func (a *budgetLiveAgent) SetSpendRail(usd float64) error { + if a.err != nil { + return a.err + } + a.rail = usd + return nil +} + +// The command that reports a conversation limit must bind the conversation +// already behind this window, including the value used by its next run. +func TestBudgetConversationBindsTheOpenChatBeforeItsReceipt(t *testing.T) { + a, _ := sheetApp(t) + engine := &budgetLiveAgent{fakeAgent: &fakeAgent{model: "openai/gpt-4.1-mini"}} + a.agent = engine + before := len(a.entries) + cmd := a.budget("conversation 1.5") + if cmd == nil { + t.Fatal("the open conversation did not receive a limit command") + } + if engine.rail != 0 { + t.Fatal("the engine call blocked the update loop") + } + if a.spendRail != 0 || !a.railRead { + t.Fatalf("the active status reading changed before the bind: %v, read=%v", a.spendRail, a.railRead) + } + if len(a.entries) != before { + t.Fatal("/budget confirmed the changed limit before the engine answered") + } + msg, ok := cmd().(doorMsg) + if !ok { + t.Fatal("the bind did not return through the door") + } + a.doorSaid(msg) + if engine.rail != 1.5 { + t.Fatalf("/budget showed a new limit but the open chat still has %v", engine.rail) + } + if a.spendRail != 1.5 { + t.Fatalf("the active status reading did not follow the bound limit: %v", a.spendRail) + } +} + +// A failed engine bind leaves the active status reading alone and says when +// the saved profile limit will apply. +func TestBudgetConversationFailedBindSaysNextConversation(t *testing.T) { + a, _ := sheetApp(t) + a.agent = &budgetLiveAgent{ + fakeAgent: &fakeAgent{model: "openai/gpt-4.1-mini"}, + err: errors.New("engine unavailable"), + } + cmd := a.budget("conversation 1.5") + if cmd == nil { + t.Fatal("the changed limit had no engine bind") + } + a.doorSaid(cmd().(doorMsg)) + if a.spendRail != 0 { + t.Fatalf("a refused bind changed the active status reading to %v", a.spendRail) + } + if len(a.entries) == 0 || !strings.Contains(a.entries[len(a.entries)-1].text, "saved for the next conversation") { + t.Fatalf("the refusal did not say when it applies: %+v", a.entries) + } +} diff --git a/internal/tui3/moneydoor.go b/internal/tui3/moneydoor.go index ef6d07586..b01edd53d 100644 --- a/internal/tui3/moneydoor.go +++ b/internal/tui3/moneydoor.go @@ -103,8 +103,8 @@ const spendTodayKey = config.KeyDailyBudget // // THE FIGURE IS HELD AND NOT READ. This is asked once per PAINT, and the rail // lives in a file — a status line that stat'd the profile sixty times a second -// is the shape PERF.md's allocation law exists to catch. It is read on the way -// in and again the moment the row is written ([app.readSpendRail]). +// is the shape PERF.md's allocation law exists to catch. A changed rail is +// read only after the open engine accepts it ([app.bindSpendRail]). func (a *app) moneyNearRail() bool { if !a.railRead { a.readSpendRail() @@ -112,9 +112,8 @@ func (a *app) moneyNearRail() bool { return a.spendRail > 0 && a.spendShown() >= a.spendRail*machineCeilingNear } -// readSpendRail takes that reading. It is called once, lazily, and again from -// the registry's own Applied seam when the row is written, so the ink follows an -// edit without the paint ever touching the disk. +// readSpendRail takes that reading once, lazily, and again after a live bind +// succeeds, so the ink follows what the engine actually uses. func (a *app) readSpendRail() { a.spendRail, a.railRead = config.SpendRailUSDAt(a.profileDir), true } diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index eba6d4b44..eef047d7d 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1234,12 +1234,6 @@ func (a *app) registry() *config.Settings { if key == config.KeyAPIKey { a.handAPIKey() } - // AND THE CONVERSATION'S OWN CEILING IS RE-READ HERE and nowhere - // else, so the status line's warm ink follows an edit without the - // paint ever touching the disk (moneydoor.go). - if key == config.KeySpendRail { - a.readSpendRail() - } }, }) return a.settings @@ -2073,7 +2067,12 @@ func formatModelRoles(pins map[string]string) string { // leaves the process: a service on the Connections tab that is not connected // yet starts the same browser trip /connect starts, and a sign-in is a thing // that reaches the network (connectcaps.go). -func (a *app) sheetKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { +func (a *app) sheetKey(msg tea.KeyPressMsg) (cmd tea.Cmd, took bool) { + defer func() { + if bind := a.takeSpendRailBindCmd(); bind != nil { + cmd = tea.Batch(cmd, bind) + } + }() if !a.at(pageSettings) { return nil, false } @@ -2455,6 +2454,11 @@ const gateNextSessionWord = "saved" + nextSessionWord // window has a conversation to push a gate into. A push from there would be a // seam called with nothing on the other end. func (a *app) applySetting(item sheetItem, raw string) { + if item.row.Key == config.KeySpendRail && !a.railRead { + // The panel's file write must not make the status line's first lazy + // read claim an active limit the engine has not accepted yet. + a.readSpendRail() + } if err := item.row.Apply(raw); err != nil { a.sheet.msg = err.Error() return @@ -2516,6 +2520,19 @@ func (a *app) applySetting(item sheetItem, raw string) { } else { note = gateNextSessionWord } + case config.KeySpendRail: + a.spendRailBindCmd = a.bindSpendRail(func(err error) { + if err != nil { + a.sheet.msg = "saved for the next conversation · this one still has its previous limit" + } else { + a.sheet.msg = "" + } + a.sheet.rows = a.sheet.registry.Rows() + a.sheet.build() + }) + if a.spendRailBindCmd != nil { + note = "applying to this conversation…" + } } a.sheet.msg = note a.sheet.rows = a.sheet.registry.Rows() @@ -2676,7 +2693,12 @@ type sheetHit struct { // sheetPress is a click inside the panel: a tab word switches tabs, a row // selects and answers, anything else does nothing. It hands back a command for // the reason [app.sheetKey] does — a sign-in reaches the network. -func (a *app) sheetPress(x, y int) tea.Cmd { +func (a *app) sheetPress(x, y int) (cmd tea.Cmd) { + defer func() { + if bind := a.takeSpendRailBindCmd(); bind != nil { + cmd = tea.Batch(cmd, bind) + } + }() if a.sheet.conn.entry != nil { // A BOX BEING TYPED INTO IS NOT A LIST. Every press is swallowed and none // of them acts — esc is the way out, which is the way out of every box on From df7f7ae2a8539e0ca42319eafc4ee39b67be7ee8 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:02:40 -0400 Subject: [PATCH 180/195] changes: the live /budget bind and the model's own commits Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index f2bdc72ea..004abed8b 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -56,6 +56,8 @@ invalidates: - "A call the loopback API refused at the estimated ceiling ended the run as a crash, so no limit line was written and the chat could hand the work back. It now ends the run on its cost (or time) limit whatever the metered spend." - "A run in a gitignored folder inside a repository said at its start that the repository holds the home folder. The start receipt now says the folder is ignored there, so the run works in place without a branch." - "The general task pages said a task has no dollar limit of its own and is never stopped on its own dollar count. They now say that of an ordinary `/task` and point to senior-dev's run ceiling." + - "`/budget conversation` in an open chat saved the figure and showed it as active while the conversation kept the limit it was opened with, so its next turn, task and senior-dev run spent against the old one. The open conversation now takes the new limit before the receipt says so, on the engine road and `--no-host`; if an older engine host cannot take it, the receipt says it applies to the next conversation." + - "A commit senior-dev's model made itself through bash took the person's git identity and carried no `Assisted-by`. The model's commands now commit as the run, and a run-made tip without the credit has it added at the finish; a commit from before the run is never amended." --- `docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate` From d866bd9ce0ed815bdedd8864a4fcf545d7f81eca Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:10:48 -0400 Subject: [PATCH 181/195] seniordev: name rescued edits in every submitted ending The ship road built its terminal before restoring the frozen candidate, so the person's later edits were saved under codeaf's state root but the shell ending, the task's end record and the chat's landing never said where. It now restores first and then builds the one terminal, with the rescue folder and the deletion manifest. A test holds the post-submit verification in a real child process under the delegate host, edits and deletes files meanwhile, and proves the path crosses the protocol into the stored end action; the failed-suite road (which already restored first) and an unchanged tree are covered too. Review of #1488, verification finding V2.1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 1 + internal/manual/chat/senior-dev.md | 3 +- .../seniordev/app/solo_restore_ending_test.go | 283 ++++++++++++++++++ internal/seniordev/app/solo_ship.go | 25 +- 4 files changed, 301 insertions(+), 11 deletions(-) create mode 100644 internal/seniordev/app/solo_restore_ending_test.go diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 004abed8b..70d68fb68 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -35,6 +35,7 @@ invalidates: - "The chat manual said a stale task page opened from another window showed a footer reading `reading`. It now quotes the footer the page draws: `current status unavailable — showing the last known state`." - "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." - "A restore after submission or a failed suite could erase later edits and new files; it now copies each differing file outside the project before restoring and names the rescue folder in the ending." + - "A submitted run built its terminal before restoring the frozen tree, so rescued concurrent edits had no location in the shell ending, task end record or chat landing. It now restores before building that terminal, and all three endings name the rescue folder when one was made." - "An eager write followed HEAD onto the person's branch, and the moved-HEAD ending denied existing task commits; eager commits now require the run's branch and the ending names committed and uncommitted work truthfully." - "Changing `.gitignore` could commit a secret ignored when the run began, and test caches entered the task commit; the start-time ignored paths and the narrow generated-path list are excluded from eager and finishing commits." - "The folder hold let workspace restore, workspace merge and unnamed generated output write inside the held folder; it now fences those writes with the other file tools." diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index f1aae11ff..b6e5e11d4 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -364,7 +364,8 @@ earlier checkpoint has to be restored, codeaf first copies every changed tracked file and new file not ignored when the run started that restore would replace into a rescue folder under codeaf's state root, outside your project. The submitted candidate is then put back. The ending says exactly: `Files that changed in the folder before senior-dev -restored its checkpoint were set aside in <path>`. The path holds the bytes as they +restored its checkpoint were set aside in <path>`. The shell ending, the task's +end record and the chat's landing all carry that path. The path holds the bytes as they were before the restore. A tracked file deleted after submission is named in `deleted-files.txt` there, and the ending names that manifest. A later restore in the same run has its own subfolder. With nothing to rescue, no folder is created diff --git a/internal/seniordev/app/solo_restore_ending_test.go b/internal/seniordev/app/solo_restore_ending_test.go new file mode 100644 index 000000000..d4b2caa2e --- /dev/null +++ b/internal/seniordev/app/solo_restore_ending_test.go @@ -0,0 +1,283 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// heldVerification puts the person's edits after verification has started, +// which is the window in which a submitted tree can diverge from its freeze. +func heldVerification(t *testing.T, workspace string, fail bool) (string, string) { + t.Helper() + gate := t.TempDir() + started := filepath.Join(gate, "started") + release := filepath.Join(gate, "release") + tail := "true" + if fail { + tail = "printf '[build failed]\\n'; exit 2" + } + makefile := fmt.Sprintf("build:\n\t@true\n\ntest:\n\t@touch %s; while test ! -f %s; do sleep 0.02; done; %s\n", started, release, tail) + if err := writeFile(filepath.Join(workspace, "Makefile"), makefile); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "delete-me.txt"), "original\n"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "add", "Makefile", "delete-me.txt"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "verification gate"); err != nil { + t.Fatal(err) + } + return started, release +} + +func releaseAfterPersonalEdits(t *testing.T, workspace, started, release, debugPath string, maximum time.Duration) { + t.Helper() + deadline := time.Now().Add(maximum) + for { + if _, err := os.Stat(started); err == nil { + break + } + if time.Now().After(deadline) { + debug, _ := os.ReadFile(debugPath) + t.Fatalf("the project's make test did not start; child notes:\n%s", debug) + } + time.Sleep(20 * time.Millisecond) + } + if err := writeFile(filepath.Join(workspace, "README.md"), "person's later edit\n"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "notes.txt"), "person new note\n"); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "delete-me.txt")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(release, nil, 0o600); err != nil { + t.Fatal(err) + } +} + +func checkRescueEnding(t *testing.T, workspace string, data map[string]any, message string) { + t.Helper() + rescue, _ := data["rescue_path"].(string) + if rescue == "" || !strings.Contains(message, "set aside in "+rescue) { + t.Fatalf("terminal data = %#v; ending = %q, want the rescue folder", data, message) + } + manifest, _ := data["rescue_manifest"].(string) + if data["rescue_deletions"] != true || manifest == "" || !strings.Contains(message, manifest) { + t.Fatalf("terminal data = %#v; ending = %q, want the deletion manifest", data, message) + } + if body, err := os.ReadFile(filepath.Join(rescue, manifest)); err != nil || string(body) != "delete-me.txt\n" { + t.Fatalf("deletion manifest = %q, %v", body, err) + } + for name, want := range map[string]string{ + "README.md": "person's later edit\n", "notes.txt": "person new note\n", + } { + got, err := os.ReadFile(filepath.Join(rescue, name)) + if err != nil || string(got) != want { + t.Fatalf("rescued %s = %q, %v; want %q", name, got, err, want) + } + } + if got, err := os.ReadFile(filepath.Join(workspace, "README.md")); err != nil || string(got) != "base\n" { + t.Fatalf("restored README = %q, %v", got, err) + } + if _, err := os.Stat(filepath.Join(workspace, "notes.txt")); !os.IsNotExist(err) { + t.Fatalf("later note remains in restored tree: %v", err) + } + if body, err := os.ReadFile(filepath.Join(workspace, "delete-me.txt")); err != nil || string(body) != "original\n" { + t.Fatalf("candidate file was not restored after the deletion: %q, %v", body, err) + } +} + +type rescueEndSink struct { + dir string + err error +} + +func (sink *rescueEndSink) Hello(delegate.Hello) {} +func (sink *rescueEndSink) Stage(delegate.StageRecord) {} +func (sink *rescueEndSink) Step(delegate.StepRecord) {} +func (sink *rescueEndSink) Terminal(terminal delegate.Terminal) { + sink.err = delegate.AppendAction(sink.dir, delegate.EndAction(time.Now(), terminal)) +} + +func rescueChildProgram() delegate.Delegate { + return delegate.Delegate{Name: "senior-dev", Default: "run", Commands: []delegate.Command{{ + Name: "run", Bind: func(*flag.FlagSet) delegate.Body { + return func(ctx context.Context, host delegate.Host, args []string) error { + host.Hello(Stages) + ending := Run(ctx, host, Options{Goal: strings.Join(args, " "), High: "openrouter/fixture/vendor-model"}, os.Stderr) + host.Terminal(ending) + return nil + } + }, + }}} +} + +// The child is the test executable, but the pipeline, process pipe, delegate +// host and HTTP model road are real. Only the model's answers are scripted. +func TestRescueShipEndingCrossesTheDelegateChild(t *testing.T) { + if os.Getenv("CODEAF_RESCUE_TEST_CHILD") == "1" { + program := rescueChildProgram() + inv, err := delegate.Parse(program, []string{"run", "--dir", os.Getenv("CODEAF_RESCUE_TEST_WORKSPACE"), "--", "Add the feature."}, io.Discard) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if delegate.RunChild(context.Background(), inv, os.Stdout) != delegate.StatusPass { + os.Exit(1) + } + os.Exit(0) + } + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + catalog, err := filepath.Abs("../modelsdev/testdata/catalog.json") + if err != nil { + t.Fatal(err) + } + t.Setenv("SENIOR_DEV_MODELS_PATH", catalog) + t.Setenv("SENIOR_DEV_DISABLE_MODELS_FETCH", "1") + var calls atomic.Int32 + stub := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + steps := []struct{ name, arguments string }{ + {"write", `{"filePath":"feature.txt","content":"implemented\n"}`}, + {"write", `{"filePath":".senior-dev/checklist.md","content":"- [x] feature implemented\n"}`}, + {"write", `{"filePath":".senior-dev/pinned.txt","content":"make test\n"}`}, + {"submit", `{"reason":"feature implemented","evidence":"make test exit 0","checklist_satisfied":true}`}, + } + index := int(calls.Add(1)) - 1 + if index > len(steps) { + http.Error(writer, "unexpected model call", http.StatusBadRequest) + return + } + writer.Header().Set("Content-Type", "text/event-stream") + if index == len(steps) { + _, _ = io.WriteString(writer, chatReply("done", 10)) + return + } + _, _ = io.WriteString(writer, toolCallReply(steps[index].name, steps[index].arguments)) + })) + defer stub.Close() + workspace, _ := guardWorkspace(t) + started, release := heldVerification(t, workspace, false) + t.Setenv("CODEAF_RESCUE_TEST_CHILD", "1") + t.Setenv("CODEAF_RESCUE_TEST_WORKSPACE", workspace) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + type answer struct { + result delegate.Result + err error + } + completed := make(chan answer, 1) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + sink := &rescueEndSink{dir: t.TempDir()} + stderr := filepath.Join(t.TempDir(), "child-stderr.log") + go func() { + result, err := delegate.Run(ctx, delegate.Launch{ + Name: "senior-dev", Bin: self, Args: []string{"-test.run=^TestRescueShipEndingCrossesTheDelegateChild$"}, + Env: delegate.ChildEnv(delegate.ModelAPI{BaseURL: stub.URL + "/v1", Token: "stub-token"}), Dir: workspace, + StderrPath: stderr, + }, sink) + completed <- answer{result, err} + }() + releaseAfterPersonalEdits(t, workspace, started, release, stderr, 75*time.Second) + finished := <-completed + if finished.err != nil { + t.Fatal(finished.err) + } + terminal := finished.result.Reading.Terminal + if terminal == nil { + t.Fatal("child sent no terminal record") + } + data := map[string]any{} + for key, raw := range terminal.Data { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + data[key] = value + } + checkRescueEnding(t, workspace, data, terminal.Message) + if got := calls.Load(); got != 5 { + t.Fatalf("model stub answered %d calls, want four tool calls and their final reply through the real model API", got) + } + if sink.err != nil { + t.Fatal(sink.err) + } + actions, err := delegate.ReadActions(sink.dir, 10) + if err != nil { + t.Fatal(err) + } + if len(actions) != 1 || actions[0].Kind != delegate.ActionEnd || !strings.Contains(actions[0].Message, data["rescue_path"].(string)) { + t.Fatalf("stored task end record = %#v", actions) + } +} + +func TestShipWithoutLaterEditsHasNoRescueSentence(t *testing.T) { + workspace := testRepoWithEntrypoints(t) + base := strings.TrimSpace(gitOutput(context.Background(), workspace, "rev-parse", "HEAD")) + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Backend: &soloScriptedBackend{}, Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + defer runner.runtime.Close() + outcome, err := runner.runSolo(context.Background(), "Add the feature.", base) + if err != nil { + t.Fatal(err) + } + ending := endingOf(pipelineResult{Status: delegate.StatusPass, Terminal: outcome.TerminalData}) + if _, hasRescue := outcome.TerminalData["rescue_path"]; hasRescue || strings.Contains(ending.Message, "set aside") { + t.Fatalf("unchanged submitted tree named a rescue: data=%#v ending=%q", outcome.TerminalData, ending.Message) + } +} + +func TestFailedSuiteRestoreNamesRescuedEditsInEnding(t *testing.T) { + state := t.TempDir() + t.Setenv("CODEAF_HOME", state) + workspace, _ := guardWorkspace(t) + started, release := heldVerification(t, workspace, true) + base := strings.TrimSpace(gitOutput(context.Background(), workspace, "rev-parse", "HEAD")) + var events bytes.Buffer + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Events: newEventWriter(&events), Notes: io.Discard}) + defer runner.runtime.Close() + stateOfRun := &soloState{baseSHA: base} + if err := runner.soloCaptureStart(stateOfRun); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "broken.go"), "package broken\nfunc (\n"); err != nil { + t.Fatal(err) + } + finished := make(chan soloOutcome, 1) + go func() { + outcome := soloOutcome{} + runner.soloShip(context.Background(), stateOfRun, &outcome, nil) + finished <- outcome + }() + releaseAfterPersonalEdits(t, workspace, started, release, "", 15*time.Second) + outcome := <-finished + if !outcome.SuiteDead || outcome.RestoreSource == "" { + t.Fatalf("failed suite was not restored: %#v", outcome) + } + ending := endingOf(pipelineResult{Status: delegate.StatusFail, Terminal: outcome.TerminalData}) + checkRescueEnding(t, workspace, outcome.TerminalData, ending.Message) +} diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go index 1da770ffe..74bffa22b 100644 --- a/internal/seniordev/app/solo_ship.go +++ b/internal/seniordev/app/solo_ship.go @@ -43,11 +43,12 @@ func (runner *pipeline) soloShip( // 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( + endingReason := fmt.Sprintf( "%s; shipping the submitted candidate, which nothing checked: %s", reason, candidate.describe(), - )) + ) runner.soloRestoreIfDiverged(state, outcome) + runner.soloTerminal(outcome, endingReason) return } @@ -58,25 +59,26 @@ func (runner *pipeline) soloShip( outcome.Verification = &verification failing := countFailingEntrypoints(verification) + var endingReason string 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( + endingReason = 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( + endingReason = 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 @@ -84,22 +86,25 @@ func (runner *pipeline) soloShip( // project whose suite was never found -- the vacuous-green shape -- a // verified pass. outcome.Status = "pass" - runner.soloTerminal(outcome, fmt.Sprintf( + endingReason = 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( + endingReason = fmt.Sprintf( "submitted candidate failed verification (%s); "+ "shipping it anyway as the run's own answer: %s", verificationFailureSummary(verification, failing), candidate.describe(), - )) + ) } + // The restore can set later edits aside. Build the terminal only after it + // finishes so the one ending carries their durable location on every road. runner.soloRestoreIfDiverged(state, outcome) + runner.soloTerminal(outcome, endingReason) } // verificationUnaffordable reports whether post-submit verification can still From 19eb19fea731fc98d6b97892e8b508cc092997b7 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:37:42 -0400 Subject: [PATCH 182/195] seniordev/util: the run facts codeaf reads build on every platform The fix that keeps generated caches out of a run's commits had internal/session call util.GeneratedRunPath, and every file of internal/seniordev/util is !windows, so the Windows build of codeaf stopped compiling ("build constraints exclude all Go files"). The pull-request gate cross-builds nothing; ci-full does, on the way into staging. The generated-path list and the engine's commit identity now live in runshape.go, the one file of the package without a build constraint; GOOS=windows builds again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/seniordev/util/eagercommit.go | 21 ------------- internal/seniordev/util/gitidentity.go | 5 +--- internal/seniordev/util/runshape.go | 41 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 25 deletions(-) create mode 100644 internal/seniordev/util/runshape.go diff --git a/internal/seniordev/util/eagercommit.go b/internal/seniordev/util/eagercommit.go index 2f3e16ed6..925a3b4f2 100644 --- a/internal/seniordev/util/eagercommit.go +++ b/internal/seniordev/util/eagercommit.go @@ -92,27 +92,6 @@ func EagerCommit(ctx context.Context, options EagerCommitOptions) { ), RunOptions{ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true}) } -// GeneratedRunPaths is the one narrow list of test droppings this run is -// known to create. A general guess would hide a person's actual deliverable. -const GeneratedRunPaths = "__pycache__/,.pytest_cache/,*.pyc" - -func GeneratedRunPath(path string) bool { - for _, pattern := range strings.Split(GeneratedRunPaths, ",") { - if strings.HasPrefix(pattern, "*.") { - if strings.HasSuffix(path, strings.TrimPrefix(pattern, "*")) { - return true - } - continue - } - for _, part := range strings.Split(filepath.ToSlash(path), "/") { - if part == strings.TrimSuffix(pattern, "/") { - return true - } - } - } - return false -} - // IgnoredAtStart reads the parent's frozen ignore list. It is a small file in // the run record, since ignored directories can contain thousands of files. func IgnoredAtStart(path string) bool { diff --git a/internal/seniordev/util/gitidentity.go b/internal/seniordev/util/gitidentity.go index 934f62776..55f65fd56 100644 --- a/internal/seniordev/util/gitidentity.go +++ b/internal/seniordev/util/gitidentity.go @@ -16,10 +16,7 @@ package util // commit codeaf makes of whatever the run left uncommitted when it ended, // which carries codeaf's identity rather than this one. The address is a local // one: it names the program that made a commit and no account anywhere. -const ( - CommitterName = "senior-dev" - CommitterEmail = "senior-dev@localhost" -) +// The constants themselves are in runshape.go, which every platform builds. // GitArgv is a git command line that carries senior-dev's commit identity. func GitArgv(args ...string) []string { diff --git a/internal/seniordev/util/runshape.go b/internal/seniordev/util/runshape.go new file mode 100644 index 000000000..9282e7ca2 --- /dev/null +++ b/internal/seniordev/util/runshape.go @@ -0,0 +1,41 @@ +package util + +import ( + "path/filepath" + "strings" +) + +// THE FACTS ABOUT A RUN THAT CODEAF ITSELF READS BUILD ON EVERY PLATFORM. +// +// Everything else in this package is the engine's own, and the engine does not +// build on Windows. codeaf's side of a run (internal/session's program folder) +// still needs to know which paths a run's tests leave behind and which identity +// the engine commits under, on every platform codeaf ships for, so those two +// facts live here, in the one file of this package without a build constraint. + +// The identity senior-dev's own commits carry (gitidentity.go says why it has one). +const ( + CommitterName = "senior-dev" + CommitterEmail = "senior-dev@localhost" +) + +// GeneratedRunPaths is the one narrow list of test droppings this run is +// known to create. A general guess would hide a person's actual deliverable. +const GeneratedRunPaths = "__pycache__/,.pytest_cache/,*.pyc" + +func GeneratedRunPath(path string) bool { + for _, pattern := range strings.Split(GeneratedRunPaths, ",") { + if strings.HasPrefix(pattern, "*.") { + if strings.HasSuffix(path, strings.TrimPrefix(pattern, "*")) { + return true + } + continue + } + for _, part := range strings.Split(filepath.ToSlash(path), "/") { + if part == strings.TrimSuffix(pattern, "/") { + return true + } + } + } + return false +} From 951527952a0760f17c69b2bf6685153da0844205 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:37:42 -0400 Subject: [PATCH 183/195] session: credit the engine's own checkpoint at the tip, and never amend a pushed one The finish credited a run-made tip only when it carried codeaf's identity, so in the common ending, where every write was already checkpointed by the engine as senior-dev and nothing was left to stage, no commit on the branch carried Assisted-by (seen on a real run). A tip made under either of the run's two identities is now credited. The amend also now requires that HEAD is the run's branch and that no remote-tracking ref holds the tip: a model that pushed its commit used to have the finish rewrite it, leaving the local branch diverged from the remote. A person's commit on the run branch is still never amended. Review of #1488, final verification. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/session/program_model_commit_test.go | 92 +++++++++++++++++++ internal/session/programfolder.go | 29 +++++- 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/internal/session/program_model_commit_test.go b/internal/session/program_model_commit_test.go index 145cc58fd..54725b126 100644 --- a/internal/session/program_model_commit_test.go +++ b/internal/session/program_model_commit_test.go @@ -3,11 +3,15 @@ package session import ( "context" "encoding/json" + "os" + "os/exec" + "path/filepath" "strings" "testing" "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" "github.com/Agent-Field/codeaf/internal/seniordev/tool" + "github.com/Agent-Field/codeaf/internal/seniordev/util" ) // A command the model writes and the finishing hand must give the same run @@ -77,3 +81,91 @@ func TestProgramFinishDoesNotAmendThePreRunTip(t *testing.T) { t.Fatalf("finishing an empty run amended the pre-run tip: %s → %s", base, tip) } } + +// THE COMMON ENDING CARRIES THE CREDIT TOO. When every write was already +// checkpointed by the engine, nothing is left to stage, and the tip of the run's +// branch is one of senior-dev's own checkpoints; that tip is the run's work and +// gets the credit, with its tree unchanged. +func TestProgramFinishCreditsTheEnginesOwnCheckpointAtTheTip(t *testing.T) { + repo := newTestRepo(t) + folder, err := PrepareProgramFolder(ProgramFolderOrder{ + Program: testPrograms("fake")[0], Dir: repo, Title: "Engine work", + Holder: "task 9", Keep: t.TempDir(), SignModel: "fixture/vendor-model", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(folder.release) + if err := os.WriteFile(filepath.Join(repo, "engine.txt"), []byte("engine\n"), 0o600); err != nil { + t.Fatal(err) + } + mustGit(t, repo, "add", "engine.txt") + mustGit(t, repo, "-c", "user.name="+util.CommitterName, "-c", "user.email="+util.CommitterEmail, + "commit", "-q", "--no-verify", "-m", "wip(write): engine.txt") + tree := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")) + folder.Finish("done") + message := gitOut(t, repo, "show", "-s", "--format=%B", "HEAD") + if !strings.Contains(message, "Assisted-by:") || !strings.Contains(message, "wip(write): engine.txt") { + t.Fatalf("the engine's checkpoint at the tip was not credited:\n%s", message) + } + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")); after != tree { + t.Fatalf("crediting the tip changed its tree: %s -> %s", tree, after) + } +} + +// A tip the model already pushed is left as it is: amending it would leave the +// person's local branch diverged from the remote for the sake of a trailer. +func TestProgramFinishLeavesAPushedTipAlone(t *testing.T) { + repo := newTestRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + if out, err := exec.Command("git", "init", "--bare", "-q", remote).CombinedOutput(); err != nil { + t.Fatalf("bare remote: %v: %s", err, out) + } + mustGit(t, repo, "remote", "add", "origin", remote) + folder, err := PrepareProgramFolder(ProgramFolderOrder{ + Program: testPrograms("fake")[0], Dir: repo, Title: "Model work", + Holder: "task 9", Keep: t.TempDir(), SignModel: "fixture/vendor-model", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(folder.release) + registry := tool.New(repo) + defer registry.CloseShellProcesses() + input, _ := json.Marshal(map[string]any{"command": "printf 'model work\\n' > model.txt && git add model.txt && git commit -q -m 'model work' && git push -q origin HEAD:refs/heads/task"}) + if result, err := registry.Execute(context.Background(), steploop.ToolCall{ID: "commit", Name: "bash", Input: input}); err != nil { + t.Fatalf("model commit and push: %v: %+v", err, result) + } + mustGit(t, repo, "fetch", "-q", "origin") + pushed := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + folder.Finish("done") + if local := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); local != pushed { + t.Fatalf("finishing rewrote a commit the remote already holds: pushed %s, local now %s", pushed, local) + } +} + +// A commit the person made on the run's branch while it worked is theirs, and +// the finish never rewrites it. +func TestProgramFinishLeavesThePersonsCommitOnTheRunBranchAlone(t *testing.T) { + repo := newTestRepo(t) + mustGit(t, repo, "config", "user.name", "Fixture Person") + mustGit(t, repo, "config", "user.email", "person@example.test") + folder, err := PrepareProgramFolder(ProgramFolderOrder{ + Program: testPrograms("fake")[0], Dir: repo, Title: "Model work", + Holder: "task 9", Keep: t.TempDir(), SignModel: "fixture/vendor-model", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(folder.release) + if err := os.WriteFile(filepath.Join(repo, "person.txt"), []byte("person\n"), 0o600); err != nil { + t.Fatal(err) + } + mustGit(t, repo, "add", "person.txt") + mustGit(t, repo, "commit", "-q", "-m", "person midrun") + before := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + folder.Finish("done") + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); after != before { + t.Fatalf("the person's commit on the run branch was amended: %s -> %s", before, after) + } +} diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index eebb01063..fa8a30520 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -838,9 +838,20 @@ func (f *ProgramFolder) commitLeftovers(result string) string { return "" } -// creditModelCommit signs a model's own final commit when there are no loose +// creditModelCommit signs the run's own final commit when there are no loose // changes for a finishing commit. It leaves the run's base and any commit the // person authored on the run branch alone. +// +// THE TIP IS THE RUN'S WHEN EITHER OF ITS TWO IDENTITIES MADE IT. The model's +// own `git commit` carries codeaf's identity (the model's shell is given it), +// and the engine's per-write checkpoints carry senior-dev's; the common ending, +// where every write was already checkpointed and nothing is left to stage, has +// one of the latter at its tip, and it is as much the run's work as the other. +// +// AND IT IS AMENDED ONLY WHERE AMENDING CHANGES NOTHING ANYONE ELSE HOLDS: the +// tip must be what HEAD points at, on the run's branch, and no remote-tracking +// ref may contain it. A tip the model already pushed keeps its credit-less +// message rather than leave the local branch diverged from the remote. func (f *ProgramFolder) creditModelCommit() string { if f.NoAttribution || f.Start == "" { return "" @@ -852,13 +863,18 @@ func (f *ProgramFolder) creditModelCommit() string { if _, err := git(f.Dir, "merge-base", "--is-ancestor", f.Start, tip); err != nil { return "" } + if head, err := git(f.Dir, "symbolic-ref", "--quiet", "HEAD"); err != nil || strings.TrimSpace(head) != "refs/heads/"+f.Branch { + return "" + } + if held, err := git(f.Dir, "branch", "-r", "--contains", tip); err != nil || strings.TrimSpace(held) != "" { + return "" + } identity, err := git(f.Dir, "show", "-s", "--format=%an%x00%ae%x00%cn%x00%ce", tip) if err != nil { return "git identity: " + firstLine(identity) } parts := strings.Split(strings.TrimSpace(identity), "\x00") - if len(parts) != 4 || parts[0] != codeafGitName || parts[1] != codeafGitEmail || - parts[2] != codeafGitName || parts[3] != codeafGitEmail { + if len(parts) != 4 || !runGitIdentity(parts[0], parts[1]) || !runGitIdentity(parts[2], parts[3]) { return "" } message, err := git(f.Dir, "show", "-s", "--format=%B", tip) @@ -877,6 +893,13 @@ func (f *ProgramFolder) creditModelCommit() string { return "" } +// runGitIdentity reports whether a commit's name and address are one of the two +// a run commits under: codeaf's, or senior-dev's own. +func runGitIdentity(name, email string) bool { + return (name == codeafGitName && email == codeafGitEmail) || + (name == util.CommitterName && email == util.CommitterEmail) +} + func (f *ProgramFolder) excludedFromCommit(path string) bool { if f.Notes != "" && (path == f.Notes || strings.HasPrefix(path, strings.TrimSuffix(f.Notes, "/")+"/")) { return true From 8a937a03b1078989c915067180a39993aacc81fa Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:39:43 -0400 Subject: [PATCH 184/195] seniordev: a restore that could not run ends unchecked, and says why When the folder changed after submission and the rescue copy could not be made, the restore refused (correctly: nothing is lost) and the folder kept the later changes, but the run still ended "submitted a change, and the project's own build and tests passed", about a tree nothing had checked. A failed restore on the ship road now turns a pass into pass-unverified, which the chat treats as work to check, and both restore roads record why it failed; the ending adds that the folder could not be put back, and why, so it also holds later changes that nothing checked. Review of #1488, final verification. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/manual/chat/senior-dev.md | 5 +- internal/seniordev/app/run.go | 3 + internal/seniordev/app/solo.go | 4 ++ internal/seniordev/app/solo_finalize.go | 1 + .../app/solo_restore_failure_test.go | 61 +++++++++++++++++++ internal/seniordev/app/solo_ship.go | 11 ++++ 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 internal/seniordev/app/solo_restore_failure_test.go diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index b6e5e11d4..1b04d692f 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -369,7 +369,10 @@ end record and the chat's landing all carry that path. The path holds the bytes were before the restore. A tracked file deleted after submission is named in `deleted-files.txt` there, and the ending names that manifest. A later restore in the same run has its own subfolder. With nothing to rescue, no folder is created -or named. +or named. If the copy cannot be made, nothing is restored and your folder is left +as it was; the run then does not say its build and tests passed, and the ending +adds `The folder changed after senior-dev's last check and could not be put back +(<why>), so it also holds later changes that nothing checked`. Files git ignored when the run started are not committed even if senior-dev changes `.gitignore`. Python `__pycache__/`, `.pytest_cache/` and `*.pyc` files made by its checks are not committed either. Those files stay in your folder. diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go index 27d2e6165..e8083bd42 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -316,6 +316,9 @@ func endingOf(result pipelineResult) delegate.Ending { ending.Message += "; files deleted during the run are listed in " + manifest + " there" } } + if failed, _ := extra["restore_failed"].(string); failed != "" { + ending.Message += ". The folder changed after senior-dev's last check and could not be put back (" + failed + "), so it also holds later changes that nothing checked" + } if reason, _ := extra["reason"].(string); reason != "" && reason != ending.Message { ending.Reason = reason } diff --git a/internal/seniordev/app/solo.go b/internal/seniordev/app/solo.go index 5d7884ed6..cee4b0dcf 100644 --- a/internal/seniordev/app/solo.go +++ b/internal/seniordev/app/solo.go @@ -75,6 +75,10 @@ type soloOutcome struct { FinalTree string SuiteDead bool + // RestoreFailed is why putting a recorded tree back failed, when it did. The + // folder then holds whatever was there, and the ending has to say so. + RestoreFailed string + // 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. diff --git a/internal/seniordev/app/solo_finalize.go b/internal/seniordev/app/solo_finalize.go index 2568e702d..18e3eb015 100644 --- a/internal/seniordev/app/solo_finalize.go +++ b/internal/seniordev/app/solo_finalize.go @@ -218,6 +218,7 @@ func (runner *pipeline) soloFinalizeUnsubmitted( runner.events.stage("landing", "restore-failed", map[string]any{ "source": target.Source, "error": err.Error(), }) + outcome.RestoreFailed = err.Error() } else { outcome.RestoreSource = target.Source runner.events.stage("landing", "restored", map[string]any{ diff --git a/internal/seniordev/app/solo_restore_failure_test.go b/internal/seniordev/app/solo_restore_failure_test.go new file mode 100644 index 000000000..bc1ddffbb --- /dev/null +++ b/internal/seniordev/app/solo_restore_failure_test.go @@ -0,0 +1,61 @@ +//go:build !windows + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// A RESTORE THAT COULD NOT RUN LEAVES AN ENDING THAT SAYS SO. The submitted +// candidate passed its build and tests, but the folder changed after that and +// the restore could not set the later bytes aside (here the rescue folder's +// place is taken by a file), so it refused to put the candidate back. Nothing is +// lost, and what the folder holds is no longer what was checked: the ending must +// not claim the build and tests passed on it, and must say why. +func TestAFailedRestoreEndsUncheckedAndSaysWhy(t *testing.T) { + stateRoot := t.TempDir() + t.Setenv("CODEAF_HOME", stateRoot) + runner, state, _, _ := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "candidate\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "later untested bytes\n"); err != nil { + t.Fatal(err) + } + rescueRoot := filepath.Join(stateRoot, "v3", "carried", "senior-dev", "rescued") + if err := os.MkdirAll(filepath.Dir(rescueRoot), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(rescueRoot, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + outcome := &soloOutcome{Status: "pass", Frozen: state.candidate()} + runner.soloRestoreIfDiverged(state, outcome) + runner.soloTerminal(outcome, "submitted, and its build and tests passed") + if outcome.Status != "pass-unverified" { + t.Fatalf("a restore that failed left the outcome %q; the folder is not what was checked", outcome.Status) + } + ending := endingOf(pipelineResult{Status: delegate.StatusPass, Terminal: outcome.TerminalData}) + if strings.Contains(ending.Message, "build and tests passed") { + t.Fatalf("the ending claims a pass for a folder nothing checked: %q", ending.Message) + } + if !strings.Contains(ending.Message, "could not be put back") { + t.Fatalf("the ending does not say the folder could not be put back: %q", ending.Message) + } + content, err := os.ReadFile(filepath.Join(runner.workspace, "feature.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "later untested bytes\n" { + t.Fatalf("a failed restore must leave the folder as it was, got %q", content) + } +} diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go index 74bffa22b..915d263e2 100644 --- a/internal/seniordev/app/solo_ship.go +++ b/internal/seniordev/app/solo_ship.go @@ -156,6 +156,14 @@ func (runner *pipeline) soloRestoreIfDiverged(state *soloState, outcome *soloOut "error": err.Error(), "commit_sha": candidate.CommitSHA, }) runner.note("[senior-dev] ship: RESTORE FAILED, shipping the diverged tree: " + err.Error() + "\n") + // A PASS WAS ABOUT THE CANDIDATE, AND THE CANDIDATE IS NO LONGER WHAT IS + // ON DISK. The folder keeps the later changes nobody checked, so the run + // cannot say its build and tests passed on what it leaves; it says the + // work is unchecked, and the ending says why. + outcome.RestoreFailed = err.Error() + if outcome.Status == "pass" { + outcome.Status = "pass-unverified" + } return } runner.events.stage("ship", "restored", map[string]any{ @@ -191,6 +199,9 @@ func (runner *pipeline) soloTerminal(outcome *soloOutcome, reason string) { if outcome.SuiteDead { data["suite_dead"] = true } + if outcome.RestoreFailed != "" { + data["restore_failed"] = outcome.RestoreFailed + } if runner.rescuePath != "" { data["rescue_path"] = runner.rescuePath if runner.rescueDeleted { From b9edfa1088d35159f6fa3772042479803a93d346 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 15:39:44 -0400 Subject: [PATCH 185/195] changes: a failed restore, the engine's checkpoint credit, and the Windows build Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 70d68fb68..ece93716c 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -59,6 +59,9 @@ invalidates: - "The general task pages said a task has no dollar limit of its own and is never stopped on its own dollar count. They now say that of an ordinary `/task` and point to senior-dev's run ceiling." - "`/budget conversation` in an open chat saved the figure and showed it as active while the conversation kept the limit it was opened with, so its next turn, task and senior-dev run spent against the old one. The open conversation now takes the new limit before the receipt says so, on the engine road and `--no-host`; if an older engine host cannot take it, the receipt says it applies to the next conversation." - "A commit senior-dev's model made itself through bash took the person's git identity and carried no `Assisted-by`. The model's commands now commit as the run, and a run-made tip without the credit has it added at the finish; a commit from before the run is never amended." + - "A restore that could not set the person's later edits aside left the folder as it was (nothing lost) but still ended with `the project's own build and tests passed`, about a folder holding changes nothing checked. The run now ends unchecked and says the folder could not be put back, and why." + - "In the common ending, where every write was already checkpointed by the engine and nothing was left to stage, no commit on the run's branch carried `Assisted-by`. The engine's own checkpoint at the tip is now credited too, and a tip the model already pushed is never amended." + - "The Windows build of codeaf stopped compiling once internal/session read senior-dev's generated-path list, because every file of internal/seniordev/util is !windows. The two run facts codeaf reads now live in the package's one file without a build constraint." --- `docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate` From 64564dc8e95a8473f7bd3419d7e6752e22b0deef Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 16:56:01 -0400 Subject: [PATCH 186/195] gitidentity: the run facts codeaf reads live outside the engine The previous fix kept the Windows build compiling by giving internal/seniordev/util one file without a build constraint, which broke the PR's own law that no file of senior-dev reaches a Windows build (TestNoFileOfSeniorDevReachesAWindowsBuild). The engine's commit identity and the list of caches a run's tests leave now live in internal/gitidentity, beside codeaf's own run identity, which builds on every platform; the engine keeps its names for them as aliases, and internal/session no longer imports the engine at all. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/gitidentity/identity.go | 45 ++++++++++++++++++- internal/seniordev/util/eagercommit.go | 7 +++ internal/seniordev/util/gitidentity.go | 10 ++++- internal/seniordev/util/runshape.go | 41 ----------------- internal/session/program_model_commit_test.go | 4 +- internal/session/programfolder.go | 6 +-- 6 files changed, 64 insertions(+), 49 deletions(-) delete mode 100644 internal/seniordev/util/runshape.go diff --git a/internal/gitidentity/identity.go b/internal/gitidentity/identity.go index dc0217956..f95a6a7f8 100644 --- a/internal/gitidentity/identity.go +++ b/internal/gitidentity/identity.go @@ -1,7 +1,18 @@ -// Package gitidentity holds the signature shared by codeaf's own commits and -// commands a program runs on its behalf. +// Package gitidentity holds what a run's commits carry and leave out: the +// signature shared by codeaf's own commits and the commands a program runs on +// its behalf, the identity senior-dev's engine commits under, and the caches a +// run's own tests leave behind. +// +// IT BUILDS ON EVERY PLATFORM. internal/seniordev does not build on Windows +// (its own law says so), and codeaf's side of a run still has to know these +// facts there, so they live here rather than in the engine. package gitidentity +import ( + "path/filepath" + "strings" +) + const ( Name = "codeaf" Email = "agentfield-bot@users.noreply.github.com" @@ -17,3 +28,33 @@ func Environment() []string { "GIT_COMMITTER_EMAIL=" + Email, } } + +// The identity senior-dev's engine commits under: its starting tree, every +// write it checkpoints and the candidate it submits (internal/seniordev/util +// says why it carries one of its own). +const ( + EngineName = "senior-dev" + EngineEmail = "senior-dev@localhost" +) + +// GeneratedRunPaths is the one narrow list of test droppings a run is known to +// create. A general guess would hide a person's actual deliverable. +const GeneratedRunPaths = "__pycache__/,.pytest_cache/,*.pyc" + +// GeneratedRunPath reports whether a path is one of [GeneratedRunPaths]. +func GeneratedRunPath(path string) bool { + for _, pattern := range strings.Split(GeneratedRunPaths, ",") { + if strings.HasPrefix(pattern, "*.") { + if strings.HasSuffix(path, strings.TrimPrefix(pattern, "*")) { + return true + } + continue + } + for _, part := range strings.Split(filepath.ToSlash(path), "/") { + if part == strings.TrimSuffix(pattern, "/") { + return true + } + } + } + return false +} diff --git a/internal/seniordev/util/eagercommit.go b/internal/seniordev/util/eagercommit.go index 925a3b4f2..a04c72760 100644 --- a/internal/seniordev/util/eagercommit.go +++ b/internal/seniordev/util/eagercommit.go @@ -6,6 +6,7 @@ package util import ( "context" "fmt" + "github.com/Agent-Field/codeaf/internal/gitidentity" "os" "path/filepath" "strings" @@ -161,3 +162,9 @@ func resolveExisting(path string) string { } return filepath.Join(resolveExisting(parent), filepath.Base(path)) } + +// GeneratedRunPaths and GeneratedRunPath are internal/gitidentity's, where +// codeaf's own side of a run reads them on every platform. +const GeneratedRunPaths = gitidentity.GeneratedRunPaths + +func GeneratedRunPath(path string) bool { return gitidentity.GeneratedRunPath(path) } diff --git a/internal/seniordev/util/gitidentity.go b/internal/seniordev/util/gitidentity.go index 55f65fd56..4edf5f411 100644 --- a/internal/seniordev/util/gitidentity.go +++ b/internal/seniordev/util/gitidentity.go @@ -2,6 +2,8 @@ package util +import "github.com/Agent-Field/codeaf/internal/gitidentity" + // The identity senior-dev's own commits carry. // // senior-dev commits as it works: its exact starting tree, every file its @@ -16,7 +18,13 @@ package util // commit codeaf makes of whatever the run left uncommitted when it ended, // which carries codeaf's identity rather than this one. The address is a local // one: it names the program that made a commit and no account anywhere. -// The constants themselves are in runshape.go, which every platform builds. + +// The values live in internal/gitidentity, which codeaf's own side of a run +// reads on every platform. +const ( + CommitterName = gitidentity.EngineName + CommitterEmail = gitidentity.EngineEmail +) // GitArgv is a git command line that carries senior-dev's commit identity. func GitArgv(args ...string) []string { diff --git a/internal/seniordev/util/runshape.go b/internal/seniordev/util/runshape.go deleted file mode 100644 index 9282e7ca2..000000000 --- a/internal/seniordev/util/runshape.go +++ /dev/null @@ -1,41 +0,0 @@ -package util - -import ( - "path/filepath" - "strings" -) - -// THE FACTS ABOUT A RUN THAT CODEAF ITSELF READS BUILD ON EVERY PLATFORM. -// -// Everything else in this package is the engine's own, and the engine does not -// build on Windows. codeaf's side of a run (internal/session's program folder) -// still needs to know which paths a run's tests leave behind and which identity -// the engine commits under, on every platform codeaf ships for, so those two -// facts live here, in the one file of this package without a build constraint. - -// The identity senior-dev's own commits carry (gitidentity.go says why it has one). -const ( - CommitterName = "senior-dev" - CommitterEmail = "senior-dev@localhost" -) - -// GeneratedRunPaths is the one narrow list of test droppings this run is -// known to create. A general guess would hide a person's actual deliverable. -const GeneratedRunPaths = "__pycache__/,.pytest_cache/,*.pyc" - -func GeneratedRunPath(path string) bool { - for _, pattern := range strings.Split(GeneratedRunPaths, ",") { - if strings.HasPrefix(pattern, "*.") { - if strings.HasSuffix(path, strings.TrimPrefix(pattern, "*")) { - return true - } - continue - } - for _, part := range strings.Split(filepath.ToSlash(path), "/") { - if part == strings.TrimSuffix(pattern, "/") { - return true - } - } - } - return false -} diff --git a/internal/session/program_model_commit_test.go b/internal/session/program_model_commit_test.go index 54725b126..278d9edc7 100644 --- a/internal/session/program_model_commit_test.go +++ b/internal/session/program_model_commit_test.go @@ -9,9 +9,9 @@ import ( "strings" "testing" + "github.com/Agent-Field/codeaf/internal/gitidentity" "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" "github.com/Agent-Field/codeaf/internal/seniordev/tool" - "github.com/Agent-Field/codeaf/internal/seniordev/util" ) // A command the model writes and the finishing hand must give the same run @@ -100,7 +100,7 @@ func TestProgramFinishCreditsTheEnginesOwnCheckpointAtTheTip(t *testing.T) { t.Fatal(err) } mustGit(t, repo, "add", "engine.txt") - mustGit(t, repo, "-c", "user.name="+util.CommitterName, "-c", "user.email="+util.CommitterEmail, + mustGit(t, repo, "-c", "user.name="+gitidentity.EngineName, "-c", "user.email="+gitidentity.EngineEmail, "commit", "-q", "--no-verify", "-m", "wip(write): engine.txt") tree := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")) folder.Finish("done") diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index fa8a30520..7432d0672 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -79,8 +79,8 @@ import ( "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/gitidentity" "github.com/Agent-Field/codeaf/internal/home" - "github.com/Agent-Field/codeaf/internal/seniordev/util" ) // programFolderDir is where the hold on each folder a program works in, and @@ -897,7 +897,7 @@ func (f *ProgramFolder) creditModelCommit() string { // a run commits under: codeaf's, or senior-dev's own. func runGitIdentity(name, email string) bool { return (name == codeafGitName && email == codeafGitEmail) || - (name == util.CommitterName && email == util.CommitterEmail) + (name == gitidentity.EngineName && email == gitidentity.EngineEmail) } func (f *ProgramFolder) excludedFromCommit(path string) bool { @@ -909,7 +909,7 @@ func (f *ProgramFolder) excludedFromCommit(path string) bool { return true } } - return util.GeneratedRunPath(path) + return gitidentity.GeneratedRunPath(path) } // goBack checks out the person's own branch again (or the commit their From 1e2b17737a67d7d73bf410b8b68f5e5511c87912 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 17:25:02 -0400 Subject: [PATCH 187/195] seniordev: no file of the engine reaches a Windows build, again without exception The dev merge made TestNoFileOfSeniorDevReachesAWindowsBuild allow one file of internal/seniordev/util to build on Windows, the one that held the run facts codeaf reads on every platform. Those facts now live in internal/gitidentity, so the law is back to its original form: no file of senior-dev builds there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/seniordev/absentonwindows_test.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/internal/seniordev/absentonwindows_test.go b/internal/seniordev/absentonwindows_test.go index e83ef816a..259c4bb4d 100644 --- a/internal/seniordev/absentonwindows_test.go +++ b/internal/seniordev/absentonwindows_test.go @@ -10,15 +10,17 @@ import ( "testing" ) -// SENIOR-DEV'S ENGINE IS ABSENT ON WINDOWS. Its process groups, file locks and -// bash shell have no Windows form, so its files stay out of that build. The -// one exception is util/runshape.go: codeaf's cross-platform program folder -// reads those shared run facts even when the engine is absent. -func TestOnlySharedRunFactsOfSeniorDevReachAWindowsBuild(t *testing.T) { +// 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 - sharedFacts := filepath.Join("util", "runshape.go") err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -37,12 +39,6 @@ func TestOnlySharedRunFactsOfSeniorDevReachAWindowsBuild(t *testing.T) { if err != nil { return err } - if path == sharedFacts { - if !included { - t.Errorf("%s must remain available to codeaf's Windows program folder", path) - } - return nil - } if included { t.Errorf("%s would be compiled into a Windows build; give it //go:build !windows", path) } From 58e8b7803fb6f6252032ed1168d981cd358a1035 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 16:56:02 -0400 Subject: [PATCH 188/195] delegate, config: register the run marker, and let the key suffix rule name the rest The environment-pin law found two CODEAF_ names nothing had registered. The strip list named CODEAF_API_KEY, which codeaf never reads and which the *_API_KEY suffix rule in the same function already removes (the older spelling too), so it is no longer named. CODEAF_DELEGATE_RUN, the mark codeaf sets on a program's process to find what its commands left behind after the engine is killed, is plumbing like the model API's own two names, and is registered beside them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/config/settings.go | 5 +++++ internal/delegate/host.go | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/config/settings.go b/internal/config/settings.go index 883db9642..c0a527f96 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1074,6 +1074,11 @@ var OperatorEnvPins = []string{ // and the footer names them and never shows a value. "CODEAF_MODEL_API", "CODEAF_MODEL_TOKEN", + // The mark codeaf sets on a program's process so that, if the program's + // engine is killed outright, the processes its commands left behind can + // still be found and ended (internal/processgroup). codeaf sets it and reads + // it back, and a person has nothing to say to it, so it is plumbing too. + "CODEAF_DELEGATE_RUN", // 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/host.go b/internal/delegate/host.go index da93db1a3..982f5cda8 100644 --- a/internal/delegate/host.go +++ b/internal/delegate/host.go @@ -133,7 +133,9 @@ func ModelAPIFromEnv() (ModelAPI, bool) { // redirection left here would let a program reach a model outside the API, // the one road codeaf can meter and show a person. func ChildEnv(api ModelAPI) []string { - strip := []string{EnvModelAPI, EnvModelToken, envBaseURL, "CODEAF_API_KEY", "OPENAI_API_KEY", modelsource.DefaultSource("").KeyEnv} // legacy-name + // Every *_API_KEY name, codeaf's own and the older spelling included, goes + // by its suffix below, so it is not named here. + strip := []string{EnvModelAPI, EnvModelToken, envBaseURL, modelsource.DefaultSource("").KeyEnv} for _, source := range modelsource.Vendored() { if source.KeyEnv != "" { strip = append(strip, source.KeyEnv) From 898c3d1052e1e1770780bdba6747b17d735d2468 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 16:56:18 -0400 Subject: [PATCH 189/195] changes: where the run facts codeaf reads now live Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 39d4202be..01b085e7f 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -61,7 +61,7 @@ invalidates: - "A commit senior-dev's model made itself through bash took the person's git identity and carried no `Assisted-by`. The model's commands now commit as the run, and a run-made tip without the credit has it added at the finish; a commit from before the run is never amended." - "A restore that could not set the person's later edits aside left the folder as it was (nothing lost) but still ended with `the project's own build and tests passed`, about a folder holding changes nothing checked. The run now ends unchecked and says the folder could not be put back, and why." - "In the common ending, where every write was already checkpointed by the engine and nothing was left to stage, no commit on the run's branch carried `Assisted-by`. The engine's own checkpoint at the tip is now credited too, and a tip the model already pushed is never amended." - - "The Windows build of codeaf stopped compiling once internal/session read senior-dev's generated-path list, because every file of internal/seniordev/util is !windows. The two run facts codeaf reads now live in the package's one file without a build constraint." + - "The Windows build of codeaf stopped compiling once internal/session read senior-dev's generated-path list, because every file of internal/seniordev/util is !windows. The two run facts codeaf reads (the engine's commit identity and the list of caches a run's tests leave) now live in internal/gitidentity, which builds everywhere, and the engine itself still never reaches a Windows build." - "Dev's per-task crew router and one-column chat arrived after senior-dev's first review. A senior-dev run now keeps its requested models or one profile worker recommendation, outside the ordinary task's crew cap; its badge fits the shared side column. The combined remote door is wire version 19, so an older engine refuses before it can silently drop a delegate start." --- From 5086e5c147e8fc6ba94df198a493b7acb9c4ca2c Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 17:12:11 -0400 Subject: [PATCH 190/195] seniordev: the recovery test prepares its recorder the way a real run does TestSoloRecoveryCrossesThePersistedEngineBoundaryWithoutReplayingToolEffects drove soloConverse without the recorder's Prepare, which every real run calls first and which installs git's exclusion for the live session store. Without it the store's temporary files could race git add during submit, the submit failed, the model was nudged that it had stopped without submitting, and the stub saw a fourth request it did not expect. Under load it failed 2 runs in 40; with the recorder prepared it passes 40 of 40, and 10 of 10 with -race. Review of #1488. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/seniordev/app/runtime_retry_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/seniordev/app/runtime_retry_test.go b/internal/seniordev/app/runtime_retry_test.go index 81f025041..c1681935c 100644 --- a/internal/seniordev/app/runtime_retry_test.go +++ b/internal/seniordev/app/runtime_retry_test.go @@ -216,6 +216,12 @@ func TestSoloRecoveryCrossesThePersistedEngineBoundaryWithoutReplayingToolEffect Sleep: func(context.Context, time.Duration) error { return nil }, }) t.Cleanup(runner.runtime.Close) + // The real run prepares its recorder before starting the conversation. That + // installs Git's exclusion for the live session store, whose temporary files + // otherwise race git add during submit in this direct-to-converse fixture. + if err := runner.recorder.Prepare(context.Background()); err != nil { + t.Fatal(err) + } // The adaptive router is orthogonal to this test. Keeping its single // candidate out of cooldown lets the fresh turn start immediately. backend.router = nil From 3b97085d38b3f1efac8bce8531adc7ce5c1aca3b Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 17:43:07 -0400 Subject: [PATCH 191/195] manual: the stop heading says "if it will not stop" in the asker's words With senior-dev's pages beside dev's new team-manager page, the probe "what happens if it will not stop" reached home, tasks, team-manager and senior-dev instead of the keys page that answers it. The heading already answered it as "will not let go"; it now says both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/manual/chat/keys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index d7e1b6b13..0423cc213 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -409,7 +409,7 @@ land, stops the answer and then quits. Nothing you typed is lost when it does: t and anything waiting for an answer are written to disk on the way out. See "Quitting codeaf — how do I exit, close it, or why did ctrl+c not quit" below. -## Esc is not stopping it — how long does a stop take, why the turn is still finishing, how long stopping takes, and what happens if it will not let go +## Esc is not stopping it — how long does a stop take, why the turn is still finishing, how long stopping takes, and what happens if it will not stop or will not let go **I pressed escape and it is still running.** That is this section: escape is not being ignored, the turn is being let go of, and if it will not let go codeaf ends it for you From f4a93620f1a3f94e9368fb6db04d9f7eb3516236 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 19:06:43 -0400 Subject: [PATCH 192/195] session: codeaf never rewrites a commit; the credit rides only on a commit it makes The finish amended the run branch's tip to add Assisted-by when nothing was left to stage. The final verification showed that rewrote a commit the model had pushed by direct URL (no remote-tracking ref records such a push) and dropped the signature of a signed commit; guarding the amend further would always miss a case. The amend is gone: commits senior-dev makes as it works keep the run's identity and are never rewritten, and the credit is carried by the finishing commit codeaf makes when there is something left to commit. Review of #1488, final verification V4.1, V4.2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../session/program_commit_immutable_test.go | 78 +++++++++++++++++++ internal/session/program_model_commit_test.go | 35 +++++---- internal/session/programfolder.go | 66 +--------------- 3 files changed, 99 insertions(+), 80 deletions(-) create mode 100644 internal/session/program_commit_immutable_test.go diff --git a/internal/session/program_commit_immutable_test.go b/internal/session/program_commit_immutable_test.go new file mode 100644 index 000000000..4565bd012 --- /dev/null +++ b/internal/session/program_commit_immutable_test.go @@ -0,0 +1,78 @@ +package session + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// A signed commit is durable evidence. Finishing a clean run preserves its +// hash, signature, and complete object bytes. +func TestProgramFinishLeavesSignedTipUntouched(t *testing.T) { + if _, err := exec.LookPath("ssh-keygen"); err != nil { + t.Skip("ssh-keygen is needed to sign the fixture commit") + } + repo := newTestRepo(t) + key := filepath.Join(t.TempDir(), "signing-key") + if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key).CombinedOutput(); err != nil { + t.Fatalf("ssh-keygen: %v: %s", err, out) + } + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "Signed work", Holder: "task 9", Keep: t.TempDir(), SignModel: "fixture/vendor-model"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(folder.release) + if err := os.WriteFile(filepath.Join(repo, "signed.txt"), []byte("work\n"), 0600); err != nil { + t.Fatal(err) + } + mustGit(t, repo, "add", "signed.txt") + mustGit(t, repo, "config", "gpg.format", "ssh") + mustGit(t, repo, "config", "user.signingkey", key) + mustGit(t, repo, "-c", "user.name="+codeafGitName, "-c", "user.email="+codeafGitEmail, "commit", "-q", "-S", "-m", "model signed work") + beforeHash := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + before := gitOut(t, repo, "cat-file", "-p", "HEAD") + if !strings.Contains(before, "gpgsig") { + t.Fatal("fixture did not sign") + } + folder.Finish("done") + after := gitOut(t, repo, "cat-file", "-p", "HEAD") + if afterHash := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); afterHash != beforeHash { + t.Fatalf("finish rewrote signed commit: %s -> %s", beforeHash, afterHash) + } + if after != before { + t.Fatal("finish changed the signed commit object") + } +} + +// A push to a direct URL creates no remote-tracking ref, but the pushed commit +// is still immutable at finish. +func TestProgramFinishLeavesDirectURLPushUntouched(t *testing.T) { + repo := newTestRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + if out, err := exec.Command("git", "init", "--bare", "-q", remote).CombinedOutput(); err != nil { + t.Fatalf("bare remote: %v: %s", err, out) + } + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "Pushed work", Holder: "task 9", Keep: t.TempDir(), SignModel: "fixture/vendor-model"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(folder.release) + if err := os.WriteFile(filepath.Join(repo, "pushed.txt"), []byte("work\n"), 0600); err != nil { + t.Fatal(err) + } + mustGit(t, repo, "add", "pushed.txt") + mustGit(t, repo, "-c", "user.name="+codeafGitName, "-c", "user.email="+codeafGitEmail, "commit", "-q", "-m", "model work") + mustGit(t, repo, "push", "-q", remote, "HEAD:refs/heads/task") + before := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + object := gitOut(t, repo, "cat-file", "-p", "HEAD") + folder.Finish("done") + after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + if after != before { + t.Fatalf("finish rewrote pushed commit via direct URL: pushed=%s local=%s", before, after) + } + if afterObject := gitOut(t, repo, "cat-file", "-p", "HEAD"); afterObject != object { + t.Fatal("finish changed the pushed commit object") + } +} diff --git a/internal/session/program_model_commit_test.go b/internal/session/program_model_commit_test.go index 278d9edc7..cd5ed6791 100644 --- a/internal/session/program_model_commit_test.go +++ b/internal/session/program_model_commit_test.go @@ -14,9 +14,9 @@ import ( "github.com/Agent-Field/codeaf/internal/seniordev/tool" ) -// A command the model writes and the finishing hand must give the same run -// credit, without rewriting a commit that was present before the run. -func TestProgramModelCommitHasRunIdentityAndFinishingCredit(t *testing.T) { +// A model's own commit uses the run identity, and finishing an otherwise clean +// branch cannot rewrite either that commit or the base it started from. +func TestProgramModelCommitKeepsRunIdentityAndCommitObject(t *testing.T) { repo := newTestRepo(t) mustGit(t, repo, "config", "user.name", "Person") mustGit(t, repo, "config", "user.email", "person@example.test") @@ -47,10 +47,13 @@ func TestProgramModelCommitHasRunIdentityAndFinishingCredit(t *testing.T) { t.Fatalf("model commit identity = %q, want %q", identity, wantIdentity) } tree := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")) + object := gitOut(t, repo, "cat-file", "-p", "HEAD") folder.Finish("completed the change") - message := gitOut(t, repo, "show", "-s", "--format=%B", "HEAD") - if !strings.Contains(message, "Assisted-by:") || !strings.Contains(message, "glm-5.3-flash") { - t.Fatalf("model commit lacks answered-model credit: %q", message) + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); after != tip { + t.Fatalf("finishing rewrote the model's commit: %s -> %s", tip, after) + } + if after := gitOut(t, repo, "cat-file", "-p", "HEAD"); after != object { + t.Fatal("finishing changed the model's commit object") } if after := gitOut(t, repo, "show", "-s", "--format=%an <%ae>|%cn <%ce>|%B", base); after != before { t.Fatalf("pre-run commit changed:\nbefore %q\nafter %q", before, after) @@ -82,11 +85,9 @@ func TestProgramFinishDoesNotAmendThePreRunTip(t *testing.T) { } } -// THE COMMON ENDING CARRIES THE CREDIT TOO. When every write was already -// checkpointed by the engine, nothing is left to stage, and the tip of the run's -// branch is one of senior-dev's own checkpoints; that tip is the run's work and -// gets the credit, with its tree unchanged. -func TestProgramFinishCreditsTheEnginesOwnCheckpointAtTheTip(t *testing.T) { +// An engine checkpoint at the tip is already a commit. With nothing left to +// stage, finishing cannot rewrite it just to add model attribution. +func TestProgramFinishLeavesTheEnginesOwnCheckpointAtTheTip(t *testing.T) { repo := newTestRepo(t) folder, err := PrepareProgramFolder(ProgramFolderOrder{ Program: testPrograms("fake")[0], Dir: repo, Title: "Engine work", @@ -102,14 +103,14 @@ func TestProgramFinishCreditsTheEnginesOwnCheckpointAtTheTip(t *testing.T) { mustGit(t, repo, "add", "engine.txt") mustGit(t, repo, "-c", "user.name="+gitidentity.EngineName, "-c", "user.email="+gitidentity.EngineEmail, "commit", "-q", "--no-verify", "-m", "wip(write): engine.txt") - tree := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")) + tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + object := gitOut(t, repo, "cat-file", "-p", "HEAD") folder.Finish("done") - message := gitOut(t, repo, "show", "-s", "--format=%B", "HEAD") - if !strings.Contains(message, "Assisted-by:") || !strings.Contains(message, "wip(write): engine.txt") { - t.Fatalf("the engine's checkpoint at the tip was not credited:\n%s", message) + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); after != tip { + t.Fatalf("the engine's checkpoint was rewritten: %s -> %s", tip, after) } - if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD^{tree}")); after != tree { - t.Fatalf("crediting the tip changed its tree: %s -> %s", tree, after) + if after := gitOut(t, repo, "cat-file", "-p", "HEAD"); after != object { + t.Fatal("the engine's checkpoint object changed") } } diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go index 7432d0672..8dd0d4f1a 100644 --- a/internal/session/programfolder.go +++ b/internal/session/programfolder.go @@ -816,7 +816,9 @@ func (f *ProgramFolder) commitLeftovers(result string) string { } } if _, err := git(f.Dir, "diff", "--cached", "--quiet"); err == nil { - return f.creditModelCommit() + // A COMMIT MAY ALREADY BE PUSHED OR SIGNED. With nothing left to stage, + // making the model credit would require rewriting that commit. + return "" } message := clip(firstLine(f.Title), 72) if strings.TrimSpace(message) == "" { @@ -838,68 +840,6 @@ func (f *ProgramFolder) commitLeftovers(result string) string { return "" } -// creditModelCommit signs the run's own final commit when there are no loose -// changes for a finishing commit. It leaves the run's base and any commit the -// person authored on the run branch alone. -// -// THE TIP IS THE RUN'S WHEN EITHER OF ITS TWO IDENTITIES MADE IT. The model's -// own `git commit` carries codeaf's identity (the model's shell is given it), -// and the engine's per-write checkpoints carry senior-dev's; the common ending, -// where every write was already checkpointed and nothing is left to stage, has -// one of the latter at its tip, and it is as much the run's work as the other. -// -// AND IT IS AMENDED ONLY WHERE AMENDING CHANGES NOTHING ANYONE ELSE HOLDS: the -// tip must be what HEAD points at, on the run's branch, and no remote-tracking -// ref may contain it. A tip the model already pushed keeps its credit-less -// message rather than leave the local branch diverged from the remote. -func (f *ProgramFolder) creditModelCommit() string { - if f.NoAttribution || f.Start == "" { - return "" - } - tip := branchCommit(f.Dir, f.Branch) - if tip == "" || tip == f.Start { - return "" - } - if _, err := git(f.Dir, "merge-base", "--is-ancestor", f.Start, tip); err != nil { - return "" - } - if head, err := git(f.Dir, "symbolic-ref", "--quiet", "HEAD"); err != nil || strings.TrimSpace(head) != "refs/heads/"+f.Branch { - return "" - } - if held, err := git(f.Dir, "branch", "-r", "--contains", tip); err != nil || strings.TrimSpace(held) != "" { - return "" - } - identity, err := git(f.Dir, "show", "-s", "--format=%an%x00%ae%x00%cn%x00%ce", tip) - if err != nil { - return "git identity: " + firstLine(identity) - } - parts := strings.Split(strings.TrimSpace(identity), "\x00") - if len(parts) != 4 || !runGitIdentity(parts[0], parts[1]) || !runGitIdentity(parts[2], parts[3]) { - return "" - } - message, err := git(f.Dir, "show", "-s", "--format=%B", tip) - if err != nil { - return "git message: " + firstLine(message) - } - if strings.Contains(message, "Assisted-by:") { - return "" - } - message = signed(strings.TrimRight(message, "\n"), gitSignature{named: f.SignModel != "", model: f.SignModel}) - args := append([]string{"-c", "commit.gpgsign=false"}, codeafGitIdentity()...) - args = append(args, "commit", "--amend", "-q", "--no-verify", "-m", message) - if out, err := git(f.Dir, args...); err != nil { - return "git amend: " + firstLine(out) - } - return "" -} - -// runGitIdentity reports whether a commit's name and address are one of the two -// a run commits under: codeaf's, or senior-dev's own. -func runGitIdentity(name, email string) bool { - return (name == codeafGitName && email == codeafGitEmail) || - (name == gitidentity.EngineName && email == gitidentity.EngineEmail) -} - func (f *ProgramFolder) excludedFromCommit(path string) bool { if f.Notes != "" && (path == f.Notes || strings.HasPrefix(path, strings.TrimSuffix(f.Notes, "/")+"/")) { return true From dbb5e957f5e482d64f0a388332b4ebf37a53b34e Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 19:06:43 -0400 Subject: [PATCH 193/195] codeaf: an explicit shell model starts senior-dev without a crew seat After the Pareto-crew merge, codeaf senior-dev run --high <model> on a fresh profile with a key but no crew rows was refused before its first call ("no allowed model on a connected provider can start"), because the profile seat was resolved before the explicit model was applied. The seat is now resolved only when the run needs a default, which keeps that refusal and its /crew door for a run with no model named. Review of #1488, final verification V4.4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- cmd/codeaf/carried.go | 30 +++--- cmd/codeaf/carried_fresh_profile_test.go | 116 +++++++++++++++++++++++ internal/delegate/cli.go | 20 ++-- internal/delegate/cli_test.go | 20 ++++ internal/session/program_ground_test.go | 17 ++++ 5 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 cmd/codeaf/carried_fresh_profile_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index c06c7e322..5915b6e12 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -124,6 +124,7 @@ type carriedRoad struct { serves func(model string) bool modelPrice func(model string) (input, output float64, known bool) seat string + defaultSeat func() (string, error) signNamed bool } @@ -134,8 +135,8 @@ 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 +// one sentence every command gives it — the crew's work seat if this run +// needs a default, 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() @@ -143,13 +144,6 @@ func profileRoad() (carriedRoad, error) { return carriedRoad{}, err } useAutoSeats(settings) - // A shell run has no task crew of its own. Resolve the profile's worker - // seat once here; the program keeps that seat unless its invocation names - // models explicitly. - seats, err := config.ResolveSeats(settings.ProfileDir, config.SeatFlags{}, config.CrewAsk{}) - if err != nil && !errors.Is(err, config.ErrCrewAtCap) { - return carriedRoad{}, err - } settings.Models = sharedCatalog(settings) adapters := &carriedAdapters{settings: settings, built: map[string]modelapi.Completer{}} sources := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL) @@ -157,8 +151,16 @@ func profileRoad() (carriedRoad, error) { completerFor: adapters.forModel, serves: func(model string) bool { return session.ServesModel(sources, model) }, modelPrice: settings.Models.PriceNow, - seat: seats.Work.Model, - signNamed: config.AttributionModelAt(settings.ProfileDir), + defaultSeat: func() (string, error) { + // A shell run needs the profile's work seat only when nobody pinned + // a model for this invocation. A fresh profile can still use --high. + seats, err := config.ResolveSeats(settings.ProfileDir, config.SeatFlags{}, config.CrewAsk{}) + if err != nil && !errors.Is(err, config.ErrCrewAtCap) { + return "", err + } + return seats.Work.Model, nil + }, + signNamed: config.AttributionModelAt(settings.ProfileDir), }, nil } @@ -226,6 +228,12 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { if err != nil { return err } + if strings.TrimSpace(inv.ExplicitFlags["high"]) == "" && road.defaultSeat != nil { + road.seat, err = road.defaultSeat() + 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). diff --git a/cmd/codeaf/carried_fresh_profile_test.go b/cmd/codeaf/carried_fresh_profile_test.go new file mode 100644 index 000000000..d802b8cb2 --- /dev/null +++ b/cmd/codeaf/carried_fresh_profile_test.go @@ -0,0 +1,116 @@ +//go:build !windows + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" +) + +// The real shell parser must let a model named by --high start on a fresh +// profile with only a provider key. The local server sees actual chat calls. +func TestSeniorDevExplicitShellModelRunsOnFreshProfile(t *testing.T) { + if testing.Short() { + t.Skip("drives the real senior-dev child") + } + program, ok := builtin.Find("senior-dev") + if !ok { + t.Skip("senior-dev is unavailable in this build") + } + workspace := seniorDevWorkspace(t) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-fixture") + t.Setenv(carriedChildEnv, "real") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[{"id":"fixture/vendor-model","canonical_slug":"fixture/vendor-model","name":"Fixture model","context_length":200000,"architecture":{"input_modalities":["text"],"output_modalities":["text"]},"pricing":{"prompt":"0","completion":"0","request":"0"},"supported_parameters":["tools","tool_choice","max_tokens"]}]}`) + return + } + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + var request struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), 400) + return + } + if request.Model != "fixture/vendor-model" { + http.Error(w, "wrong model: "+request.Model, 400) + return + } + call := int(calls.Add(1)) + w.Header().Set("Content-Type", "text/event-stream") + var tool, arguments string + switch call { + case 1: + tool, arguments = "write", `{"filePath":"feature.txt","content":"implemented by stub\n"}` + case 2: + tool, arguments = "write", `{"filePath":".senior-dev/checklist.md","content":"- [x] feature implemented\n"}` + case 3: + tool, arguments = "submit", `{"reason":"feature implemented","evidence":"make test exit 0","checklist_satisfied":true}` + } + if tool != "" { + argumentJSON, _ := json.Marshal(arguments) + fmt.Fprintf(w, "data: {\"id\":\"fixture-%d\",\"model\":\"fixture/vendor-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call-%d\",\"type\":\"function\",\"function\":{\"name\":%q,\"arguments\":%s}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":20,\"total_tokens\":120,\"cost\":0}}\n\ndata: [DONE]\n\n", call, call, tool, argumentJSON) + } else { + fmt.Fprintf(w, "data: {\"id\":\"fixture-%d\",\"model\":\"fixture/vendor-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":20,\"total_tokens\":120,\"cost\":0}}\n\ndata: [DONE]\n\n", call) + } + })) + defer server.Close() + t.Setenv("CODEAF_BASE_URL", server.URL+"/api/v1") + previous := carriedStdout + output := &lockedBuffer{} + carriedStdout = output + t.Cleanup(func() { carriedStdout = previous }) + err := runCarried(program, []string{"run", "--high", "openrouter/fixture/vendor-model", "--dir", workspace, "--", "Add", "the", "feature."}) + if code := exitCodeOf(err); code != 0 || calls.Load() == 0 { + t.Fatalf("fresh explicit shell run exited %d after %d chat calls: %s", code, calls.Load(), output.String()) + } + if content, err := os.ReadFile(workspace + "/feature.txt"); err != nil || string(content) != "implemented by stub\n" { + t.Fatalf("the shell did not make the feature: %q, %v", content, err) + } +} + +// With no explicit model, the shell still asks the profile for a work seat. +// A connected provider whose catalog offers none keeps the crew-door error. +func TestSeniorDevShellDefaultNamesCrewDoorWhenNoModelCanStart(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":[]}`) + })) + defer server.Close() + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-fixture") + t.Setenv("CODEAF_BASE_URL", server.URL+"/api/v1") + settings, err := config.Load() + if err != nil { + t.Fatal(err) + } + if err := config.SetCrewAllowed(settings.ProfileDir, "no-such-model-anywhere"); err != nil { + t.Fatal(err) + } + road, err := profileRoad() + if err != nil { + t.Fatal(err) + } + _, err = road.defaultSeat() + if err == nil || !strings.Contains(err.Error(), "/crew") { + t.Fatalf("empty profile default error = %v, want the crew door", err) + } +} diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go index cd17a28ea..fb81afbd1 100644 --- a/internal/delegate/cli.go +++ b/internal/delegate/cli.go @@ -39,7 +39,10 @@ type Invocation struct { // 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 + // ExplicitFlags records values the caller actually wrote, so a host can + // distinguish a model pin from a program's default after parsing. + ExplicitFlags map[string]string + body Body } // Brief is the brief's words, joined. @@ -90,6 +93,8 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) } return nil, fmt.Errorf("%s %s: %w", program.Name, command.Name, err) } + explicitFlags := map[string]string{} + fs.Visit(func(value *flag.Flag) { explicitFlags[value.Name] = value.Value.String() }) if *cost < 0 || *hours < 0 || math.IsNaN(*cost) || math.IsNaN(*hours) || math.IsInf(*cost, 0) || math.IsInf(*hours, 0) { return nil, fmt.Errorf("%s %s: a ceiling must be a finite, non-negative number", program.Name, command.Name) } @@ -107,12 +112,13 @@ func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) } return &Invocation{ Program: program, Command: command, - Workspace: abs, - Ceilings: ceilings, - JSON: *asJSON, - Args: fs.Args(), - Line: append([]string(nil), line...), - body: body, + Workspace: abs, + Ceilings: ceilings, + JSON: *asJSON, + Args: fs.Args(), + Line: append([]string(nil), line...), + ExplicitFlags: explicitFlags, + body: body, }, nil } diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go index 1b66c30f7..e992773d4 100644 --- a/internal/delegate/cli_test.go +++ b/internal/delegate/cli_test.go @@ -64,6 +64,26 @@ func TestParseTakesANamedCommandAndItsOwnFlags(t *testing.T) { } } +// A shell host can ask whether a model flag was written only after the real +// parser has separated flags from brief words and program defaults. +func TestParseRecordsOnlyFlagsTheCallerWrote(t *testing.T) { + program := testProgram(nil) + inv, err := Parse(program, []string{"run", "--variant=high", "--", "--variant=brief"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if inv.ExplicitFlags["variant"] != "high" || inv.Brief() != "--variant=brief" { + t.Fatalf("explicit flags = %#v, brief = %q", inv.ExplicitFlags, inv.Brief()) + } + bare, err := Parse(program, []string{"run", "brief"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if _, written := bare.ExplicitFlags["variant"]; written { + t.Fatalf("an unwritten program flag appeared in %#v", bare.ExplicitFlags) + } +} + // The line a host starts its child with is the line Parse reads back. func TestChildArgsParseBackToTheSameInvocation(t *testing.T) { program := testProgram(nil) diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go index 766e556fd..3bbd37651 100644 --- a/internal/session/program_ground_test.go +++ b/internal/session/program_ground_test.go @@ -184,3 +184,20 @@ func TestAProgramIsNotHandedAModelNoServiceServes(t *testing.T) { t.Fatalf("no model named was refused: %+v", choice) } } + +// A chat proposal with via and an explicit connected model resolves the +// model before any crew default, even when the profile has no crew rows. +func TestProgramProposalNamesAConnectedModelWithoutCrewRows(t *testing.T) { + router := modelsource.DefaultSource("https://openrouter.ai/api/v1") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.TaskModel = "" + config.TaskModels = nil + config.Sources = modelsource.NewSet(modelsource.Connected{ + Source: router, Key: "sk-or-v1-fixture", Address: router.Address, + }) + }) + choice := agent.resolveProgramModels("openrouter/fixture/vendor-model") + if choice.problem != "" || choice.model != "openrouter/fixture/vendor-model" { + t.Fatalf("explicit program model on a fresh crew = %+v", choice) + } +} From e236d4454546fd1e591377280efbee8aa00f2c16 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 19:06:43 -0400 Subject: [PATCH 194/195] seniordev: nothing unchecked is called passed, and the rescue is bounded Three ways a run could claim or cost more than it should. A comparison of the folder with the submitted candidate that failed (git gone, say) left the run at pass, and even a failed restore could keep a "build and tests passed" reason beside its pass-unverified status. Both now end unchecked, and every field of the terminal record agrees with the final status. A verification command that ran zero tests counted as a pass: a real run's unittest fallback ran plain discovery from the root, found nothing in a tests/ folder without __init__.py, printed "Ran 0 tests ... OK", and the run said its build and tests passed. An empty run from any runner the discovery picks now ends unchecked with a sentence saying so, and the unittest fallback discovers the project's test folders. The rescue before a restore copied whatever had changed, of any size, with the project's permissions. It now checks a 25 MiB per-file and 250 MiB total limit before it touches anything; over them nothing is restored and the run ends unchecked, and what it does copy is private to the user (0700 folders, 0600 files). The manual states all of it, and a test keeps its figures on the constants. Review of #1488, final verification V4.3, V4.5, V4.6. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/changes/unreleased/1488-senior-dev.md | 8 +- internal/manual/chat/senior-dev.md | 51 +++++-- internal/seniordev/app/full_verification.go | 4 + .../seniordev/app/full_verification_run.go | 16 ++- .../app/python_zero_verification_test.go | 60 ++++++++ internal/seniordev/app/run.go | 6 +- .../app/solo_compare_failure_test.go | 50 +++++++ internal/seniordev/app/solo_finalize.go | 97 ++++++++++++- .../seniordev/app/solo_rescue_limits_test.go | 133 ++++++++++++++++++ .../app/solo_restore_failure_test.go | 3 + internal/seniordev/app/solo_ship.go | 23 ++- .../seniordev/app/verification_no_tests.go | 45 ++++++ .../app/verification_no_tests_test.go | 37 +++++ .../session/fullverification/discovery.go | 25 +++- .../fullverification/discovery_test.go | 20 +++ 15 files changed, 560 insertions(+), 18 deletions(-) create mode 100644 internal/seniordev/app/python_zero_verification_test.go create mode 100644 internal/seniordev/app/solo_compare_failure_test.go create mode 100644 internal/seniordev/app/solo_rescue_limits_test.go create mode 100644 internal/seniordev/app/verification_no_tests.go create mode 100644 internal/seniordev/app/verification_no_tests_test.go diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md index 01b085e7f..cac499a86 100644 --- a/docs/changes/unreleased/1488-senior-dev.md +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -58,11 +58,15 @@ invalidates: - "A run in a gitignored folder inside a repository said at its start that the repository holds the home folder. The start receipt now says the folder is ignored there, so the run works in place without a branch." - "The general task pages said a task has no dollar limit of its own and is never stopped on its own dollar count. They now say that of an ordinary `/task` and point to senior-dev's run ceiling." - "`/budget conversation` in an open chat saved the figure and showed it as active while the conversation kept the limit it was opened with, so its next turn, task and senior-dev run spent against the old one. The open conversation now takes the new limit before the receipt says so, on the engine road and `--no-host`; if an older engine host cannot take it, the receipt says it applies to the next conversation." - - "A commit senior-dev's model made itself through bash took the person's git identity and carried no `Assisted-by`. The model's commands now commit as the run, and a run-made tip without the credit has it added at the finish; a commit from before the run is never amended." + - "A commit senior-dev's model made itself through bash took the person's git identity. The model's commands now commit as the run; codeaf puts `Assisted-by` only in a finishing commit it makes when something remains to stage. With nothing to stage it makes no credit commit and rewrites no existing commit, whether made by the model, the engine or the person, signed or pushed." - "A restore that could not set the person's later edits aside left the folder as it was (nothing lost) but still ended with `the project's own build and tests passed`, about a folder holding changes nothing checked. The run now ends unchecked and says the folder could not be put back, and why." - - "In the common ending, where every write was already checkpointed by the engine and nothing was left to stage, no commit on the run's branch carried `Assisted-by`. The engine's own checkpoint at the tip is now credited too, and a tip the model already pushed is never amended." + - "The common ending used to amend the engine's last checkpoint to add `Assisted-by`, and a direct-URL push or signed commit could be rewritten. The finishing path now leaves every existing commit byte-identical; only a new finishing commit can carry the credit." - "The Windows build of codeaf stopped compiling once internal/session read senior-dev's generated-path list, because every file of internal/seniordev/util is !windows. The two run facts codeaf reads (the engine's commit identity and the list of caches a run's tests leave) now live in internal/gitidentity, which builds everywhere, and the engine itself still never reaches a Windows build." - "Dev's per-task crew router and one-column chat arrived after senior-dev's first review. A senior-dev run now keeps its requested models or one profile worker recommendation, outside the ordinary task's crew cap; its badge fits the shared side column. The combined remote door is wire version 19, so an older engine refuses before it can silently drop a delegate start." + - "A failed comparison between the submitted candidate and the checkout left the run at `pass`, while a failed restore could leave a stale `build and tests passed` terminal reason. Both now end unchecked with the comparison or restore failure in the terminal and ending." + - "A shell run with explicit `--high` was refused on a fresh profile before its first model call because profile seat resolution ran first. It now resolves a profile seat only when no model was explicitly named; chat proposals already resolved a prefixed explicit model before their task default." + - "Python unittest discovery from the project root could exit zero after running zero tests in a `tests/` folder without `__init__.py`. The fallback now discovers top-level test folders, and an empty-suite report from any discovered runner ends a submitted run unchecked with the command named." + - "A restore rescue could copy arbitrarily large, newly ignored files with their project permissions. It now preflights a 25 MiB per-file and 250 MiB total bound, refuses restoration before touching the project when a bound is exceeded, and keeps rescue directories at 0700 and copied files at 0600." --- `docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate` diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 55bea68c3..853a02484 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -288,9 +288,17 @@ work: `senior-dev cannot contain its shell processes on this machine: <error>`. ## How does senior-dev run Python tests — pytest, unittest, missing pytest -For a Python project with test files, senior-dev uses `python3 -m pytest` when pytest is -installed or the project declares it. Otherwise it runs `python3 -m unittest discover`, -so a project using Python's standard library tests does not fail for lack of pytest. +senior-dev chooses a project's test command in this order: CI, `AGENTS.md`, a +declared script such as a Makefile `test` target, `README.md` or +`CONTRIBUTING.md`, then an ecosystem default. For a Python project with test +files, the default is `python3 -m pytest` when pytest is installed or declared. +Otherwise it runs unittest discovery in each top-level `test/` or `tests/` +folder holding `test*.py`; without either folder it runs plain +`python3 -m unittest discover`. A folder with `__init__.py` uses `-t .` to +resolve project imports. Older Python cannot use `-t .` on a folder without +`__init__.py`, so that folder uses `-s <folder>` alone. A test command that +reports it ran no tests leaves the submission unchecked, even if it exits zero; +the ending says `no tests were found by <command>`. ## 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 @@ -373,10 +381,31 @@ or named. If the copy cannot be made, nothing is restored and your folder is lef as it was; the run then does not say its build and tests passed, and the ending adds `The folder changed after senior-dev's last check and could not be put back (<why>), so it also holds later changes that nothing checked`. +If codeaf cannot compare the folder with the submitted candidate at all, it +also leaves the folder untouched and ends unchecked: `The folder could not be +checked against what was verified (<why>), so it may hold later changes that +nothing checked`. Files git ignored when the run started are not committed even if senior-dev changes `.gitignore`. Python `__pycache__/`, `.pytest_cache/` and `*.pyc` files made by its checks are not committed either. Those files stay in your folder. +## What does a rescue copy, where is it kept, and how large can it be? + +Before restoring a submitted candidate or checkpoint, senior-dev copies files +changed after submission that the restore would replace, including files newly +ignored by a rule added during the run. It records later deletions in +`deleted-files.txt`; links are kept as links, and files ignored when the run +started are left in place. The copy is under the state root at +`v3/carried/senior-dev/rescued/run-…` (normally +`~/.codeaf/v3/carried/senior-dev/rescued/run-…`), outside the project. If the +state root itself is inside the project, the rescue instead uses the machine's +temporary `codeaf-rescued/` folder so the restore cannot erase its own copy. +The rescue folder is private to your account (mode `0700`); copied regular files +and the deletion manifest use `0600`. Each copied file is limited to **25 MiB** +and one rescue to **250 MiB** total. If a file or the total exceeds the limit, +senior-dev refuses the restore before changing the folder; the ending says it +could not be put back and that the folder holds later changes nothing checked. + ## 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, @@ -414,9 +443,11 @@ that run, including a model that answered in place of the one asked for. If no m answered, there is no `Assisted-by` trailer. The attribution setting still decides whether answered model names are shown. If senior-dev runs `git commit` itself, the commit uses codeaf's run identity rather -than your Git identity. When that commit is the branch tip and there is nothing -left to stage, codeaf adds the answered model's `Assisted-by` credit to its message -without changing its files. It never rewrites a commit from before the run. +than your Git identity. The `Assisted-by` credit is added only to a finishing +commit codeaf makes when there is something left to stage. When the branch is +already clean, codeaf makes no commit for credit. It never amends, rebases or +rewrites a commit, including one senior-dev or its model made earlier in the +run, one you pushed, or one you signed. 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 @@ -594,11 +625,15 @@ 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. +## Which models does a senior-dev shell run use — --high, fresh profile, no crew + **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 usable crew model uses it. A shell run -also reads one worker recommendation from your profile, then falls back to this list -if that model cannot be used. +without `--high` asks the profile for a worker recommendation; if no connected +model can fill that seat, it says to widen or pin `/crew` models. An explicit +`--high <model>` runs on a fresh profile with a provider key and no crew rows; +that model is used without resolving a profile seat. **At a shell you choose**: `--high` replaces the list, `--low` sets the summaries' models, and `--variant` sets the reasoning effort every call asks for. diff --git a/internal/seniordev/app/full_verification.go b/internal/seniordev/app/full_verification.go index 541e174f3..c75d40b3b 100644 --- a/internal/seniordev/app/full_verification.go +++ b/internal/seniordev/app/full_verification.go @@ -21,6 +21,10 @@ type projectVerificationResult struct { Prompt string Failed *fullverification.Entrypoint Failure string + // NoTests records a command that reported an empty suite, regardless of + // its process exit status. An empty suite does not verify a candidate. + NoTests bool + NoTestsCommand 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. diff --git a/internal/seniordev/app/full_verification_run.go b/internal/seniordev/app/full_verification_run.go index 0388315a2..ee2a9e21e 100644 --- a/internal/seniordev/app/full_verification_run.go +++ b/internal/seniordev/app/full_verification_run.go @@ -31,6 +31,7 @@ type verificationObservation struct { exitCode int timedOut bool tail string + noTests bool evidence map[string]any // suiteDead marks a failure whose output shows the suite aborted before // running at all (verification_deadtree.go). @@ -158,6 +159,9 @@ func (run *projectVerificationRun) execute(observation *verificationObservation) output = err.Error() } observation.tail = verificationOutputTail(output, 600) + if entrypoint.Kind == fullverification.KindTest && !observation.timedOut { + observation.noTests = noTestsReported(output) + } // Suite-abort detection for the unsubmitted-tree finalizer // (verification_deadtree.go). if observation.exitCode != 0 && !observation.timedOut && suiteDeadOutput(output) { @@ -213,6 +217,9 @@ func (run *projectVerificationRun) commandEvidence( if observation.suiteDead { evidence["suite_dead"] = true } + if observation.noTests { + evidence["no_tests"] = true + } if observation.safetyRegression { evidence["safety_regression"] = true } @@ -236,9 +243,13 @@ func (run *projectVerificationRun) record(observation verificationObservation) { "[senior-dev] full verification %s: %s (exit=%d, source=%s)\n", entrypoint.Kind, entrypoint.Command, observation.exitCode, entrypoint.Source, )) - if observation.exitCode == 0 { + if observation.exitCode == 0 && !observation.noTests { return } + if observation.noTests { + run.result.NoTests = true + run.result.NoTestsCommand = entrypoint.Command + } // 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 @@ -277,6 +288,9 @@ func (run *projectVerificationRun) recordNewFailure(observation verificationObse "project %s verification failed: `%s` exited %d", entrypoint.Kind, entrypoint.Command, observation.exitCode, ) + if observation.noTests { + issue = fmt.Sprintf("no tests were found by `%s`", entrypoint.Command) + } if observation.timedOut { run.result.TimedOut = true issue = fmt.Sprintf( diff --git a/internal/seniordev/app/python_zero_verification_test.go b/internal/seniordev/app/python_zero_verification_test.go new file mode 100644 index 000000000..1aa1cb9f2 --- /dev/null +++ b/internal/seniordev/app/python_zero_verification_test.go @@ -0,0 +1,60 @@ +//go:build !windows + +package app + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// A tests/ folder without __init__.py was invisible to plain unittest +// discovery. The fallback must run its test and record the selected command. +func TestUnittestFallbackRunsTestsFolderWithoutPackageMarker(t *testing.T) { + python, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 is not installed") + } + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "python3"), []byte("#!/bin/sh\nexec '"+python+"' -S \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + runner := verificationWorkspace(t, map[string]string{ + "store.py": "def value():\n return 8\n", + "tests/test_store.py": "import unittest\nfrom store import value\nclass StoreTest(unittest.TestCase):\n def test_value(self):\n self.assertEqual(value(), 8)\n", + }) + result := runner.runProjectVerification(context.Background()) + if result.Failed != nil || !strings.Contains(result.Prompt, "Ran 1 test") || !strings.Contains(result.Prompt, "discover -s tests") { + t.Fatalf("unittest folder was not verified: %+v", result) + } +} + +// A declared command that exits zero after discovering no tests cannot turn +// the submitted candidate into a verified pass. +func TestZeroTestCommandEndsSubmittedRunUnchecked(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is not installed") + } + runner, state, _, _ := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "tests", "test_store.py"), "import unittest\nclass StoreTest(unittest.TestCase):\n def test_value(self): self.assertTrue(True)\n"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "Makefile"), "test:\n\t@python3 -m unittest discover\n"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "candidate\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil { + t.Fatal(err) + } + outcome := &soloOutcome{} + runner.soloShip(context.Background(), state, outcome, nil) + if outcome.Status != "pass-unverified" || !strings.Contains(outcome.TerminalReason, "no tests were found by `make test`") { + t.Fatalf("zero-test command ended as %q: %q", outcome.Status, outcome.TerminalReason) + } +} diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go index e8083bd42..c762c7d31 100644 --- a/internal/seniordev/app/run.go +++ b/internal/seniordev/app/run.go @@ -317,7 +317,11 @@ func endingOf(result pipelineResult) delegate.Ending { } } if failed, _ := extra["restore_failed"].(string); failed != "" { - ending.Message += ". The folder changed after senior-dev's last check and could not be put back (" + failed + "), so it also holds later changes that nothing checked" + if strings.HasPrefix(failed, "could not compare the folder") { + ending.Message += ". The folder could not be checked against what was verified (" + failed + "), so it may hold later changes that nothing checked" + } else { + ending.Message += ". The folder changed after senior-dev's last check and could not be put back (" + failed + "), so it also holds later changes that nothing checked" + } } if reason, _ := extra["reason"].(string); reason != "" && reason != ending.Message { ending.Reason = reason diff --git a/internal/seniordev/app/solo_compare_failure_test.go b/internal/seniordev/app/solo_compare_failure_test.go new file mode 100644 index 000000000..4044d47f5 --- /dev/null +++ b/internal/seniordev/app/solo_compare_failure_test.go @@ -0,0 +1,50 @@ +//go:build !windows + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// A failed tree comparison gives no evidence about the checkout left behind. +// The terminal and the ending must both say it could not be checked. +func TestFailedCandidateComparisonEndsUnchecked(t *testing.T) { + runner, state, _, _ := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "candidate\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "untested later bytes\n"); err != nil { + t.Fatal(err) + } + gitDir := filepath.Join(runner.workspace, ".git") + hidden := filepath.Join(runner.workspace, ".git-paused") + if err := os.Rename(gitDir, hidden); err != nil { + t.Fatal(err) + } + defer os.Rename(hidden, gitDir) + outcome := &soloOutcome{Status: "pass", Frozen: state.candidate()} + runner.soloRestoreIfDiverged(state, outcome) + runner.soloTerminal(outcome, "submitted, and its build and tests passed") + ending := endingOf(pipelineResult{Status: delegate.StatusPass, Terminal: outcome.TerminalData}) + if outcome.Status == "pass" || strings.Contains(ending.Message, "build and tests passed") { + t.Fatalf("comparison failed but status=%q, ending=%q, restore_failed=%q", outcome.Status, ending.Message, outcome.RestoreFailed) + } + if reason := outcome.TerminalData["reason"].(string); strings.Contains(reason, "build and tests passed") || !strings.Contains(reason, "could not be checked") { + t.Fatalf("terminal reason disagrees with unchecked status: %q", reason) + } + if !strings.Contains(outcome.RestoreFailed, "could not compare the folder with the submitted candidate") { + t.Fatalf("comparison failure was not recorded: %q", outcome.RestoreFailed) + } + if !strings.Contains(ending.Message, "could not be checked against what was verified") { + t.Fatalf("ending hid the failed comparison: %q", ending.Message) + } +} diff --git a/internal/seniordev/app/solo_finalize.go b/internal/seniordev/app/solo_finalize.go index 18e3eb015..d18b169d4 100644 --- a/internal/seniordev/app/solo_finalize.go +++ b/internal/seniordev/app/solo_finalize.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "io" "os" "path/filepath" "sort" @@ -16,6 +17,13 @@ import ( "github.com/Agent-Field/codeaf/internal/home" ) +// A rescue can include newly ignored files that the person did not expect +// codeaf to copy. Twenty-five MiB covers ordinary source files, while ten +// such files fit in one rescue; larger generated output stays in the project +// and leaves the checkout unchecked instead of filling the state root. +const rescueFileLimitBytes int64 = 25 << 20 +const rescueTotalLimitBytes int64 = 250 << 20 + // 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. @@ -259,17 +267,41 @@ func (runner *pipeline) soloRestoreTree(commitSHA, wantTree string) error { func (runner *pipeline) rescueBeforeRestore(paths []string) error { var deleted []string var existing []string + var total int64 for _, path := range paths { - _, err := os.Lstat(filepath.Join(runner.workspace, filepath.FromSlash(path))) + info, err := os.Lstat(filepath.Join(runner.workspace, filepath.FromSlash(path))) switch { case os.IsNotExist(err): deleted = append(deleted, path) case err != nil: return err default: + if !info.Mode().IsRegular() && info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("cannot preserve %s before restore", path) + } + if info.Size() > rescueFileLimitBytes { + return fmt.Errorf("a changed file is larger than %d MiB: %s", rescueFileLimitBytes>>20, path) + } + if info.Size() < 0 || info.Size() > rescueTotalLimitBytes-total { + return fmt.Errorf("changed files exceed the %d MiB rescue limit", rescueTotalLimitBytes>>20) + } + total += info.Size() existing = append(existing, path) } } + // The deletion manifest is a rescued file too. Check its prospective size + // before making any rescue folder or touching the project. + var manifestBytes int64 + for _, path := range deleted { + if strings.ContainsAny(path, "\r\n") { + path = strconv.Quote(path) + } + entryBytes := int64(len(path) + 1) + if entryBytes > rescueFileLimitBytes-manifestBytes || entryBytes > rescueTotalLimitBytes-total-manifestBytes { + return fmt.Errorf("deletion manifest exceeds the rescue size limit") + } + manifestBytes += entryBytes + } if len(deleted) == 0 && len(existing) == 0 { return nil } @@ -280,6 +312,9 @@ func (runner *pipeline) rescueBeforeRestore(paths []string) error { if err := os.MkdirAll(root, 0o700); err != nil { return err } + if err := os.Chmod(root, 0o700); err != nil { + return err + } if runner.rescuePath == "" { created, err := os.MkdirTemp(root, "run-") if err != nil { @@ -291,6 +326,7 @@ func (runner *pipeline) rescueBeforeRestore(paths []string) error { if runner.rescueCount > 0 { destination = filepath.Join(destination, fmt.Sprintf("later-%d", runner.rescueCount+1)) } + remaining := rescueTotalLimitBytes - manifestBytes for _, path := range existing { from := filepath.Join(runner.workspace, filepath.FromSlash(path)) info, err := os.Lstat(from) @@ -303,17 +339,23 @@ func (runner *pipeline) rescueBeforeRestore(paths []string) error { } switch { case info.Mode().IsRegular(): - if err := copyFile(from, to, info.Mode()); err != nil { + copied, err := copyRescueFile(from, to, remaining) + if err != nil { return err } + remaining -= copied case info.Mode()&os.ModeSymlink != 0: link, err := os.Readlink(from) if err != nil { return err } + if int64(len(link)) > rescueFileLimitBytes || int64(len(link)) > remaining { + return fmt.Errorf("a changed file is larger than the rescue size limit: %s", path) + } if err := os.Symlink(link, to); err != nil { return err } + remaining -= int64(len(link)) default: return fmt.Errorf("cannot preserve %s before restore", from) } @@ -359,7 +401,14 @@ func (runner *pipeline) rescueBeforeRestore(paths []string) error { all = append(all, path) } sort.Strings(all) - if err := os.WriteFile(manifest, []byte(strings.Join(all, "\n")+"\n"), 0o600); err != nil { + body := []byte(strings.Join(all, "\n") + "\n") + if int64(len(body)) > rescueFileLimitBytes || int64(len(body)) > remaining+manifestBytes { + return fmt.Errorf("deletion manifest exceeds the rescue size limit") + } + if err := os.WriteFile(manifest, body, 0o600); err != nil { + return err + } + if err := os.Chmod(manifest, 0o600); err != nil { return err } runner.rescueDeleted = true @@ -367,3 +416,45 @@ func (runner *pipeline) rescueBeforeRestore(paths []string) error { runner.rescueCount++ return nil } + +// copyRescueFile copies no more than either bound and refuses a source that +// grew since preflight. The extra read detects growth without copying it. +func copyRescueFile(source, destination string, remaining int64) (copied int64, err error) { + limit := min(rescueFileLimitBytes, remaining) + in, err := os.Open(source) + if err != nil { + return 0, err + } + defer in.Close() + out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return 0, err + } + defer func() { + if err != nil { + _ = os.Remove(destination) + } + }() + copied, err = io.CopyN(out, in, limit) + if err != nil && err != io.EOF { + _ = out.Close() + return copied, err + } + var extra [1]byte + n, readErr := in.Read(extra[:]) + if readErr != nil && readErr != io.EOF { + _ = out.Close() + return copied, readErr + } + if n > 0 { + _ = out.Close() + return copied, fmt.Errorf("a changed file is larger than the %d MiB per-file or %d MiB total rescue limit: %s", rescueFileLimitBytes>>20, rescueTotalLimitBytes>>20, source) + } + if err = out.Close(); err != nil { + return copied, err + } + if err = os.Chmod(destination, 0o600); err != nil { + return copied, err + } + return copied, nil +} diff --git a/internal/seniordev/app/solo_rescue_limits_test.go b/internal/seniordev/app/solo_rescue_limits_test.go new file mode 100644 index 000000000..545815c9f --- /dev/null +++ b/internal/seniordev/app/solo_rescue_limits_test.go @@ -0,0 +1,133 @@ +//go:build !windows + +package app + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// The manual's limits must move in the same change as the copying bounds. +func TestRescueManualQuotesTheBoundedCopyLimits(t *testing.T) { + page, err := os.ReadFile(filepath.Join("..", "..", "manual", "chat", "senior-dev.md")) + if err != nil { + t.Fatal(err) + } + for _, limit := range []int64{rescueFileLimitBytes, rescueTotalLimitBytes} { + figure := fmt.Sprintf("%d MiB", limit>>20) + if !strings.Contains(string(page), figure) { + t.Fatalf("rescue manual omits %s", figure) + } + } +} + +// The total bound is checked before the first file is copied, even when every +// individual file fits under the per-file bound. +func TestRescueTotalLimitRefusesBeforeCopying(t *testing.T) { + stateRoot := t.TempDir() + t.Setenv("CODEAF_HOME", stateRoot) + workspace := t.TempDir() + runner := newPipeline(cliArgs{InPlace: true}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + var paths []string + for index := 0; index < int(rescueTotalLimitBytes/rescueFileLimitBytes)+1; index++ { + name := fmt.Sprintf("later-%02d.bin", index) + file := filepath.Join(workspace, name) + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Truncate(file, rescueFileLimitBytes); err != nil { + t.Fatal(err) + } + paths = append(paths, name) + } + if err := runner.rescueBeforeRestore(paths); err == nil || !strings.Contains(err.Error(), "250 MiB") { + t.Fatalf("over-total rescue = %v", err) + } + if _, err := os.Stat(filepath.Join(stateRoot, "v3", "carried", "senior-dev", "rescued")); !os.IsNotExist(err) { + t.Fatalf("over-total rescue wrote a folder: %v", err) + } +} + +// An oversized later file refuses the entire restore before any project path +// is changed, and the ending describes the checkout as unchecked. +func TestOversizedRescueLeavesTheFolderUntouchedAndUnchecked(t *testing.T) { + stateRoot := t.TempDir() + t.Setenv("CODEAF_HOME", stateRoot) + runner, state, _, _ := soloPipeline(t) + feature := filepath.Join(runner.workspace, "feature.txt") + if err := writeFile(feature, "candidate\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil { + t.Fatal(err) + } + if err := writeFile(feature, "later edit\n"); err != nil { + t.Fatal(err) + } + large := filepath.Join(runner.workspace, "large.bin") + if err := os.WriteFile(large, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Truncate(large, rescueFileLimitBytes+1); err != nil { + t.Fatal(err) + } + outcome := &soloOutcome{Status: "pass", Frozen: state.candidate()} + runner.soloRestoreIfDiverged(state, outcome) + runner.soloTerminal(outcome, "submitted, and its build and tests passed") + ending := endingOf(pipelineResult{Status: delegate.StatusPass, Terminal: outcome.TerminalData}) + if outcome.Status != "pass-unverified" || !strings.Contains(outcome.RestoreFailed, "larger than") { + t.Fatalf("oversized rescue ended as %q: %q", outcome.Status, outcome.RestoreFailed) + } + if strings.Contains(outcome.TerminalReason, "build and tests passed") || !strings.Contains(ending.Message, "could not be put back") { + t.Fatalf("unchecked ending = %q, terminal = %q", ending.Message, outcome.TerminalReason) + } + if data, err := os.ReadFile(feature); err != nil || string(data) != "later edit\n" { + t.Fatalf("later edit changed: %q, %v", data, err) + } + if info, err := os.Stat(large); err != nil || info.Size() != rescueFileLimitBytes+1 { + t.Fatalf("large file changed: %v, %v", info, err) + } + if _, err := os.Stat(filepath.Join(stateRoot, "v3", "carried", "senior-dev", "rescued")); !os.IsNotExist(err) { + t.Fatalf("oversized rescue made a folder before refusal: %v", err) + } +} + +// A successful rescue stays private even when the source files are readable +// by everyone in the project. +func TestRescueFolderAndFilesArePrivate(t *testing.T) { + stateRoot := t.TempDir() + t.Setenv("CODEAF_HOME", stateRoot) + workspace, _ := guardWorkspace(t) + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Events: newEventWriter(discardWriter{}), Notes: discardWriter{}}) + t.Cleanup(runner.runtime.Close) + wanted, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + checkpoint, err := runner.soloRecordTree(wanted, "candidate") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "notes.txt"), []byte("later\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "README.md")); err != nil { + t.Fatal(err) + } + if err := runner.soloRestoreTree(checkpoint, wanted); err != nil { + t.Fatal(err) + } + for path, want := range map[string]os.FileMode{runner.rescuePath: 0o700, filepath.Join(runner.rescuePath, "notes.txt"): 0o600, filepath.Join(runner.rescuePath, "deleted-files.txt"): 0o600} { + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != want { + t.Fatalf("rescue mode %s = %v, %v; want %v", path, info, err, want) + } + } +} diff --git a/internal/seniordev/app/solo_restore_failure_test.go b/internal/seniordev/app/solo_restore_failure_test.go index bc1ddffbb..1cc8e89bb 100644 --- a/internal/seniordev/app/solo_restore_failure_test.go +++ b/internal/seniordev/app/solo_restore_failure_test.go @@ -41,6 +41,9 @@ func TestAFailedRestoreEndsUncheckedAndSaysWhy(t *testing.T) { outcome := &soloOutcome{Status: "pass", Frozen: state.candidate()} runner.soloRestoreIfDiverged(state, outcome) runner.soloTerminal(outcome, "submitted, and its build and tests passed") + if reason := outcome.TerminalData["reason"].(string); strings.Contains(reason, "build and tests passed") || !strings.Contains(reason, "could not be checked") { + t.Fatalf("terminal reason disagrees with unchecked status: %q", reason) + } if outcome.Status != "pass-unverified" { t.Fatalf("a restore that failed left the outcome %q; the folder is not what was checked", outcome.Status) } diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go index 915d263e2..b2404b4e8 100644 --- a/internal/seniordev/app/solo_ship.go +++ b/internal/seniordev/app/solo_ship.go @@ -79,6 +79,14 @@ func (runner *pipeline) soloShip( "verification did not complete (an entrypoint hung); shipping the submitted candidate: %s", candidate.describe(), ) + case verification.NoTests && verification.NewFailures == 1: + // An empty suite gives no evidence about the candidate even when its + // command exits zero, so the ending must not call it a passed test. + outcome.Status = "pass-unverified" + endingReason = fmt.Sprintf( + "no tests were found by `%s`; shipping the submitted candidate unchecked: %s", + verification.NoTestsCommand, 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 @@ -135,8 +143,11 @@ func (runner *pipeline) soloRestoreIfDiverged(state *soloState, outcome *soloOut } current, err := runner.currentTreeSHA() if err != nil { - runner.note("[senior-dev] ship: could not compare the tree to the frozen candidate: " + - err.Error() + "\n") + outcome.RestoreFailed = "could not compare the folder with the submitted candidate: " + err.Error() + if outcome.Status == "pass" { + outcome.Status = "pass-unverified" + } + runner.note("[senior-dev] ship: " + outcome.RestoreFailed + "\n") return } if current == candidate.TreeSHA { @@ -182,6 +193,11 @@ func (runner *pipeline) soloRestoreIfDiverged(state *soloState, outcome *soloOut // 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) { + // THE CANDIDATE'S PASS IS NOT A PASS FOR A FOLDER WE COULD NOT COMPARE + // OR RESTORE. Replace any reason prepared before that final folder check. + if outcome.RestoreFailed != "" { + reason = "the folder could not be checked against what was verified: " + outcome.RestoreFailed + } data := map[string]any{ "status": outcome.Status, "reason": reason, "submitted": outcome.Frozen != nil, "nudges": outcome.Nudges, @@ -251,6 +267,9 @@ func missingEntrypointFailure(result projectVerificationResult) bool { } func verificationFailureSummary(result projectVerificationResult, failing int) string { + if result.NoTests && result.NewFailures == 1 { + return "no tests were found by `" + result.NoTestsCommand + "`" + } if missingEntrypointFailure(result) { return "no " + string(result.Failed.Kind) + " entrypoint could be discovered" } diff --git a/internal/seniordev/app/verification_no_tests.go b/internal/seniordev/app/verification_no_tests.go new file mode 100644 index 000000000..1e98e6e93 --- /dev/null +++ b/internal/seniordev/app/verification_no_tests.go @@ -0,0 +1,45 @@ +//go:build !windows + +package app + +import ( + "regexp" + "strings" +) + +var ( + unitZeroTests = regexp.MustCompile(`(?i)\bRan 0 tests?\b`) + goNoTestFiles = regexp.MustCompile(`(?m)^\?[ \t]+[^\n]+\[no test files\][ \t]*$`) + goSomeTests = regexp.MustCompile(`(?m)^(?:ok|FAIL)[ \t]+[^\n]+$`) + cargoZeroTests = regexp.MustCompile(`(?i)\brunning 0 tests?\b`) + cargoSomeTests = regexp.MustCompile(`(?i)\brunning [1-9][0-9]* tests?\b`) + mavenZeroTests = regexp.MustCompile(`(?i)\bTests run: 0\b`) + mavenSomeTests = regexp.MustCompile(`(?i)\bTests run: [1-9][0-9]*\b`) +) + +// noTestsReported recognizes the empty-suite words of the runners discovery +// can choose, including wrappers such as make test. A mixed Go, unittest, +// Cargo, or Maven run with positive evidence is not called empty. +func noTestsReported(output string) bool { + lower := strings.ToLower(output) + switch { + case unitZeroTests.MatchString(output): + return true + case strings.Contains(lower, "no tests ran"), strings.Contains(lower, "collected 0 items"): + return true + case strings.Contains(lower, "no tests found"), strings.Contains(lower, "no test files found"), + strings.Contains(lower, "no test files matched"), strings.Contains(lower, "no test suites found"): + return true + case goNoTestFiles.MatchString(output) && !goSomeTests.MatchString(output): + return true + case cargoZeroTests.MatchString(output) && !cargoSomeTests.MatchString(output): + return true + case mavenZeroTests.MatchString(output) && !mavenSomeTests.MatchString(output): + return true + case strings.Contains(lower, "no test is available"), strings.Contains(lower, "no tests were found"): + return true + case strings.Contains(lower, "no tests to run"), strings.Contains(lower, ":test no-source"): + return true + } + return false +} diff --git a/internal/seniordev/app/verification_no_tests_test.go b/internal/seniordev/app/verification_no_tests_test.go new file mode 100644 index 000000000..a936b65e7 --- /dev/null +++ b/internal/seniordev/app/verification_no_tests_test.go @@ -0,0 +1,37 @@ +//go:build !windows + +package app + +import "testing" + +// The discovery can select these runners directly or through a project +// command. Their empty-suite reports never count as a verified test run. +func TestEveryDiscoveredRunnerReportsAnEmptySuite(t *testing.T) { + cases := []struct { + name, output string + empty bool + }{ + {"unittest", "Ran 0 tests in 0.001s\nOK", true}, + {"pytest", "no tests ran in 0.01s", true}, + {"go", "? example.test/pkg [no test files]\n", true}, + {"vitest", "No test files found, exiting with code 1", true}, + {"jest", "No tests found, exiting with code 1", true}, + {"cargo", "running 0 tests\ntest result: ok. 0 passed", true}, + {"maven", "Tests run: 0, Failures: 0", true}, + {"maven no tests", "No tests to run.", true}, + {"gradle", "> Task :test NO-SOURCE", true}, + {"ctest", "No tests were found!!!", true}, + {"dotnet", "No test is available in assembly", true}, + {"go mixed", "? example.test/empty [no test files]\nok example.test/tested 0.01s", false}, + {"unittest folder missed", "Ran 0 tests in 0.001s\nRan 1 test in 0.001s", true}, + {"cargo mixed", "running 0 tests\nrunning 2 tests", false}, + {"maven mixed", "Tests run: 0\nTests run: 3", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := noTestsReported(tc.output); got != tc.empty { + t.Fatalf("noTestsReported(%q) = %v, want %v", tc.output, got, tc.empty) + } + }) + } +} diff --git a/internal/seniordev/session/fullverification/discovery.go b/internal/seniordev/session/fullverification/discovery.go index 7843aeb61..955f41713 100644 --- a/internal/seniordev/session/fullverification/discovery.go +++ b/internal/seniordev/session/fullverification/discovery.go @@ -549,13 +549,36 @@ func ecosystemDefaults(workspace string) []Entrypoint { if declaresPytest(workspace) || pythonHasPytest(workspace) { add(KindTest, "python3 -m pytest", "Python test files") } else { - add(KindTest, "python3 -m unittest discover", "Python test files") + add(KindTest, unittestDiscoveryCommand(workspace), "Python test files") } } } return entries } +// unittestDiscoveryCommand names each top-level test folder that actually +// holds tests. Python 3.10 cannot use -t . for a folder without __init__.py: +// omitting -t in that case still puts the project root on sys.path and runs +// the tests instead of raising "Start directory is not importable". +func unittestDiscoveryCommand(workspace string) string { + var commands []string + for _, dir := range []string{"test", "tests"} { + files, err := filepath.Glob(filepath.Join(workspace, dir, "test*.py")) + if err != nil || len(files) == 0 { + continue + } + command := "python3 -m unittest discover -s " + dir + if fileExists(filepath.Join(workspace, dir, "__init__.py")) { + command += " -t ." + } + commands = append(commands, command) + } + if len(commands) == 0 { + return "python3 -m unittest discover" + } + return strings.Join(commands, " && ") +} + // declaresPytest keeps an explicit project choice even if this machine lacks // the package; that failure is a missing dependency rather than a test style // the verifier should silently replace. diff --git a/internal/seniordev/session/fullverification/discovery_test.go b/internal/seniordev/session/fullverification/discovery_test.go index d839aca3f..7660cd8ab 100644 --- a/internal/seniordev/session/fullverification/discovery_test.go +++ b/internal/seniordev/session/fullverification/discovery_test.go @@ -599,3 +599,23 @@ func planHasEntrypointKind(plan Plan, kind EntrypointKind) bool { } return false } + +// The fallback discovers both conventional top-level test folders, while an +// explicit pytest project keeps its pytest command. +func TestPythonDefaultsDiscoverBothTestFoldersAndKeepPytest(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "test/__init__.py", "") + writeDiscoveryFile(t, workspace, "test/test_one.py", "") + writeDiscoveryFile(t, workspace, "tests/test_two.py", "") + want := "python3 -m unittest discover -s test -t . && python3 -m unittest discover -s tests" + if command := unittestDiscoveryCommand(workspace); command != want { + t.Fatalf("unittest fallback = %q, want %q", command, want) + } + writeDiscoveryFile(t, workspace, "pytest.ini", "[pytest]\n") + plan := Discover(workspace) + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind == KindTest && entrypoint.Command != "python3 -m pytest" { + t.Fatalf("declared pytest changed to %q", entrypoint.Command) + } + } +} From 49ad1ea67a4c0ae3d442379470bd28249dc53062 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 25 Sep 2026 19:18:16 -0400 Subject: [PATCH 195/195] manual: the crew's $5 task limit is pinned where the pages say "ordinary" Dev's #1518 pinned three sentences that carry the crew's default per-task limit ("No task may cost more than its limit: $5 ..."). With senior-dev in the tree those sentences are false for a senior-dev run, which is not a crew task and keeps its own ceiling, and the pages already say "an ordinary /task" there. The pins now match the qualified sentences; the figure still comes from config.CrewTaskCapDefault. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- internal/manual/truth_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/manual/truth_test.go b/internal/manual/truth_test.go index be6d047e3..da3a8ce19 100644 --- a/internal/manual/truth_test.go +++ b/internal/manual/truth_test.go @@ -195,21 +195,24 @@ func quotedFacts(t *testing.T) []quotedFact { value: taskDollars, others: otherTaskDollars, quotes: []quotedIn{ {"models-and-cost", "**cap** — `per task %s · crew daily cap none`"}, - {"models-and-cost", "No task may cost more than its limit: **%s** unless you set another"}, + // THE $5 IS THE CREW'S, and a senior-dev run is not a crew task: it keeps + // its own ceiling (delegate.DefaultSeniorDevCostUSD and the conversation's + // limit), so the pages say "ordinary" where this figure applies. + {"models-and-cost", "No ordinary task may cost more than its limit: **%s** unless you set another"}, {"models-and-cost", "(`per task %s · crew daily cap none`)"}, {"models-and-cost", "an emptied box is %s again"}, {"models-and-cost", "this task reached its %s limit · raise it in /crew"}, {"models-and-cost", "held under the %s.00 task limit"}, {"models-and-cost", "| **per task** | `%s a task`"}, {"models-and-cost", "## What may a task spend — %s a task unless you set another"}, - {"models-and-cost", "**A task carries a dollar limit of its own: %s unless you set another.**"}, + {"models-and-cost", "**An ordinary `/task` carries a dollar limit of its own: %s unless you set another.**"}, {"models-and-cost", "`per task` row — `%s a task`"}, {"commands", "the most one task may spend — %s unless set"}, {"commands", "the most one task may spend — %s unless set."}, {"commands", "per task %s · crew daily cap $5.00"}, {"running-from-the-terminal", "**Every run is held to the per-task limit**, %s unless"}, {"running-from-the-terminal", "this task reached its %s limit · raise it in /crew"}, - {"tasks", "**Every task also has a money limit of its own: %s unless you set another**"}, + {"tasks", "**An ordinary `/task`, or any task codeaf's own worker does, also has a money limit of its own: %s unless you set another**"}, }, }, { fact: "the checker's ceiling multiplier", owner: "config.CrewCheckCeilingTimes",