Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion cmd/codeaf/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
160 changes: 160 additions & 0 deletions cmd/codeaf/do_lock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions cmd/codeaf/do_pending_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion docs/HEADLESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ schema, and a person needs to know which command actually thinks.
codeaf do "<task>" [--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
Expand All @@ -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. |
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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. |
Expand Down
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1959-do-no-resident.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions internal/manual/chat/adaptive-runs.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ keyboard decides for itself and says on the record that it decided.
| --- | --- |
| `--db <path>` | 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 <dir>` | 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 |
Expand Down
Loading
Loading