Skip to content
16 changes: 16 additions & 0 deletions docs/changes/unreleased/1517-task-room-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
kind: fixed
title: runs land every file by its real name, --one-model reaches every run seat, caches stay out
pr: 1517
surface: [chat, engine]
invalidates:
- "A run's landing read `git status --porcelain` without `-z`, so a name git quotes (an accent, a quote, a newline, `a -> b`) was dropped from the commit without a word. Every reader of paths on the landing road now reads git's NUL form, and those files land."
- "The work tab took a file's name from the last ` b/` in its diff header, so `x b/plandb.db` was hidden as codeaf's own store and an accented name drew as octal escapes. One reader (`session.PatchSectionPath`) now reads the name the way git's own apply does, for both the filter and the room."
- "Under `--one-model` a `/task` run's worker, planner, checker and probe were seated from the profile's crew rows and `CODEAF_CHECK_MODEL`. Every seat now rides the conversation's live model, and the environment rung stands down under the flag."
- "A run's landing committed every untracked file, including `__pycache__/*.pyc`. An untracked file matching `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/` or `.DS_Store` now stays out; a tracked or worker-staged one still lands."
- "`the worker reads a note at its next step` was two constants in two packages. It is one, `session.RunNotePickupWord`."
---

The belt worker's page also tells a worker to delete its scratch files before
`plandb done`, because a fresh-install run landed a `calc_work.py` it had made to
compare against HEAD.
4 changes: 3 additions & 1 deletion internal/manual/chat/models-and-cost.md
Original file line number Diff line number Diff line change
Expand Up @@ -1157,7 +1157,9 @@ other, timing a benchmark cell, or attributing a cost.

It settles four things on your model: the crew's three seats and the two small rows, any role you pinned, the model
that work leaving the conversation runs on, and the fallback chain codeaf would otherwise
move to when a model cannot answer. Under this flag **nothing hops** — not on a refusal,
move to when a model cannot answer. That includes every seat of a `/task` run — its worker,
planner, checker and probe — whatever your crew rows say, and `CODEAF_CHECK_MODEL` stands
down too. Under this flag **nothing hops** — not on a refusal,
not on a reply that keeps stalling, not on rate limiting that will not clear — because a
run whose cost is being attributed to one model cannot have finished a single reply on
another. That includes the catalog's own guess: with no `fallback models` row written, an
Expand Down
3 changes: 3 additions & 0 deletions internal/manual/chat/reading-a-task-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ changed.
`the files this task changes are listed here when it lands`.
- On a run's task, `work` is the run's working copy read against the commit it was cut
from: the difference, and the files added, with the files codeaf keeps there left out.
Every file is listed under its own name, including spaces, quotes and accents. Only
codeaf's own files are left out: `plandb.db` and the files beside it at the copy's
top, the `.codeaf` folder and the `bin/plandb` shim.
The whole run shares that one copy, and the tab says so at its top. Once the run has
ended its copy is given back, and the tab reads the work off the run's branch instead.
With that branch gone too, it says `the run's working copy is not here any more`.
Expand Down
28 changes: 27 additions & 1 deletion internal/manual/chat/worker-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ that will not go in is never forced: the branch is kept in your repository and t
names it, for example `its branch <branch> was kept`, when your checkout moved on after
the copy was cut. A run whose workers committed everything still names its
branch and changed files; codeaf signs those commits before the work comes home.
A file lands under its own name whatever characters it holds, including spaces, quotes,
accents or a newline; only codeaf's own `plandb.db` and the files beside it, `.codeaf/`
files, and untracked build caches stay out of the landing commit (see *Why a task's
commit has no __pycache__* below).
A run that only read says `nothing to land: the run's working copy holds no change`
and changes no file. The
landing card says `merged` when the work is in your folder and `branch kept` only for a
Expand All @@ -54,6 +58,26 @@ row the run was published under settles `done` when the run finished whole and
`CODEAF_TASK_BELT=node` to use the older session tree road instead. See *How
to turn it off*.

## Why a task's commit has no __pycache__ or .pyc files, and no scratch files — what a landing leaves out

A run's landing commits the files its copy holds, except three kinds:

- codeaf's own files: `plandb.db` and the files beside it, and `.codeaf/`;
- **untracked build caches**, the ones the interpreter and the test tools write while a
worker runs your tests: `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`,
`.ruff_cache/` and `.DS_Store`. These are matched by exact name, never by resemblance, so
`poetry.lock`, `bench-results/` or `pycache_notes.md` still land;
- nothing else.

**A cache your repository already tracks still lands when it changes**, and so does one the
worker staged with `git add` or committed itself. Only a cache file git has never been told
about stays out. The task's `work` tab leaves the same files out of its list, so it never
shows a file that will not come home.

**Scratch copies are the worker's to clean up.** A worker is told to delete every scratch
file it made in its copy before it reports done, because anything left there lands on your
branch.

## The tasks pane and a task's page

While a run is live the task pane draws its **plan**: one row per task in the
Expand Down Expand Up @@ -714,7 +738,9 @@ named on a door or in the profile:

The seat a person names is the seat **every** launch takes — a task launched
after the door resolved the seats still runs on them, not on whichever row the
profile happens to hold. Read it back with `plandb spend --by seat`.
profile happens to hold. Read it back with `plandb spend --by seat`. A conversation
started with `--one-model` seats all four — work, plan, check and probe — on the model you
are talking to, and neither the crew rows nor `CODEAF_CHECK_MODEL` moves them.

## Headless: codeaf do — the exit code it leaves with

Expand Down
1 change: 1 addition & 0 deletions internal/manual/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2881,6 +2881,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
// second road for a task and the plan pane a run draws, each asked the
// way somebody meets it rather than in the machinery's own words.
{"how do I pause a task", "worker-harness"},
{"why did __pycache__ files not land in my task's commit", "worker-harness"},
{"how do I open one part of a run from the side list", "worker-harness"},
{"can I add a note to a running task", "worker-harness"},
{"why did the task refuse my cancel", "worker-harness"},
Expand Down
81 changes: 81 additions & 0 deletions internal/run/chat_check_seat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,84 @@ func TestTheChatDoorsCheckRidesTheCheckModelVariable(t *testing.T) {
asked, config.CheckModelEnv)
}
}

// UNDER `--one-model` THE RUN HAS ONE SEAT (contract 3a). The chat's door names
// the conversation's model as every seat, and the engine seats every role on it
// — the root's planning, a leaf, a check and a probe — whatever the profile's
// crew rows say and whatever CODEAF_CHECK_MODEL says. The profile rows below
// name OTHER models for every tier, so a seat that fell back to the profile or
// the environment would be seen here.
func TestUnderOneModelEveryRunSeatIsTheConversationsModel(t *testing.T) {
t.Setenv(config.CheckModelEnv, "vendor/env-check")
store := runOpenStore(t)
if _, err := store.AddMany([]plandb.TaskSpec{
{ID: "one", Title: "One", ParentID: store.RootID()},
{ID: "review", Title: "Review", Role: plandb.RoleCheck},
{ID: "discriminate", Title: "Discriminate", Role: plandb.RoleProbe},
}); err != nil {
t.Fatal(err)
}
dir := crewProfile(t, map[string]string{
config.KeyTierLowModel: "vendor/profile-small",
config.KeyTierWorkerModel: "vendor/profile-worker",
config.KeyTierHighModel: "vendor/profile-careful",
config.KeyTierMastermindModel: "vendor/profile-thinking",
})
recorder := &recordingCompleter{}
factory := run.CrewFactory(store, t.TempDir(), dir, run.Seats{One: "vendor/one"}, recorder.forModel)
for _, id := range []string{store.RootID(), "one", "review", "discriminate"} {
recorder.models = nil
factory(*store.Task(id))
if len(recorder.models) != 1 || recorder.models[0] != "vendor/one" {
t.Errorf("task %s was seated on %v under one model, want vendor/one", id, recorder.models)
}
}
}

// THE CHAT'S DOOR CARRIES THE ONE SEAT TO THE ENGINE (contract 3a). The spec
// names only OneModel — no work, plan or check seat — so a launch that rode
// anything else read it from the profile or the environment, which is the leak
// the tagged suite billed claude-fable-5.1 through.
func TestTheChatDoorsOneModelReachesEveryLaunch(t *testing.T) {
t.Setenv("CODEAF_TASK_BELT", "bash")
t.Setenv("CODEAF_PLANDB_BIN", stubCLI(t))
t.Setenv(config.CheckModelEnv, "vendor/env-check")
store := runOpenStore(t)
if _, err := store.AddMany([]plandb.TaskSpec{{ID: "review", Title: "Review", Role: plandb.RoleCheck}}); err != nil {
t.Fatal(err)
}
dir := crewProfile(t, map[string]string{
config.KeyTierHighModel: "vendor/profile-careful",
config.KeyTierWorkerModel: "vendor/profile-worker",
config.KeyTierMastermindModel: "vendor/profile-thinking",
})
var mu sync.Mutex
var asked []string
refuse := &seat{ever: func(context.Context, []ai.Message) (*ai.Response, error) {
return nil, errors.New("scripted: no provider behind this seat")
}}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
run.ChatEngine.Start(ctx, session.RunSpec{
Store: store,
Workspace: t.TempDir(),
ProfileDir: dir,
OneModel: "vendor/one",
CompleterFor: func(model string) session.Completer {
mu.Lock()
asked = append(asked, model)
mu.Unlock()
return refuse
},
})
mu.Lock()
defer mu.Unlock()
if len(asked) == 0 {
t.Fatal("the run launched nothing, so nothing was seated")
}
for _, model := range asked {
if model != "vendor/one" {
t.Fatalf("a launch under one model was seated on %s (all asked: %v)", model, asked)
}
}
}
21 changes: 21 additions & 0 deletions internal/run/crew.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ package run
// comes from the profile's tiers, and an empty seat falls exactly where an empty
// tier always fell.
//
// UNDER `--one-model` THE PROFILE DOES NOT ANSWER. A conversation started with
// the flag names its own model as every seat ([Seats.One]), and the factory
// seats every role on it without reading a crew row or the check seat's
// environment rung: the flag promises every text call rides the model the
// person is talking to, and a crew row answering for an empty seat is how a
// run under it once billed two models nobody named.
//
// THE READ IS AT LAUNCH, NEVER CACHED. The factory asks the profile again for
// every task it seats — for the check and probe rows, and for any seat the door
// left empty — so a crew change between two launches (a /crew run in the
Expand Down Expand Up @@ -80,6 +87,14 @@ type Seats struct {
Work string
Plan string
Check string
// One is the conversation's model under `--one-model`, and when it is set
// it is EVERY seat: the three above, the probe no door names, and any role
// this build has not learned. Nothing here asks the profile or the check
// seat's environment rung while it is set, because the flag promises that
// every text call rides the model the person is talking to — and an empty
// seat falling to the crew row is exactly how a run under it billed models
// nobody named.
One string
}

// CrewFactory is the run's WorkerFactory: it seats each task in the model its
Expand Down Expand Up @@ -109,6 +124,12 @@ type Seats struct {
// profile is old.
func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, completerFor func(model string) session.Completer) WorkerFactory {
return func(task plandb.Task) Worker {
// UNDER `--one-model` THERE IS NO TIER TO READ. The door named one model
// for every seat ([Seats.One]), so the role does not matter and neither
// the profile nor the environment is asked.
if seats.One != "" {
return NewBashWorker(store, workspace, seats.One, completerFor(seats.One))
}
// A task the store cannot name — which the supervisor never hands over —
// reads as the work seat, the same fallback SeatFor gives an unknown
// role, so RoleOf's error needs no reader here.
Expand Down
3 changes: 3 additions & 0 deletions internal/run/enginewire.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar
Work: spec.WorkModel,
Plan: spec.PlanModel,
Check: chatCheckSeat(spec.CheckModel),
// AND UNDER `--one-model` ONE MODEL IS EVERY SEAT, the probe and the
// check's environment rung included ([Seats.One]).
One: spec.OneModel,
}, spec.CompleterFor),
OnSpend: spec.OnSpend,
Gate: spec.Admission,
Expand Down
143 changes: 143 additions & 0 deletions internal/run/land_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -97,6 +98,148 @@ func TestLandCommitsTheRunsWorkOntoItsBranch(t *testing.T) {
}
}

// Contract 4a and 4b: the real landing must carry git-quoted names as their
// exact bytes while leaving the harness's own files outside the commit.
func TestLandKeepsEveryRealFilename(t *testing.T) {
t.Setenv("CODEAF_TASK_BELT", "bash")
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "checkout", "-b", "work")
writeRunFile(t, filepath.Join(repo, "x b", "plandb.db"), "one\n")
runGit(t, repo, "add", "x b/plandb.db")
runGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "seed")
base := strings.TrimSpace(runGitOut(t, repo, "rev-parse", "work"))
work := filepath.Join(t.TempDir(), "run")
runGit(t, repo, "worktree", "add", "-b", "run-work", work, "HEAD")
store := runOpenStore(t)
files := map[string]string{
"x b/plandb.db": "one\ntwo\n",
" lead.txt": "lead\n",
"odd name é'q.txt": "accent\n",
"has\"quote.txt": "quote\n",
"new\nline.txt": "newline\n",
"a -> b.txt": "arrow\n",
}
seat := newFakeSeat()
seat.actions["root"] = func(_ context.Context, _ plandb.Task) (run.Report, error) {
for path, content := range files {
writeRunFile(t, filepath.Join(work, path), content)
}
writeRunFile(t, filepath.Join(work, "plandb.db"), "private\n")
writeRunFile(t, filepath.Join(work, ".codeaf", "jobs", "x.log"), "private\n")
return run.Report{Result: "wrote six files"}, nil
}
ctx := runContext(t)
if got := run.NewSupervisor(store, work, 1, run.Limits{}, seat.workerFor).Run(ctx); got != run.OutcomeDone {
t.Fatalf("run outcome = %q", got)
}
landing, err := run.Land(ctx, store, work, base, store.RootID())
if err != nil || landing.Refused != "" || landing.Branch != "run-work" {
t.Fatalf("landing = %+v, error = %v", landing, err)
}
want := make([]string, 0, len(files))
for name := range files {
want = append(want, name)
}
slices.Sort(want)
if !reflect.DeepEqual(landing.Changed, want) {
t.Fatalf("landing.Changed = %q, want %q", landing.Changed, want)
}
listed := strings.Split(runGitOut(t, repo, "ls-tree", "-r", "-z", "--name-only", landing.Branch), "\x00")
for name, content := range files {
if !slices.Contains(listed, name) {
t.Errorf("landing tree omits %q: %q", name, listed)
}
if got := runGitOut(t, repo, "show", landing.Branch+":"+name); got != content {
t.Errorf("landed %q = %q, want %q", name, got, content)
}
}
for _, name := range []string{"plandb.db", ".codeaf/jobs/x.log"} {
if slices.Contains(listed, name) {
t.Errorf("landing tree includes harness file %q", name)
}
}
if notes := store.Notes(store.RootID(), 0); len(notes) == 0 || notes[len(notes)-1].Body != "landed on run-work: 6 files" {
t.Fatalf("root notes = %v, want six-file landing", notes)
}
}

// A LANDING LEAVES OUT UNTRACKED BUILD CACHES AND NOTHING ELSE (contract 5a, 5b
// and 5c). A fresh-install run in a Python repository with no .gitignore landed
// the interpreter's new `__pycache__/*.pyc` files beside its one-line fix. The
// caches a run leaves untracked stay out; a cache file the repository already
// tracks, and one the worker staged itself, are work and land; and every name
// that merely resembles a cache — a lockfile, a folder the project owns, a file
// whose name contains the word — lands as it always did.
func TestLandLeavesOutUntrackedBuildCachesOnly(t *testing.T) {
t.Setenv("CODEAF_TASK_BELT", "bash")
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "checkout", "-b", "work")
writeRunFile(t, filepath.Join(repo, "calc.py"), "def add(a, b):\n return a - b\n")
writeRunFile(t, filepath.Join(repo, "__pycache__", "calc.cpython-310.pyc"), "tracked cache\n")
runGit(t, repo, "add", "calc.py", "__pycache__/calc.cpython-310.pyc")
runGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "seed")
base := strings.TrimSpace(runGitOut(t, repo, "rev-parse", "work"))
work := filepath.Join(t.TempDir(), "run")
runGit(t, repo, "worktree", "add", "-b", "run-work", work, "HEAD")
store := runOpenStore(t)
lands := map[string]string{
"calc.py": "def add(a, b):\n return a + b\n",
"__pycache__/calc.cpython-310.pyc": "recompiled\n",
"notes/.DS_Store": "staged on purpose\n",
"poetry.lock": "lock\n",
"package-lock.json": "{}\n",
"bench-results/x": "result\n",
"pycache_notes.md": "notes\n",
"src/__pycache__helper.py": "helper\n",
"my.pyc.txt": "text\n",
"DS_Store.md": "doc\n",
}
caches := []string{
"__pycache__/test_calc.cpython-310.pyc",
"pkg/__pycache__/m.pyc",
".pytest_cache/v/cache/nodeids",
".mypy_cache/x",
".ruff_cache/x",
".DS_Store",
"lib/old.pyc",
}
seat := newFakeSeat()
seat.actions["root"] = func(_ context.Context, _ plandb.Task) (run.Report, error) {
for path, content := range lands {
writeRunFile(t, filepath.Join(work, path), content)
}
for _, path := range caches {
writeRunFile(t, filepath.Join(work, path), "cache\n")
}
runGit(t, work, "add", "notes/.DS_Store")
return run.Report{Result: "fixed add"}, nil
}
ctx := runContext(t)
if got := run.NewSupervisor(store, work, 1, run.Limits{}, seat.workerFor).Run(ctx); got != run.OutcomeDone {
t.Fatalf("run outcome = %q", got)
}
landing, err := run.Land(ctx, store, work, base, store.RootID())
if err != nil || landing.Refused != "" {
t.Fatalf("landing = %+v, error = %v", landing, err)
}
want := make([]string, 0, len(lands))
for name := range lands {
want = append(want, name)
}
slices.Sort(want)
if !reflect.DeepEqual(landing.Changed, want) {
t.Fatalf("landing.Changed = %q, want %q", landing.Changed, want)
}
listed := strings.Split(runGitOut(t, repo, "ls-tree", "-r", "-z", "--name-only", landing.Branch), "\x00")
for _, name := range caches {
if slices.Contains(listed, name) {
t.Errorf("the landing carried the untracked build cache %q", name)
}
}
}

// TestLandRefusesARunThatWroteNothing is the other ending of the same door: a
// run that only read is a refusal with no branch, and the root says so.
func TestLandRefusesARunThatWroteNothing(t *testing.T) {
Expand Down
Loading
Loading