From 9149020f7972f43b52fc8ff085fa8e0c418ad02a Mon Sep 17 00:00:00 2001 From: zeropoint95 Date: Wed, 28 Oct 2026 10:29:20 -0400 Subject: [PATCH] =?UTF-8?q?do:=20scripted=20runs=20on=20a=20box=20with=20a?= =?UTF-8?q?=20live=20resident=20get=20compiled=20conversationally=20?= =?UTF-8?q?=E2=80=94=20add=20hard=20isolation=20(--no-resident)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: CodeAF Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- cmd/codeaf/do.go | 45 ++++- cmd/codeaf/do_lock_test.go | 160 ++++++++++++++++++ cmd/codeaf/do_pending_test.go | 3 + cmd/codeaf/main.go | 4 + docs/HEADLESS.md | 11 +- .../changes/unreleased/1959-do-no-resident.md | 14 ++ internal/manual/chat/adaptive-runs.md | 1 + .../manual/chat/running-from-the-terminal.md | 8 +- 8 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 docs/changes/unreleased/1959-do-no-resident.md diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index ceee130452..ba34c476fe 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -360,6 +360,12 @@ func runDo(args []string) error { "(default: your task.parallel setting, which is no limit)") continueID := flags.String("continue", "", "carry on a run that did not finish (or was kept with --keep): "+ "its id is on the `record kept at` line; any words after it are this round's finding") + // A FLAG IS DOCUMENTED BY WHAT IT DOES, NOT BY WHAT IT SETS: this one + // says what the run will do when a live resident holds the store, and the + // next line names the env spelling without pretending it is the point. + noResident := flags.Bool("no-resident", false, + "refuse the resident hand-off: a live resident holding the store stops this run "+ + "with exit 1 instead of taking the task over (CODEAF_NO_RESIDENT=1 is the same switch)") debug := flags.Bool("debug", false, debugFlagHelp()) if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err @@ -404,7 +410,8 @@ func runDo(args []string) error { timeout: wall.wall, asJSON: *asJSON, yesSpend: *yesSpend, model: *model, planModel: *planModel, checkModel: *checkModel, contextFill: *contextFill, completionReserve: *completionReserve, slots: slots, - stdout: os.Stdout, stderr: os.Stderr, + noResident: *noResident, + stdout: os.Stdout, stderr: os.Stderr, }) } @@ -464,6 +471,14 @@ type doRequest struct { // holds the lock for its store. Zero is defaultResidentWait; a test names a // shorter one rather than sitting through it. residentWait time.Duration + // noResident refuses the resident hand-off. A live resident that holds the + // store's lock compiles the brief the way a conversation would — reworded + // goal, declared assumptions, a question into a thread nobody reads — which + // is the right thing for a person at home and corruption for a script, + // whose one guarantee is that the run it measures is the run it asked for. + // --no-resident (or CODEAF_NO_RESIDENT=1) says so: probe the lock, don't + // fight it, and stop at the door instead of handing the brief over. + noResident bool // newClient scripts the provider. Nil is the real one. newClient func(config.Config, string) (*liveClient, error) // callWall is the structuring slots' wall on one completion. Zero is @@ -806,6 +821,23 @@ func errandRun(request doRequest, seats config.Seats, started time.Time) (outcom return headlessOutcome{}, fmt.Errorf("create the store directory: %w", err) } + // THE RESIDENT HAND-OFF IS FOR PEOPLE, NOT SCRIPTS. A resident that picks + // the command up compiles it conversationally, and for a harness the + // verbatim brief is the one guarantee it has. So --no-resident + // (CODEAF_NO_RESIDENT=1) asks for hard isolation and gets it here, at the + // door, before the command reaches the journal: probe the lock rather than + // fight it, refuse while a live non-stuck holder serves this store, and + // leave no command behind for somebody else's resident to apply. + if request.noResident || env.Value("CODEAF_NO_RESIDENT") == "1" { + holder, err := lease.ProbeResident(path) + if err != nil { + return headlessOutcome{}, err + } + if holder != nil && !holder.Stuck { + return headlessOutcome{}, noResidentRefusal(holder, path) + } + } + session := headlessSessionID() window, err := openChatWindow(path, session) if err != nil { @@ -1275,6 +1307,17 @@ func residentServesThisStore(holder *lease.Resident, path string) error { mine, holder.PID, residentHost(holder), served) } +// noResidentRefusal is the whole of `do --no-resident`: a live resident holds +// the store's role, and this run was told to refuse rather than hand over. The +// sentence names the holder, the store and the switch, so a script that forgot +// it set the flag can see both what it refused and how to un-refuse it. +func noResidentRefusal(holder *lease.Resident, store string) error { + return fmt.Errorf("the resident lock for %s is held by pid %d on %s, and --no-resident (CODEAF_NO_RESIDENT=1) "+ + "says this run must not hand its task to a live resident — drop the flag to let the resident take it, "+ + "or stop that process", + store, holder.PID, residentHost(holder)) +} + // sameStore compares two store paths as plainly as a diagnostic needs to. It is // deliberately not a stat of both: the answer is used to decide what to print // and whether to wait, and a path that cannot be resolved must not turn into a diff --git a/cmd/codeaf/do_lock_test.go b/cmd/codeaf/do_lock_test.go index d9c7ccdee5..25db4cd345 100644 --- a/cmd/codeaf/do_lock_test.go +++ b/cmd/codeaf/do_lock_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/lease" + "github.com/Agent-Field/codeaf/internal/store" ) // An errand that cannot be the brain says who has the role and stops. @@ -18,6 +19,9 @@ import ( // repeated progress line and nothing anywhere naming the lock. The wait is now // bounded and the diagnostic names the holder, the store, and the way out. func TestDoFailsFastWhenAnotherProcessHoldsTheResidentLock(t *testing.T) { + // The wait under test is the default behavior; a shell that exports + // CODEAF_NO_RESIDENT would turn it into a refusal, so pin it off. + t.Setenv("CODEAF_NO_RESIDENT", "") dir := t.TempDir() path := filepath.Join(dir, "graph.db") release, heldBy, err := lease.AcquireResident(path, "chat") @@ -70,6 +74,162 @@ func lockedPID(t *testing.T, path string) int { return holder.PID } +// --no-resident is the hard isolation a script asks for: with a live resident +// holding the store, the run refuses at the door instead of handing the brief +// over. The refusal is the point — a resident that picks the command up +// compiles it conversationally, which is the one thing a harness cannot +// measure — so the run must stop fast, name the holder and the switch, and +// leave the journal exactly as it found it: no command for the resident to +// apply, because a run this process refused is not a run anybody may do. +func TestDoNoResidentRefusesWhileAResidentHoldsTheLock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "graph.db") + release, _, err := lease.AcquireResident(path, "chat") + if err != nil || release == nil { + t.Fatalf("could not stand in for the resident: release %v, err %v", release != nil, err) + } + defer release() + + var stdout, stderr strings.Builder + started := time.Now() + err = doErrand(doRequest{ + task: "write the release note", + database: path, + noResident: true, + // Every seat is named, so the crew needs no router to reach the lock. + model: "test/model", planModel: "test/model", checkModel: "test/model", + timeout: 5 * time.Minute, + stdout: &stdout, + stderr: &stderr, + }) + if err == nil { + t.Fatalf("--no-resident deferred to the live resident instead of refusing\nstderr:\n%s", stderr.String()) + } + if elapsed := time.Since(started); elapsed > 30*time.Second { + t.Fatalf("the refusal waited %s — it must stop at the door, not at the bound", elapsed) + } + // The diagnostic carries what the refusal is about: who holds the role, + // which store it is held for, and the switch that refused it. + for _, want := range []string{strconv.Itoa(lockedPID(t, path)), path, "--no-resident"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %q: %v", want, err) + } + } + // NOTHING WAS HANDED OVER: the journal holds no command, so no resident can + // apply a run this process refused. This is what makes the refusal a + // refusal and not a slower hand-off. + graph, openErr := store.Open(path) + if openErr != nil { + t.Fatalf("open the store to read the journal: %v", openErr) + } + defer graph.Close() + if pending, err := graph.PendingCommands(10); err != nil || len(pending) != 0 { + t.Fatalf("the refused run left %d command(s) in the journal (err %v) — nothing may be handed over", len(pending), err) + } +} + +// CODEAF_NO_RESIDENT=1 is the same switch from the environment, for a wrapper +// that cannot change a call site. +func TestCodeafNoResidentEnvRefusesWhileAResidentHoldsTheLock(t *testing.T) { + t.Setenv("CODEAF_NO_RESIDENT", "1") + dir := t.TempDir() + path := filepath.Join(dir, "graph.db") + release, _, err := lease.AcquireResident(path, "chat") + if err != nil || release == nil { + t.Fatalf("could not stand in for the resident: release %v, err %v", release != nil, err) + } + defer release() + + var stdout, stderr strings.Builder + err = doErrand(doRequest{ + task: "write the release note", + database: path, + model: "test/model", planModel: "test/model", checkModel: "test/model", + timeout: 5 * time.Minute, + stdout: &stdout, + stderr: &stderr, + }) + if err == nil { + t.Fatalf("CODEAF_NO_RESIDENT=1 deferred to the live resident instead of refusing\nstderr:\n%s", stderr.String()) + } + if !strings.Contains(err.Error(), "--no-resident") { + t.Fatalf("the refusal does not name the switch: %v", err) + } +} + +// A --json caller gets the same refusal through the envelope it reads every +// other ending through: one object, ok false, stop "error", and the sentence +// in the error field — never empty stdout with the reason buried on stderr. +func TestDoNoResidentRefusalReachesTheJSONEnvelope(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "graph.db") + release, _, err := lease.AcquireResident(path, "chat") + if err != nil || release == nil { + t.Fatalf("could not stand in for the resident: release %v, err %v", release != nil, err) + } + defer release() + + var stdout, stderr strings.Builder + err = doErrand(doRequest{ + task: "write the release note", + database: path, + noResident: true, + asJSON: true, + model: "test/model", planModel: "test/model", checkModel: "test/model", + timeout: 5 * time.Minute, + stdout: &stdout, + stderr: &stderr, + }) + if err == nil { + t.Fatalf("--json --no-resident reported success instead of refusing") + } + fields := errandJSONFields(t, stdout.String()) + if got, _ := fields["ok"].(bool); got { + t.Fatalf("the refusal envelope says ok: true\n%s", stdout.String()) + } + if got, _ := fields["stop"].(string); got != "error" { + t.Fatalf("stop = %q, want %q\n%s", got, "error", stdout.String()) + } + if got, _ := fields["error"].(string); !strings.Contains(got, "--no-resident") { + t.Fatalf("the envelope's error does not name the switch: %q\n%s", got, stdout.String()) + } +} + +// The whole door, as the issue names it: `do --no-resident` on a box with a +// live resident. Driven through runDo so the argv-to-request wiring is under +// test — the flag string has to reach the refusal, not just the request field +// a test fills in by hand. +func TestDoNoResidentFlagFromTheCommandLineRefuses(t *testing.T) { + // The switch under test is the flag, so a shell that exports the env + // spelling must not be the thing refusing. + t.Setenv("CODEAF_NO_RESIDENT", "") + dir := t.TempDir() + path := filepath.Join(dir, "graph.db") + release, _, err := lease.AcquireResident(path, "chat") + if err != nil || release == nil { + t.Fatalf("could not stand in for the resident: release %v, err %v", release != nil, err) + } + defer release() + + stdout, _, runErr := captureDoRefusal(t, []string{ + "--no-resident", "--db", path, + "--model", "test/model", "--plan-model", "test/model", "--check-model", "test/model", + "write the release note", + }) + if exitCodeOf(runErr) != 1 { + t.Fatalf("exit = %d, want 1 (%v)", exitCodeOf(runErr), runErr) + } + // main is the one that prints the `error:` line for a plain door error; + // what runDo hands back is the sentence itself, and it has to name the + // holder and the switch. + if !strings.Contains(runErr.Error(), "the resident lock") || !strings.Contains(runErr.Error(), "--no-resident") { + t.Fatalf("the refusal does not name the switch and the holder: %v", runErr) + } + if stdout != "" { + t.Fatalf("stdout = %q, want empty — a refused run writes nothing to the machine stream", stdout) + } +} + // The other half of the same failure: a holder that is serving a different // database is not slow, it is absent. There is nothing to wait for, and the run // says so immediately rather than spending its bound finding out. diff --git a/cmd/codeaf/do_pending_test.go b/cmd/codeaf/do_pending_test.go index 3e88f0800b..667e5e34f5 100644 --- a/cmd/codeaf/do_pending_test.go +++ b/cmd/codeaf/do_pending_test.go @@ -130,6 +130,9 @@ func TestADoErrandHandedToAResidentLeavesNoPendingLanding(t *testing.T) { script := newScriptedBrain(t) defer script.close() t.Setenv("CODEAF_MODEL_POOL", "on") + // The hand-off under test is the default behavior; a shell that exports + // CODEAF_NO_RESIDENT would turn it into a refusal, so pin it off. + t.Setenv("CODEAF_NO_RESIDENT", "") dir := t.TempDir() path := filepath.Join(dir, "graph.db") diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 37ceec6d1e..cf2b9cd365 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -731,6 +731,10 @@ A variable that pins a ` + "`/settings`" + ` row wins over that row. CODEAF_PREAUTHORIZE_SPEND 1 spends past the day's limit without stopping a headless run to ask + CODEAF_NO_RESIDENT 1 refuses the resident hand-off for ` + "`codeaf do`" + `: when + a live resident holds the store's role, the run stops + with exit 1 instead of handing the task over. + --no-resident is the same switch on the command line. CODEAF_HOME the whole state root — journal, workspace, CAS, craft, profiles, catalog, skills (default ~/.codeaf). Move it to run a disposable store that touches nothing of yours. diff --git a/docs/HEADLESS.md b/docs/HEADLESS.md index 61fbb65ad0..766796ce31 100644 --- a/docs/HEADLESS.md +++ b/docs/HEADLESS.md @@ -18,7 +18,7 @@ schema, and a person needs to know which command actually thinks. codeaf do "" [--dir dir] [--db path] [--keep] [--timeout D] [--continue id] [--json] [--yes-spend] [--model slug] [--plan-model slug] [--check-model slug] [--best|--cheap] [--pin seat=model[@provider]] - [--context-fill N] [--completion-reserve N] + [--context-fill N] [--completion-reserve N] [--no-resident] ``` **Use this one.** `do` is the resident's own brain with the conversation @@ -39,6 +39,7 @@ work done. | `--dir dir` | the current directory | The directory it works in, **edited in place**. Not an output folder — it opens what is there and leaves nothing behind that you did not ask for. `-w` is the shorthand and keeps working forever. | | `--db path` | a private temp store, deleted on exit | Work in this durable store instead. This is how state survives across runs. | | `--keep` | off | Keep the private store instead of deleting it; the path is printed to stderr. | +| `--no-resident` | off | Refuse the resident hand-off: when a live resident already holds this store, the run stops with **exit 1** and names the holder instead of handing the task over. Equivalent to `CODEAF_NO_RESIDENT=1`. For scripts — see the store section below. | | `--continue id` | none | Carry on a kept run (one that did not finish, or was kept with `--keep`): `id` is the part of the `record kept at` folder name after `codeaf-do-`, an unambiguous start of it, or the folder path. Words after it are this round's finding; the assignment is unchanged. Works in `--dir`. Run engine only. | | `--timeout D` | `15m` | Hard wall, as a duration with a unit: `5m`, `2h`, `90s`. A bare number is still read as seconds for one release, so `--timeout 900` keeps working. A wall, not a schedule — the length of rope at which a wedged run is more useful dead. | | `--json` | off | Print one machine-readable object instead of the prose deliverable. | @@ -385,6 +386,13 @@ the command, not about the command itself. A resident that picks it up compiles it the way a conversation would — reworded goal, declared assumptions, and a question asked into a thread nobody is reading. +**Scripted consumers set `--no-resident`.** The hand-off is for people: a +person at home gets a resident that does the work with its own head attached, +and a harness cannot measure a run it did not ask for. With `--no-resident` (or +`CODEAF_NO_RESIDENT=1`), a live resident holding this store is a refusal — the +run stops with **exit 1**, names the holder, and writes nothing to the journal: +the run you asked for is the run that runs, or the run does not run at all. + For guaranteed isolation from your own resident, give the run its own state root with `CODEAF_HOME`. @@ -778,6 +786,7 @@ The full list is `codeaf help env`. What matters headless: | `CODEAF_MODELS` | unset | A panel instead of one model: calls cascade cheapest-first and escalate when a verifier catches a failure. Comma-separated slugs or a JSON path. **Changes what a run costs and how it fails — pin it when measuring.** | | `CODEAF_DAILY_BUDGET` | `20.0` | The day's spending limit in dollars; `0` is unlimited. A run that reaches it stops. | | `CODEAF_PREAUTHORIZE_SPEND` | unset | `1` is `--yes-spend` for every run. | +| `CODEAF_NO_RESIDENT` | unset | `1` is `--no-resident` for every `do` run: a live resident holding the store stops the run with exit 1 instead of taking the task over. | | `CODEAF_HOME` | `~/.codeaf` | The whole state root — journal, workspace, CAS, craft, profiles, catalog, skills. One word moves everything; this is the isolation seam. | | `CODEAF_PROFILE_DIR` | `CODEAF_HOME` | Where measured behaviour is kept. | | `CODEAF_CONTEXT_FILL_PCT` | `60` | How full any agent's context window may get before it compacts, in percent; clamped 10–90. One law for head turns, planner passes, leaf workers and judges alike. `--context-fill` sets it per run. **Setting it also moves the chat conversation's own fold line**, which otherwise follows the model's window (`/status` says which rule governs); leaving it unset is not the same as setting it to 60. | diff --git a/docs/changes/unreleased/1959-do-no-resident.md b/docs/changes/unreleased/1959-do-no-resident.md new file mode 100644 index 0000000000..91cfe893dc --- /dev/null +++ b/docs/changes/unreleased/1959-do-no-resident.md @@ -0,0 +1,14 @@ +--- +kind: added +title: do --no-resident refuses the live-resident hand-off a script cannot measure +pr: 1959 +surface: [docs, engine] +invalidates: + - "A `codeaf do` run on a machine with a live resident always handed its brief over to that resident; with --no-resident (CODEAF_NO_RESIDENT=1) it now refuses with exit 1, names the holder, and writes nothing to the journal." +--- + +The resident hand-off compiles the brief conversationally in the resident's own +head, which is the right thing for a person and a different run than a script +asked for. `--no-resident` is the opt-out: probe the lock, refuse while a live +non-stuck holder serves the store, and leave no command behind for it to apply. +Running truly headless beside the resident (isolated store) stays out of scope. \ No newline at end of file diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index 84e7616833..751fc67e30 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -153,6 +153,7 @@ keyboard decides for itself and says on the record that it decided. | --- | --- | | `--db ` | work in this durable store instead of a private one (older engine only) | | `--keep` | keep the store instead of deleting it on the way out | +| `--no-resident` | refuse the resident hand-off: when a live resident already holds this store, the run stops with exit 1 and names the holder instead of handing the task over (`CODEAF_NO_RESIDENT=1` is the same switch). The run you asked for is the run that runs, or the run does not run at all | | `-w ` | the directory to work in, edited in place, never committed — the current directory by default | | `--timeout` | a hard wall on the whole run | | `--json` | print one machine-readable object instead of the deliverable | diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 95e2b55eba..08d0c416fb 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -802,13 +802,19 @@ a `--plan-model` says who plans and nothing about who checks. session model; with that flag, an explicit media-model pin that cannot handle the operation leaves it unavailable rather than switching models. -`codeaf do` takes three more, all for this one task: +`codeaf do` takes four more, all for this one task: - **`--best`** — the strongest crew your allowed models make. - **`--cheap`** — the cheapest crew your allowed models make. - **`--pin seat=model[@provider]`** — pin one seat, `worker=`, `planner=` or `checker=`; say it once per seat. `--pin checker=moonshotai/kimi-k3@openrouter` sends the checker through that connection. A seat that is not one of the three, or a pin with no model, is refused. +- **`--no-resident`** — refuse the resident hand-off. When a live resident on this machine + already holds the store, the run stops with exit 1 and names the holder instead of handing + the task over; `CODEAF_NO_RESIDENT=1` is the same switch from the environment. The hand-off + compiles your brief conversationally in the resident's own head — the right thing for a + person at home, and unmeasurable for a script, whose one guarantee is that the run it + measures is the run it asked for. Nothing a headless run is told is written to the profile: the pins and the allowed models stay what `/crew` last set.