From 819e25dc826e1342966088ca8a14042ff8714324 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:57:24 -0400 Subject: [PATCH 1/3] session: a bash-belt worker's one call may name any hand its belt carries The bash belt composes `jobs`, `read_document` and `manual` onto the wire (the design's Decision 6), and the worker's page sends the worker to the first two, but the one-action envelope refused every call not named bash. A worker whose `find /` had become job 1 called `jobs` to stop it, was told `jobs` was not on this belt, and the walk ran on for the rest of the task. The envelope now keeps the one-call rule and refuses only a name the belt does not carry, read off the same belt the request was built from. The page says a job is read and stopped through `jobs` (output, kill), never the shell's builtin. A test proves a run worker's job still running when its task ends is stopped with it (it already was, through Close). Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/how-tasks-run.md | 4 +- internal/manual/chat/worker-harness.md | 6 +- internal/run/worker_jobs_end_test.go | 85 ++++++++++++++++++ internal/session/bashbelt_envelope.go | 41 +++++++-- internal/session/bashbelt_jobs_test.go | 116 +++++++++++++++++++++++++ internal/session/prompts/bashworker.md | 3 +- 6 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 internal/run/worker_jobs_end_test.go create mode 100644 internal/session/bashbelt_jobs_test.go diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 745a7d702b..24bec2dc1c 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -1403,7 +1403,9 @@ to the full log, all in the one turn. This is why a task does not `sleep` and `t build or test run — the waiting is done for it, and those nine `sleep N && tail` steps above are what the counter catches when something is polled that nobody is waiting on. A command started with `background: true` is the other case: a server or a sweep the task deliberately -left running holds nothing up, and the task is asked its next step straight away. +left running holds nothing up, and the task is asked its next step straight away. Either kind +of job is the task's own to read and to stop, with the `jobs` tool's `output` and `kill`, +and a job still running when the task ends is stopped with it. **A task that repeats itself is told what the work has been doing.** Before it is stopped it gets a `[stuck]` note, and that note now carries one more fact than the repetition itself: diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 4d2e1b140b..40f4d53e4d 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -557,8 +557,10 @@ same code path the tool runs, so the two cannot drift: - `codeaf web fetch URL` / `codeaf web search QUERY` — the belt's web verbs. - `codeaf image PROMPT --out PATH` — one picture the way `generate_image` makes one. -A few hands a shell cannot be are kept too — the billed `read_document`, `jobs`, -`manual`, and the web, media and services families. +A few hands a shell cannot be are kept too, and a worker calls them directly, one call +per response exactly as it calls `bash` — the billed `read_document`, `jobs` (which +reads and stops a job the worker started), `manual`, and the web, media and services +families. **A worker cannot ask you a question.** `ask` is not on its belt: the loop reaches the person through the plan CLI and not a consent gate, so a thing it cannot have diff --git a/internal/run/worker_jobs_end_test.go b/internal/run/worker_jobs_end_test.go new file mode 100644 index 0000000000..23006478ca --- /dev/null +++ b/internal/run/worker_jobs_end_test.go @@ -0,0 +1,85 @@ +package run_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/run" +) + +// TestBashWorkerStopsItsJobsWhenItsTaskEnds: a job a worker leaves running is +// the worker's, and it does not outlive the task. The fresh-install run left a +// `find /` walking the disk as job 1 for the rest of its task; whatever a worker +// did not stop itself, the end of its task must stop, process group and all. +func TestBashWorkerStopsItsJobsWhenItsTaskEnds(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", stubCLI(t)) + store := runOpenStore(t) + workspace := t.TempDir() + marker := filepath.Join(workspace, "group.txt") + // The run's own wall is generous, because a loaded machine is not what + // this test is about; the task is ended by cutting its run instead. + ctx, cut := context.WithTimeout(context.Background(), 2*time.Minute) + defer cut() + seat := &seat{script: []step{ + // The job writes its own process group (bash starts it as the group's + // leader, and exec keeps the pid) and then sleeps far past the test. + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolReply(`{"command":"echo $$ > group.txt; exec sleep 900","background":true}`), nil + }, + // Once the job has said which group it is, the task ends with the job + // still running: its run is cut, the way a person's stop cuts it. HOW + // the task ends is not the point — every ending passes the same + // deferred close of the worker's seat. + func(context.Context, []ai.Message) (*ai.Response, error) { + waitForFile(t, marker) + cut() + return textReply(""), nil + }, + }} + worker := run.NewBashWorker(store, workspace, "test/model", seat) + _, _ = worker.Run(run.WithStepsPerTask(ctx, 9), *store.Task(store.RootID())) + + data, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("the job never wrote its process group: %v", err) + } + group, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || group <= 1 { + t.Fatalf("the job wrote %q, not a process group", data) + } + deadline := time.Now().Add(5 * time.Second) + for { + if err := syscall.Kill(-group, 0); errors.Is(err, syscall.ESRCH) { + return + } + if time.Now().After(deadline) { + _ = syscall.Kill(-group, syscall.SIGKILL) + t.Fatalf("process group %d outlived the task that started it", group) + } + time.Sleep(20 * time.Millisecond) + } +} + +// waitForFile waits for a file a background command is about to write. +func waitForFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + if _, err := os.Stat(path); err == nil { + return + } + if time.Now().After(deadline) { + return + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/session/bashbelt_envelope.go b/internal/session/bashbelt_envelope.go index 828c140cfa..ba8cfa4303 100644 --- a/internal/session/bashbelt_envelope.go +++ b/internal/session/bashbelt_envelope.go @@ -13,11 +13,19 @@ import ( // // THE ENVELOPE IS THE EXPERIMENT'S SECOND BET (docs/design/bash-task-loop/ // DESIGN.md, Decision 2): a bash-belt worker gets the one-action discipline — -// exactly one tool call per response, and it names bash — in exchange for -// parallelism moving into the shell, where `&` and `xargs -P` have always -// lived. The branch belt carries one tool, so a response carrying anything -// else is not a batch, it is a response the model could not drive the belt -// with, and running a piece of it would answer a question nobody asked. +// exactly one tool call per response — in exchange for parallelism moving into +// the shell, where `&` and `xargs -P` have always lived. A response carrying +// two calls is not a batch, it is a response the model could not drive the +// belt with, and running a piece of it would answer a question nobody asked. +// +// THE ONE CALL MAY NAME ANY HAND THIS BELT CARRIES, and bash is only the +// commonest. The belt keeps `jobs`, `read_document` and `manual` because none +// of them can be a shell command (Decision 6 and the tool table), and the +// worker's page sends the worker to the first two. An envelope that refused +// every name but bash made those hands present and broken at once: a worker +// whose `find /` had become a job called `jobs` to stop it, was told `jobs` +// was not on this belt, and the walk ran on for the rest of the task. A name +// the belt does not carry is still refused here, before anything runs. // // THE REJECT IS THE SAME ON BOTH BRANCHES. A response whose calls are // addressable — every call carries a non-empty, unique id — is answered with @@ -63,7 +71,11 @@ const bashEnvelopeStop = "stopped: four responses in a row carried no valid sing // bashEnvelopeFault is what is wrong with one submission under the envelope, // and empty for one that may run. A response with no calls is a final answer, // not an invalid action — ending the turn in words is how this loop finishes. -func bashEnvelopeFault(calls []ai.ToolCall) string { +// +// WHAT THE BELT CARRIES IS READ OFF THE BELT ITSELF ([Agent.beltTools]), the +// same list the request's definitions were built from, so the envelope and the +// wire cannot come to disagree about which names a call may carry. +func (a *Agent) bashEnvelopeFault(calls []ai.ToolCall) string { if len(calls) == 0 { return "" } @@ -74,8 +86,8 @@ func bashEnvelopeFault(calls []ai.ToolCall) string { if !bashCallsAddressable(calls) { return bashEnvelopeMark + "no action executed: a tool call carries no id, so its result could never be paired with it — send the bash call again as the provider's tool-call form" } - if call.Function.Name != "bash" { - return bashEnvelopeMark + "no action executed: `" + call.Function.Name + "` is not on this belt — the one tool is bash, and what it cannot do is spelled in the belt's own page" + if !a.beltCarries(call.Function.Name) { + return bashEnvelopeMark + "no action executed: `" + call.Function.Name + "` is not on this belt — bash is the hand for files and commands, and what the belt cannot do is spelled in its own page" } // THE ARGUMENTS ARE READ WITH THE ONE DECODER EVERY TOOL USES, so a bash // call is refused in the same words on the branch belt as on today's — a @@ -86,6 +98,17 @@ func bashEnvelopeFault(calls []ai.ToolCall) string { return "" } +// beltCarries answers whether a tool of this name is on the belt the request +// was built from. +func (a *Agent) beltCarries(name string) bool { + for _, tool := range a.beltTools() { + if tool.Name == name { + return true + } + } + return false +} + // rejectBashEnvelope answers one invalid submission without running any of it // and without letting the malformed shape re-enter what the model reads next. // @@ -223,7 +246,7 @@ func (a *Agent) enforceBashEnvelope(ctx context.Context, hub *eventHub, calls [] if !a.config.mayBashBelt() { return false, false } - fault := bashEnvelopeFault(calls) + fault := a.bashEnvelopeFault(calls) if fault == "" { return false, false } diff --git a/internal/session/bashbelt_jobs_test.go b/internal/session/bashbelt_jobs_test.go new file mode 100644 index 0000000000..fa623e140a --- /dev/null +++ b/internal/session/bashbelt_jobs_test.go @@ -0,0 +1,116 @@ +package session + +import ( + "context" + "errors" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// TestBashBeltWorkerStopsItsOwnJobThroughJobs is the defect the fresh-install +// run showed: a bash-belt worker whose `find /` had become job 1 called the +// `jobs` tool its belt carries, and the envelope answered that `jobs` was not +// on this belt, so the walk ran on for the rest of the task. A worker must be +// able to stop a job it started through the door the belt hands it, and the +// kill must reach the job's whole process group. +func TestBashBeltWorkerStopsItsOwnJobThroughJobs(t *testing.T) { + completer := &routedCompleter{parent: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("start", "bash", `{"command":"sleep 900","background":true}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("stop", "jobs", `{"action":"kill","id":1}`), nil + }, + finalText("stopped it"), + }} + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.bashBelt = true + config.InTask = true + config.taskID = 1 + }) + collect(t, mustSubmit(t, agent, "go")) + + jobs := agent.jobs.all() + if len(jobs) != 1 || jobs[0].cmd == nil || jobs[0].cmd.Process == nil { + t.Fatalf("the background start left %d jobs, want the one sleep", len(jobs)) + } + group := jobs[0].cmd.Process.Pid + + var answer string + agent.mu.Lock() + for _, message := range agent.messages { + if message.Role == "tool" && message.ToolCallID == "stop" { + answer = messageContentText(message) + } + } + agent.mu.Unlock() + if strings.HasPrefix(answer, bashEnvelopeMark) { + t.Fatalf("the jobs call was refused by the envelope: %s", answer) + } + if answer != "job 1 killed" { + t.Fatalf("the jobs kill answered %q, want the registry's own `job 1 killed`", answer) + } + if !processGroupGone(group) { + t.Fatalf("process group %d is still alive after the worker stopped its job", group) + } +} + +// TestBashWorkerPageNamesOnlyDoorsTheEnvelopeAdmits is CLAUDE.md's law about +// system prompts, read against the envelope rather than against the composed +// list: a tool the page sends the worker to must be one a call to it RUNS, not +// merely one the wire lists. The page named `jobs` and `read_document` while +// the envelope refused every name but bash, and the prompt law's other test +// could not see it, because both names were composed onto the belt. +func TestBashWorkerPageNamesOnlyDoorsTheEnvelopeAdmits(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, bashBeltWorkerConfig(t)) + page := renderSystemAt(agent.config, time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)) + carried := beltNameSet(agent.beltTools()) + named := namesIn(page) + for _, door := range []string{"jobs", "read_document"} { + if !named[door] { + t.Errorf("the bash worker's page no longer names `%s`; this law reads it", door) + } + } + for name := range named { + if !carried[name] || name == "bash" { + continue + } + call := ai.ToolCall{ID: "call-" + name, Type: "function", Function: ai.ToolCallFunction{Name: name, Arguments: `{}`}} + if fault := agent.bashEnvelopeFault([]ai.ToolCall{call}); fault != "" { + t.Errorf("the page names `%s`, the belt carries it, and the envelope refuses a call to it: %s", name, fault) + } + } +} + +// TestBashBeltEnvelopeStillRefusesANameTheBeltDoesNotCarry keeps the other +// half of the envelope: a name nothing on this belt answers to is refused +// before anything runs, in the envelope's own voice. +func TestBashBeltEnvelopeStillRefusesANameTheBeltDoesNotCarry(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, bashBeltWorkerConfig(t)) + for _, name := range []string{"read", "edit", "grep", "no_such_tool"} { + call := ai.ToolCall{ID: "call-" + name, Type: "function", Function: ai.ToolCallFunction{Name: name, Arguments: `{}`}} + fault := agent.bashEnvelopeFault([]ai.ToolCall{call}) + if !strings.HasPrefix(fault, bashEnvelopeMark) || !strings.Contains(fault, "`"+name+"` is not on this belt") { + t.Errorf("a call to %s was not refused as a name the belt does not carry: %q", name, fault) + } + } +} + +// processGroupGone polls briefly for a process group to have no members left, +// because a kill that has been answered may still be reaping the last child. +func processGroupGone(group int) bool { + deadline := time.Now().Add(5 * time.Second) + for { + if err := syscall.Kill(-group, 0); errors.Is(err, syscall.ESRCH) { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 06f256c6ed..b11c9dccd2 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -162,8 +162,7 @@ is on disk: read the range you need from it with `sed -n`, or cat it whole. PDFs, scans and office documents go to `read_document`, never to `cat`: catting a PDF yields bytes, and the billed parser is on the belt for exactly that -page. A job started in the background is read through `jobs`, which is where -its log path is. +page. A job is read and stopped with the `jobs` tool, not the shell's builtin. Never simulate execution. Do not describe what a command would do, do not write the output you expect: run it, and read the observation. From 6e65cee112d4882d7b7aa832865dd0f7a8699561 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:57:28 -0400 Subject: [PATCH 2/3] session: a run's checker is told the work is in its own copy Nothing in a check's opening said where the work was. The only path in it was the footer's working directory, the run's copy, whose project folder name spells the person's checkout. A checker decoded it, began with `cd /tmp/fxfresh/repo`, read an unrelated diff there, and moved home only when a write was refused; a check that only reads is never refused. The check section now says the work is in its working directory, every check and probe runs there, and the person's checkout is not the work. The manual gains the section a person asks when they saw a check read their checkout, and a probe for asking how a task stops its own job. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/how-tasks-run.md | 14 ++++++ internal/manual/chat_test.go | 4 ++ internal/run/check_copy_test.go | 62 +++++++++++++++++++++++++++ internal/session/bashbelt_worker.go | 11 ++++- 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 internal/run/check_copy_test.go diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 24bec2dc1c..a5bbb2235f 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -1704,6 +1704,20 @@ How the restore is built depends on your workspace: The task's own checkout is untouched by any of this, and the restore is removed as soon as the answer is in. +## Which folder the checker reads — did the check look at my checkout or the task's copy + +**A task's checker checks the task's own copy, never your checkout.** When a part of the +work is finished, the checker — the seat that reads that part against what it was asked +for — stands in the same copy the worker wrote in, and its instructions say so: +the work is in its working directory, and every check and probe runs there. + +Your checkout is not the work while the task runs. It does not hold the result until the +task lands, and it may hold changes of yours, or of other work, that are not this task's — +so a check read there could pass or fail the task on the wrong diff. + +The checker may still read other folders, as every worker may. It writes only in its copy, +and a write aimed anywhere else is refused before it runs. + ## The check says my tests fail but they were already failing · red before the task started · my task was refused over somebody else's bug · pre-existing failures A worker committing its own edits does not move this baseline. A restored task diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 787031c2c8..7362e99221 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -799,6 +799,10 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I open my tasks on a phone", "tasks"}, {"how do I get back from a task on my phone", "tasks"}, {"do tasks touch my working copy", "how-tasks-run"}, + // The run's checker is told the work is in its own copy; a person who + // saw it read their checkout asks in these words. + {"did the checker read my checkout instead of the task's copy", "how-tasks-run"}, + {"can a task stop a job it started", "how-tasks-run"}, // C14: repository placement, protected landings and kept dependency // inheritance are reachable in the words a person uses after meeting them. {"why didn't my task merge", "how-tasks-run"}, diff --git a/internal/run/check_copy_test.go b/internal/run/check_copy_test.go new file mode 100644 index 0000000000..42a59c7503 --- /dev/null +++ b/internal/run/check_copy_test.go @@ -0,0 +1,62 @@ +package run_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/run" +) + +// TestCheckWorkerIsToldTheWorkIsInItsOwnCopy is the fresh-install checker that +// began with `cd /tmp/fxfresh/repo`: nothing in its opening said where the work +// was, so it decoded the person's checkout out of the project folder's name, +// read that tree's unrelated diff, and was moved home only when a write there +// was refused. The opening a check really gets — built by the run's own worker +// seat, from a check the review round's store door seated — must say the work +// is in its own working directory and that the person's checkout is not it. +func TestCheckWorkerIsToldTheWorkIsInItsOwnCopy(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", stubCLI(t)) + store := runOpenStore(t) + copyDir := filepath.Join(t.TempDir(), "h", ".codeaf", "v3", "projects", "-tmp-x-repo", "abc", "trees", "2") + if err := os.MkdirAll(copyDir, 0o755); err != nil { + t.Fatal(err) + } + check, err := store.AddReviewCheck(plandb.TaskSpec{ + ID: "chk", + Title: "check: fix the bug", + Description: "Acceptance: fix the bug that makes test_calc.py fail\n\nResult: one-line fix in calc.py", + Role: plandb.RoleCheck, + }) + if err != nil { + t.Fatal(err) + } + // The opening is all this test reads, so the run is cut the moment the + // first request carrying it has been recorded. + ctx, cut := context.WithCancel(runContext(t)) + defer cut() + seat := &seat{script: []step{func(context.Context, []ai.Message) (*ai.Response, error) { + cut() + return textReply("done"), nil + }}} + _, _ = run.NewBashWorker(store, copyDir, "test/model", seat).Run(run.WithStepsPerTask(ctx, 2), *check) + + opening := seat.opening(t) + for _, want := range []string{ + "The work is in your working directory", + "run every check and probe there", + "The person's own checkout is not the work", + } { + if !strings.Contains(opening, want) { + t.Errorf("the check's opening does not say %q:\n%s", want, opening) + } + } + if strings.Contains(opening, "/tmp/x/repo") { + t.Errorf("the check's opening names the person's checkout as a place:\n%s", opening) + } +} diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index 2b93761a93..d965c758aa 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -228,12 +228,21 @@ func askSection(ask string) string { // A DOER: it reads the acceptance above against the result above, proves each // sentence with the leaf's own tests or one probe, and answers in one of the two // shapes the finding is read from ([internal/run]'s recordCheckFinding reads -// "does not hold:"). It is written to stay under 120 words, because the whole +// "does not hold:"). It is written to stay under 200 words, because the whole // job is one comparison and a wall of instruction is the drift it exists to stop. +// +// AND IT SAYS WHERE THE WORK IS. Nothing else in a check's opening does: the +// footer's working directory is the run's copy, and its path spells the +// person's checkout inside the project folder's name. A check told nothing +// decoded that name, stood in the person's checkout, read an unrelated diff +// there, and was moved home only when a write was refused — and a check that +// only reads is never refused, so it would have answered on the wrong tree. const checkSection = `## Who checks this work You are the check, not the doer: you read the acceptance above against the result above, and you do not redo the work. +The work is in your working directory: that copy holds the worker's result, so run every check and probe there. The person's own checkout is not the work — it does not hold this result until the run lands, and it may hold changes that are not this task's — so never check it. + Read the acceptance sentence by sentence. First run every command declared under Checks:, in order and exactly as spelled. Then, for every acceptance sentence those checks do not cover, run one probe — the smallest command that would fail were that sentence not met. Answer with exactly one of these, as your whole result: From 6dad24f794631d16f6db36da3e38a76e5784d4d8 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 17:00:04 -0400 Subject: [PATCH 3/3] changes: the change entry for #1515 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../unreleased/1515-worker-jobs-and-checker-copy.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/changes/unreleased/1515-worker-jobs-and-checker-copy.md diff --git a/docs/changes/unreleased/1515-worker-jobs-and-checker-copy.md b/docs/changes/unreleased/1515-worker-jobs-and-checker-copy.md new file mode 100644 index 0000000000..d3bc2c2b7d --- /dev/null +++ b/docs/changes/unreleased/1515-worker-jobs-and-checker-copy.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: a task's worker can stop its own job, and a run's checker checks its own copy +pr: 1515 +surface: [chat, engine] +invalidates: + - "A bash-belt worker's one call had to name bash: the envelope refused `jobs`, `read_document` and `manual` with \"is not on this belt\" although the belt carried them and the worker's page named two of them. One call per response may now name any tool the belt carries, so a worker reads and stops its own job with `jobs`; a name the belt does not carry is still refused." + - "A run's checker was never told where the work was, and could start in the person's checkout, decoded from the project folder's name. Its instructions now say the work is in its working directory, the run's copy, and that the person's checkout is not the work." +---