From caa6d393e0dc5831814448f31c69e33a4d01ed4e Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:32:42 -0400 Subject: [PATCH 1/8] plandb: check terminal status before ownership check in Done, prune search guidance In Store.Done(), check if task is already terminal before checking requireOwner(). When a task is cancelled upstream, ClaimedBy is cleared or empty, which previously caused Done to fail obscurely with 'task is not claimed'. Checking terminal status first reports an explicit and actionable error: 'task is already terminal (cancelled)'. Also update bashworker prompt guidance to prune heavy directories when running find, preventing workers from freezing across deep trees. Fixes #1561 Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/plandb/done_terminal_test.go | 21 +++++++++++++++++++++ internal/plandb/store.go | 3 +++ internal/session/prompts/bashworker.md | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 internal/plandb/done_terminal_test.go diff --git a/internal/plandb/done_terminal_test.go b/internal/plandb/done_terminal_test.go new file mode 100644 index 0000000000..8719d36481 --- /dev/null +++ b/internal/plandb/done_terminal_test.go @@ -0,0 +1,21 @@ +package plandb + +import ( + "testing" +) + +func TestDoneRefusesAlreadyTerminalTaskExplicitly(t *testing.T) { + store := planOpen(t, "") + planAdd(t, store, TaskSpec{ID: "leaf", Title: "cancelled leaf"}) + if _, err := store.Cancel("leaf", "cancelled by supervisor"); err != nil { + t.Fatalf("Cancel: %v", err) + } + + _, err := store.Done("leaf", "worker1", "all finished", nil, nil) + if err == nil { + t.Fatalf("expected error finishing cancelled task, got nil") + } + if want := `task "leaf" is already terminal (cancelled)`; err.Error() != want { + t.Fatalf("got error %q, want %q", err.Error(), want) + } +} diff --git a/internal/plandb/store.go b/internal/plandb/store.go index b54b2167b9..eca1b22511 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -611,6 +611,9 @@ func (s *Store) Done(id, agent, result string, artifacts, evidence []string) (*T // worker's own done, a real ending — keeps its words and its owner. placeholder := task.Status == StatusDone && strings.TrimSpace(task.Result) == "" && task.ClaimedBy == "" if !placeholder { + if terminal(task.Status) { + return fmt.Errorf("task %q is already terminal (%s)", id, task.Status) + } // THE ROOT IS NEVER CLAIMED, so its worker cannot answer the ownership // check every other task's worker does. The root's own worker is named // instead, above, and the finish law still holds: the root cannot close diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 9400b321ab..736f897ee4 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -125,7 +125,8 @@ Parallelism lives in the shell, not in the batch: ``` cmd1 & cmd2 & wait # two commands at once, both waited for -find . -type f -name '' | xargs -P 4 grep -l +# When searching filenames, always prune heavy trees (.git, node_modules, vendor, .venv): +find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name .venv \) -prune -o -type f -name '' -print | xargs -P 4 grep -l git grep -n "theSymbol" # one search instead of three ``` From c82483b635bc3de53652de4a68099cfd0174713d Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:38:00 -0400 Subject: [PATCH 2/8] session: fall back to project place workspace when workspace is non-repository When a session operates with workspace outside a repository (e.g. user home folder ~ in a team manager session), task ground derivation would fall through to taskGroundNothing at dir: ~, leading spawned tasks to inherit ~ as ground. Workers looking for repository docs like docs/design/plandb-cli/ would then fail to locate them and launch unpruned find commands across the entire home drive. Fall back to a.config.Place.Workspace when workspace has no repository root, preserving taskGroundStandingIn on the intended project repository. Fixes #1561 Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/session/taskstands.go | 5 +++++ internal/session/taskstands_test.go | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 65b26c50fa..5c5f76b878 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -365,6 +365,11 @@ func (a *Agent) groundLadder(spec taskSpec, workspace string) taskStand { if root, ok := repositoryRoot(workspace); ok { return taskStand{dir: root, rung: taskGroundStandingIn} } + if projectWorkspace := strings.TrimSpace(a.config.Place.Workspace); projectWorkspace != "" && projectWorkspace != workspace { + if root, ok := repositoryRoot(projectWorkspace); ok { + return taskStand{dir: root, rung: taskGroundStandingIn} + } + } return taskStand{dir: workspace, rung: taskGroundNothing} } diff --git a/internal/session/taskstands_test.go b/internal/session/taskstands_test.go index 94d964e979..f6fe3bbcb6 100644 --- a/internal/session/taskstands_test.go +++ b/internal/session/taskstands_test.go @@ -775,6 +775,17 @@ func TestTheGroundLadderClimbsInOrder(t *testing.T) { } }) + t.Run("workspace outside repo falls back to place workspace", func(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = plain + config.Place = Place{Dir: t.TempDir(), Workspace: repo} + }) + stand := agent.resolveTaskGround(taskSpec{deliverable: "an answer", acceptance: "it is written"}) + if stand.dir != canonicalPath(repo) || stand.rung != taskGroundStandingIn { + t.Fatalf("stand = %+v, want dir=%s rung=%s", stand, repo, taskGroundStandingIn) + } + }) + t.Run("a repository the work only reads is not branched", func(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.Workspace = plain }) stand := agent.resolveTaskGround(taskSpec{ From 2653fddc1026ff8e87ab1dc7107d209ecef26765 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:43:14 -0400 Subject: [PATCH 3/8] docs(changes): add changelog entry for PR 1562 Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- .../1562-plandb-done-terminal-and-manager-ground.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md diff --git a/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md new file mode 100644 index 0000000000..4ede69d861 --- /dev/null +++ b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: plandb done refuses terminal tasks and manager tasks derive project workspace +pr: 1562 +surface: [engine, resident] +invalidates: + - "plandb done on a cancelled task failed with 'task is not claimed'; it now explicitly reports that the task is already terminal." + - "a task proposed by a manager whose workspace was ~ inherited ~ as folder ground; it now falls back to Place.Workspace." +--- + +When a task was cancelled upstream, plandb done called requireOwner first, which failed with a misleading 'task is not claimed' error because cancelled tasks clear their claim. Now terminal status is checked before owner verification (preserving placeholder auto-completion). + +In addition, groundLadder now falls back from a non-repository workspace (such as ~) to a configured project Place.Workspace rather than landing tasks directly in ~. From f6aca9bbc7fb8d45ac41b7f5f34d2e160e7425ea Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Sat, 26 Sep 2026 22:47:28 -0400 Subject: [PATCH 4/8] prompts: restore bashworker prompt to keep prompt size under 16 KiB Assisted-by: CodeAF (gemini-3.8-flash-high) Co-Authored-By: CodeAF <267109073+agentfield-bot@users.noreply.github.com> --- internal/session/prompts/bashworker.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 736f897ee4..9400b321ab 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -125,8 +125,7 @@ Parallelism lives in the shell, not in the batch: ``` cmd1 & cmd2 & wait # two commands at once, both waited for -# When searching filenames, always prune heavy trees (.git, node_modules, vendor, .venv): -find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name .venv \) -prune -o -type f -name '' -print | xargs -P 4 grep -l +find . -type f -name '' | xargs -P 4 grep -l git grep -n "theSymbol" # one search instead of three ``` From 54ceea1fbdc201f49fda935737af7df65bddafd8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:21:41 -0400 Subject: [PATCH 5/8] test: allow one explicit live verification model across roles --- internal/e2e/harness_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go index e315f3ee88..b9b3b0956f 100644 --- a/internal/e2e/harness_test.go +++ b/internal/e2e/harness_test.go @@ -43,7 +43,16 @@ import ( // alias marker and is dropped everywhere in this build (internal/catalog's // normalizeID, internal/provider's normalizeModel), so the plain slug is the // same model and is what the settings row takes. -const e2eModel = "deepseek/deepseek-v4-flash" +var e2eModel = liveVerificationModel() + +// One explicit override reaches the conversation, worker, auxiliary-role and +// fallback pins together. A lane must still verify its recorded model receipts. +func liveVerificationModel() string { + if model := strings.TrimSpace(os.Getenv("CODEAF_E2E_MODEL")); model != "" { + return model + } + return "deepseek/deepseek-v4-flash" +} // personConfig is the credentials this lane borrows: the profile in the // person's OWN codeaf home, read before the throwaway one is put in front of From 780f22cb98d3997ddb99392346d0c013ccd83e20 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:29:10 -0400 Subject: [PATCH 6/8] fix: resolve project ground on the default run door --- ...plandb-done-terminal-and-manager-ground.md | 9 ++++ internal/manual/chat/how-tasks-run.md | 20 ++++++++ internal/plandb/done_terminal_test.go | 17 +++++++ internal/plandb/store.go | 8 ++-- internal/session/prompts/bashworker.md | 16 +++---- internal/session/task_run_belt.go | 6 ++- internal/session/task_run_belt_test.go | 41 +++++++++++++++++ internal/session/taskstands.go | 2 +- internal/session/taskstands_test.go | 46 +++++++++++++++++++ 9 files changed, 150 insertions(+), 15 deletions(-) diff --git a/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md index 4ede69d861..e66c170ddb 100644 --- a/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md +++ b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md @@ -11,3 +11,12 @@ invalidates: When a task was cancelled upstream, plandb done called requireOwner first, which failed with a misleading 'task is not claimed' error because cancelled tasks clear their claim. Now terminal status is checked before owner verification (preserving placeholder auto-completion). In addition, groundLadder now falls back from a non-repository workspace (such as ~) to a configured project Place.Workspace rather than landing tasks directly in ~. + +The default bash run door now uses the same ground resolver as proposed and +legacy tasks. Previously a typed `/task` silently bypassed that resolver and +copied the conversation directory even when its brief named a repository. +The configured-project fallback applies only to roots; children keep their +parent's folder. Bash guidance uses bounded project discovery and directs +workers to `plandb --help` instead of hunting for missing design documents. +Cancelled review tasks report their terminal state before validating review +results, and rejected completion leaves the stored task unchanged. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 3a50c1e6b0..8d7b1c61c8 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3332,3 +3332,23 @@ object beside its matching tool result, within the existing context budget. A la input is explicitly marked omitted, rather than shown as a partial object. Earlier failures remain part of the evidence. The model continuing the work is told to check a reader's objection against the actual work before changing an already-correct result. + +## Worker starts in home instead of the project — missing relative documents + +When a top-level task has no stronger folder instruction and the conversation's +working directory is outside a repository, codeaf uses its configured project +repository. A child keeps its parent's directory, including an ordinary folder; +a project fallback must not move it elsewhere. Explicit placement still wins. + +Bash workers search the assigned project with `rg` or `git grep`. A missing +relative document calls for checking the working directory and project first, +not a recursive search of the whole home directory. `plandb --help` explains +the available plan commands without looking for repository design documents. + +## plandb done says already terminal — cancelled tasks and ownership + +A task cancelled by its supervisor stays cancelled. A late `plandb done` reports +`task "" is already terminal (cancelled)` instead of `is not claimed`. +The refusal does not reopen the task or change its result. Active tasks still +require their owner; an automatically completed composite's empty placeholder +can still receive its final report. diff --git a/internal/plandb/done_terminal_test.go b/internal/plandb/done_terminal_test.go index 8719d36481..952b2fe014 100644 --- a/internal/plandb/done_terminal_test.go +++ b/internal/plandb/done_terminal_test.go @@ -1,6 +1,7 @@ package plandb import ( + "reflect" "testing" ) @@ -11,6 +12,7 @@ func TestDoneRefusesAlreadyTerminalTaskExplicitly(t *testing.T) { t.Fatalf("Cancel: %v", err) } + before := *store.Task("leaf") _, err := store.Done("leaf", "worker1", "all finished", nil, nil) if err == nil { t.Fatalf("expected error finishing cancelled task, got nil") @@ -18,4 +20,19 @@ func TestDoneRefusesAlreadyTerminalTaskExplicitly(t *testing.T) { if want := `task "leaf" is already terminal (cancelled)`; err.Error() != want { t.Fatalf("got error %q, want %q", err.Error(), want) } + if after := *store.Task("leaf"); !reflect.DeepEqual(before, after) { + t.Fatalf("refused completion mutated task: before=%+v after=%+v", before, after) + } +} + +func TestDoneCancelledCheckReportsTerminalBeforeReviewValidation(t *testing.T) { + store := planOpen(t, "") + planAdd(t, store, TaskSpec{ID: "check", Title: "review", Role: RoleCheck}) + if _, err := store.Cancel("check", "stopped"); err != nil { + t.Fatal(err) + } + _, err := store.Done("check", "worker", "holds: finished", nil, nil) + if err == nil || err.Error() != `task "check" is already terminal (cancelled)` { + t.Fatalf("cancelled review completion: %v", err) + } } diff --git a/internal/plandb/store.go b/internal/plandb/store.go index eca1b22511..05bf2b01b7 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -583,6 +583,10 @@ func (s *Store) Done(id, agent, result string, artifacts, evidence []string) (*T if err := refuseParked(task); err != nil { return err } + placeholder := task.Status == StatusDone && strings.TrimSpace(task.Result) == "" && task.ClaimedBy == "" + if !placeholder && terminal(task.Status) { + return fmt.Errorf("task %q is already terminal (%s)", id, task.Status) + } text := strings.TrimSpace(result) // A REVIEW CONCLUSION CARRIES ITS BASIS WITH IT, written by the same // gate that judged it: a holds conclusion is refused unless every @@ -609,11 +613,7 @@ func (s *Store) Done(id, agent, result string, artifacts, evidence []string) (*T // landing is what the placeholder was waiting for, and the caller that // fills it adopts the task as its own. Any task with words in it — a // worker's own done, a real ending — keeps its words and its owner. - placeholder := task.Status == StatusDone && strings.TrimSpace(task.Result) == "" && task.ClaimedBy == "" if !placeholder { - if terminal(task.Status) { - return fmt.Errorf("task %q is already terminal (%s)", id, task.Status) - } // THE ROOT IS NEVER CLAIMED, so its worker cannot answer the ownership // check every other task's worker does. The root's own worker is named // instead, above, and the finish law still holds: the root cannot close diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 9400b321ab..3f26cd5749 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -4,11 +4,8 @@ This belt carries ONE tool: `bash`. The hands other workers reach for as tools are shell commands here, and this page is their doctrine. Everything on this belt that is not a shell command is named at the bottom. -A response that carries two calls, or a call for a hand that is not here, or -arguments that do not parse, runs NOTHING: what comes back instead is a line -beginning `[not run]` saying what was wrong, and nothing has entered the world. -Fix the shape and send the command again — a good call was never the problem, -so the step before a rejection is simply the corrected call. +Invalid or multiple calls run NOTHING. A `[not run]` reply explains the error; +correct it and retry. Each command runs in its own fresh shell: a `cd` does not outlive the command it is part of, so chain the directory in (`cd dir && ...`) or use @@ -125,7 +122,7 @@ Parallelism lives in the shell, not in the batch: ``` cmd1 & cmd2 & wait # two commands at once, both waited for -find . -type f -name '' | xargs -P 4 grep -l +rg --files -g '' # bounded discovery git grep -n "theSymbol" # one search instead of three ``` @@ -152,9 +149,10 @@ The idioms, in place of the tools other belts carry: through a `sed -i` aimed at one region. Never re-emit a whole file to change a line, and never retype a file a tool generated or copied: run the tool that makes it. -- Search inside a repository with git grep -n pattern — it respects - .gitignore the way a search tool would. Outside a repository, grep -rn - --exclude-dir=.git pattern. +- Search a named project with `rg` or `git grep`; discover files with `rg --files`. + Never widen a missing relative doc into a home-wide recursive search. Check + `pwd` and the assigned project first; use `plandb --help` for its contract. + Exclude .git, node_modules, vendor, .venv, Library and runtime logs. A big result is cut to its first half and its last half, and the WHOLE output is filed beside this node's own log; the result names that file with a line diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 8b0f92deaf..2cfc30b6ce 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -381,7 +381,11 @@ func (a *Agent) startTaskRun(ctx context.Context, brief string, solo bool, quest id := g.reserve() title := taskPersonTitle(brief) - stand := taskStand{dir: a.config.Workspace, mode: TaskModeWorktree} + // The default run door uses the same placement evidence as a proposal or + // legacy task. Starting from home must not discard the named project. + stand := a.taskGroundOrStandingIn(taskSpec{ + title: title, request: brief, brief: brief, acceptance: taskPersonAcceptance, + }) if err := a.startKnownTaskRun(ctx, id, title, brief, nil, stand, question); err != nil { if errors.Is(err, errRunRoadUnavailable) { return a.startTaskLegacy(ctx, brief, solo) diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index cac5f03a75..470c3c6f23 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -1504,3 +1504,44 @@ func TestStartTaskBashBeltPassesTheDollarLimitLeftToTheRun(t *testing.T) { } endBeltRun(t, agent, double) } + +func TestStartTaskRunUsesProjectGroundOutsideRepository(t *testing.T) { + for _, named := range []bool{false, true} { + t.Run(strconv.FormatBool(named), func(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("read the project") + registerBeltRunEngine(t, double) + repo, dir := newTestRepo(t), t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = t.TempDir() + config.Place = Place{Dir: dir, Workspace: repo} + if named { + config.Place.Workspace = "" + } + config.SessionFile = filepath.Join(dir, placeTranscript) + config.AskConsent = false + }) + brief := "Read README.md and write result.txt" + if named { + brief += " in repository " + repo + } + if _, _, _, err := agent.StartTask(context.Background(), brief, false); err != nil { + t.Fatal(err) + } + defer endBeltRun(t, agent, double) + beltRunWaitFor(t, "the project run to start", double.didRun) + double.mu.Lock() + workspace := double.spec.Workspace + double.mu.Unlock() + if data, err := os.ReadFile(filepath.Join(workspace, "shared.txt")); err != nil || len(data) == 0 { + t.Fatalf("worker cannot read project document: %q %v (workspace=%s)", data, err, workspace) + } + agent.beltMu.Lock() + ground := agent.beltRun.ground + agent.beltMu.Unlock() + if ground != canonicalPath(repo) || workspace == ground { + t.Fatalf("ground=%s workspace=%s want isolated copy of %s", ground, workspace, repo) + } + }) + } +} diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 5c5f76b878..2159e51db3 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -365,7 +365,7 @@ func (a *Agent) groundLadder(spec taskSpec, workspace string) taskStand { if root, ok := repositoryRoot(workspace); ok { return taskStand{dir: root, rung: taskGroundStandingIn} } - if projectWorkspace := strings.TrimSpace(a.config.Place.Workspace); projectWorkspace != "" && projectWorkspace != workspace { + if projectWorkspace := strings.TrimSpace(a.config.Place.Workspace); spec.parent == 0 && projectWorkspace != "" && projectWorkspace != workspace { if root, ok := repositoryRoot(projectWorkspace); ok { return taskStand{dir: root, rung: taskGroundStandingIn} } diff --git a/internal/session/taskstands_test.go b/internal/session/taskstands_test.go index f6fe3bbcb6..5dfbebb28c 100644 --- a/internal/session/taskstands_test.go +++ b/internal/session/taskstands_test.go @@ -48,6 +48,41 @@ func heldTaskWorld(t *testing.T, agent *Agent, path, content string) (<-chan tas return world, done } +// A manager outside its project must hand the actual repository files to its +// worker, not just report the right ground on the proposal card. +func TestManagerProjectFallbackOpensAWorktreeWithItsDocuments(t *testing.T) { + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, "DESIGN.md"), "project design\n") + gitOut(t, repo, "add", "DESIGN.md") + gitOut(t, repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "add project design") + place := Place{Dir: t.TempDir(), Workspace: repo} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = t.TempDir() + config.Place = place + config.AskConsent = false + config.TaskAutoApproveSeconds = 0 + }) + world, release := heldTaskWorld(t, agent, "result.txt", "done\n") + arguments, _ := json.Marshal(taskArguments{ + Title: "read project design", Summary: "use the project document", + Brief: "Read DESIGN.md and write result.txt", Deliverable: "result.txt", + Acceptance: "result.txt contains the result", + }) + result, isError, err := agent.proposeTask(context.Background(), arguments) + if err != nil || isError { + t.Fatalf("proposeTask = %q, error=%v, isError=%v", result, err, isError) + } + tree := <-world + if tree.root != canonicalPath(repo) || !withinDir(place.Trees(), tree.dir) { + t.Fatalf("worker is not in the project's isolated tree: %+v", tree) + } + if content, err := os.ReadFile(filepath.Join(tree.dir, "DESIGN.md")); err != nil || string(content) != "project design\n" { + t.Fatalf("relative project document missing in worker: %q, %v", content, err) + } + release() + waitDoneNode(t, agent.graph().node(1)) +} + // C1: a model asking for in-place repository work gets a task branch, the live // checkout stays untouched, and the proposal receipt says why it was redirected. func TestC1AProposalCannotPutRepositoryWorkInTheCheckout(t *testing.T) { @@ -786,6 +821,17 @@ func TestTheGroundLadderClimbsInOrder(t *testing.T) { } }) + t.Run("a folder child keeps its parent despite a project fallback", func(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = plain + config.Place = Place{Dir: t.TempDir(), Workspace: repo} + }) + stand := agent.resolveTaskGround(taskSpec{parent: 3, depth: 2, deliverable: "an answer", acceptance: "it is written"}) + if stand.dir != canonicalPath(plain) || stand.mode != TaskModeFolder { + t.Fatalf("folder child moved away from its parent: %+v", stand) + } + }) + t.Run("a repository the work only reads is not branched", func(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.Workspace = plain }) stand := agent.resolveTaskGround(taskSpec{ From 23d1ab588a0a6a66143c3aea3f0e88dd978c39ce Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 10:38:03 -0400 Subject: [PATCH 7/8] fix: tell every plan worker its assigned working directory --- ...plandb-done-terminal-and-manager-ground.md | 5 ++++ internal/manual/chat/how-tasks-run.md | 4 ++++ internal/run/bashworker.go | 2 +- internal/run/bashworker_test.go | 6 ++++- internal/session/bashbelt_worker.go | 11 ++++++++- internal/session/bashbelt_worker_test.go | 23 +++++++++++++++++++ 6 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md index e66c170ddb..d44a548477 100644 --- a/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md +++ b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md @@ -20,3 +20,8 @@ parent's folder. Bash guidance uses bounded project discovery and directs workers to `plandb --help` instead of hunting for missing design documents. Cancelled review tasks report their terminal state before validating review results, and rejected completion leaves the stored task unchanged. + +The shared bash-worker brief now names the actual assigned working directory +before the work order, for workers and checkers alike. A request can quote the +source checkout without inviting the worker to leave its isolated copy; the +request and unrelated read-only reference paths remain unchanged. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 8d7b1c61c8..223c206d45 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3340,6 +3340,10 @@ working directory is outside a repository, codeaf uses its configured project repository. A child keeps its parent's directory, including an ordinary folder; a project fallback must not move it elsewhere. Explicit placement still wins. +Every bash worker and checker receives its assigned working directory before the +work order. Original checkout paths in the request stay quoted, but project edits +and checks belong in the assigned directory; unrelated reference paths stay literal. + Bash workers search the assigned project with `rg` or `git grep`. A missing relative document calls for checking the working directory and project first, not a recursive search of the whole home directory. `plandb --help` explains diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 14ffd6bc7e..7c4e3c0791 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -133,7 +133,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // THE BRIEF IS SAID ONCE, on the first round. Every round after it goes out // on the harness's own note, because a round only begins again when the last // one ended on words with no action. - brief := session.BeltWorkerBrief(w.store, &task, task.ID == w.store.RootID(), len(past) > 0, WakeClause(runCtx)) + brief := session.BeltWorkerBrief(w.store, &task, task.ID == w.store.RootID(), len(past) > 0, WakeClause(runCtx), w.workspace) // noAction counts replies in a row that carried no tool call. A reply that // did call a tool resets the run to one — its own trailing words are the // first of the new run — and the fourth in a row fails the task. diff --git a/internal/run/bashworker_test.go b/internal/run/bashworker_test.go index d09eb16e54..9df4887691 100644 --- a/internal/run/bashworker_test.go +++ b/internal/run/bashworker_test.go @@ -333,13 +333,17 @@ func TestBashWorkerRecordsItsStepsAndReportsThem(t *testing.T) { return toolReply(finishCommand("root", "the greeting is in place")), nil }, }} - worker := run.NewBashWorker(store, t.TempDir(), "test/model", seat) + workspace := t.TempDir() + worker := run.NewBashWorker(store, workspace, "test/model", seat) report, err := worker.Run(run.WithStepsPerTask(runContext(t), 9), *store.Task(store.RootID())) if err != nil { t.Fatalf("the worker's run failed: %v", err) } + if brief := seat.opening(t); !strings.Contains(brief, "ASSIGNED WORKING DIRECTORY\n\n"+workspace) { + t.Fatalf("worker did not receive its actual execution directory: %s", brief) + } if report.Steps != 2 { t.Fatalf("report steps = %d, want the command and the finish the script ran", report.Steps) } diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index d965c758aa..83e84ebbcf 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -166,7 +166,7 @@ func workerJournalName() string { // of what they did — every child's title, status and result — in place of the // interrupted-predecessor sentence, which is a fact about a different worker // and not about this one. The resume flag still rides the trajectory's steps. -func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, wake string) string { +func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, wake string, workspace ...string) string { role := planIsTask if root { role = planIsRoot @@ -176,6 +176,15 @@ func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, strings.Join(task.Deliverables, "\n"), task.Acceptance, "", AdmissionContext{}, taskOrigin{}, taskCopy{}) + // The runtime's directory is authority, while the work order may still + // quote the source checkout. Preserve those words and explain their scope + // before the worker sees them, as the checker already does for its probes. + if len(workspace) > 0 && strings.TrimSpace(workspace[0]) != "" { + assignment := "ASSIGNED WORKING DIRECTORY\n\n" + workspace[0] + + "\n\nRun project edits and checks here, using relative project paths. A repository path in the request may name the original checkout; it does not change this assigned directory. Do not cd back to that checkout to do the work. Unrelated read-only reference paths remain as written." + identity, work, _ := strings.Cut(doc, "\n\n") + doc = identity + "\n\n" + assignment + "\n\n" + work + } // THE ASK, FOR EVERY LEAF AND ONLY A LEAF. The section is absent on the // root's own document (its work order is the ask) and absent when the store // holds no root row to read it from, which is the emptiness law and not a diff --git a/internal/session/bashbelt_worker_test.go b/internal/session/bashbelt_worker_test.go index b955244e01..f55b95c061 100644 --- a/internal/session/bashbelt_worker_test.go +++ b/internal/session/bashbelt_worker_test.go @@ -202,3 +202,26 @@ func TestBashWorkerPageSaysToDeleteScratchBeforeDone(t *testing.T) { } } } + +func TestBeltWorkerBriefStatesAssignedDirectoryWithoutRewritingReferences(t *testing.T) { + ask := "In /source/project, edit result.txt; consult /reference/design.md." + store := askStore(t, ask) + for _, id := range []string{planRootID, "leaf"} { + for _, role := range []string{plandb.RoleWork, plandb.RoleCheck} { + task := store.Task(id) + task.Role = role + doc := BeltWorkerBrief(store, task, id == planRootID, false, "", "/assigned/copy") + for _, want := range []string{"ASSIGNED WORKING DIRECTORY\n\n/assigned/copy", ask, "Unrelated read-only reference paths remain as written"} { + if !strings.Contains(doc, want) { + t.Fatalf("%s/%s brief lost %q: %s", id, role, want, doc) + } + } + if strings.Index(doc, "ASSIGNED WORKING DIRECTORY") > strings.Index(doc, ask) { + t.Fatal("assignment appears after the quoted source path") + } + if !strings.HasPrefix(doc, planStoreID(id)+" is your task") { + t.Fatal("assignment displaced task ownership") + } + } + } +} From 1fe960214a938bdd7697f1c971a65381a6bd43ac Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 27 Sep 2026 11:04:31 -0400 Subject: [PATCH 8/8] test: wait for run teardown and isolate heartbeat phase checks --- internal/session/planrow_identity_test.go | 3 +-- internal/session/task_beat_test.go | 26 ++++++++++++++--------- internal/session/task_run_belt_test.go | 13 +++++++----- internal/session/task_run_settle_test.go | 13 ++++++++++-- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/internal/session/planrow_identity_test.go b/internal/session/planrow_identity_test.go index 835bf84ee3..e978fdc8ae 100644 --- a/internal/session/planrow_identity_test.go +++ b/internal/session/planrow_identity_test.go @@ -32,6 +32,7 @@ func TestARunsRowNamesTheStoreTaskThePlanReadAnswersUnder(t *testing.T) { t.Fatalf("start run: %v", err) } <-double.entered + defer endBeltRun(t, agent, double) // THE ROW SAYS WHICH TASK IT IS. Without this the place is back to matching // the pair on the title they share. @@ -55,12 +56,10 @@ func TestARunsRowNamesTheStoreTaskThePlanReadAnswersUnder(t *testing.T) { var titles []string for _, task := range rows { if task.ID == row.PlanTask { - close(double.release) return } titles = append(titles, task.ID+" "+task.Title) } - close(double.release) t.Fatalf("the run's row names store task %q and the plan read answers under %v: the place cannot "+ "join the pair by an id only one of them uses", row.PlanTask, titles) } diff --git a/internal/session/task_beat_test.go b/internal/session/task_beat_test.go index a13bbffae6..1c63e71487 100644 --- a/internal/session/task_beat_test.go +++ b/internal/session/task_beat_test.go @@ -184,14 +184,20 @@ func TestARunningNodesHeartbeatAdvancesAcrossItsCalls(t *testing.T) { // tells them apart, and it goes back to the work's own word when each of them // ends. func TestTheHeartbeatSaysWhichPhaseTheNodeIsIn(t *testing.T) { - repo := newGoModuleRepo(t) + // This test observes phase transitions, not the Go compiler. The checker + // must fail before the repair and succeed only after the test file exists. + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, "Makefile"), "test:\n\t@test -f greet_test.go\n\t@echo greeting-test-present\n") + mustGit(t, repo, "add", "Makefile") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "phase check fixture") + const check = "make test" t.Setenv("HOME", t.TempDir()) var agent *Agent working, checking, repairing := &beatWatch{}, &beatWatch{}, &beatWatch{} completer := &routedCompleter{ parent: []step{ - proposeCall("Add the greeting", "write greet.go and a test for it", "go test ./..."), + proposeCall("Add the greeting", "write greet.go and a test for it", check), finalText("handed off"), }, child: nodeLane(8, func(repairs, wrote bool) *ai.Response { @@ -214,14 +220,14 @@ func TestTheHeartbeatSaysWhichPhaseTheNodeIsIn(t *testing.T) { } }), audit: []step{ - checkingStep(&agent, checking, bashCall("call-verify", "go test ./...")), - checkingStep(&agent, checking, verdictFromEvidence("ok \t", - "VERIFIED — go test ./... ok", - "REFUTED — go test ./... reports no test files: the acceptance asks for a test and there is none")), - checkingStep(&agent, checking, bashCall("call-verify-again", "go test ./...")), - checkingStep(&agent, checking, verdictFromEvidence("ok \t", - "VERIFIED — go test ./... ok · the greeting test runs", - "REFUTED — go test ./... still reports no test files")), + checkingStep(&agent, checking, bashCall("call-verify", check)), + checkingStep(&agent, checking, verdictFromEvidence("greeting-test-present", + "VERIFIED — the greeting test file exists", + "REFUTED — the greeting test file is absent: the acceptance asks for a test and there is none")), + checkingStep(&agent, checking, bashCall("call-verify-again", check)), + checkingStep(&agent, checking, verdictFromEvidence("greeting-test-present", + "VERIFIED — the greeting test file exists", + "REFUTED — the greeting test file is still absent")), }, } agent = beatSession(t, repo, completer, func(config *Config) { config.TaskRepairRounds = 1 }) diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index 470c3c6f23..dcb17225df 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -164,12 +164,15 @@ func registerBeltRunEngine(t *testing.T, engine RunEngine) { // directory's removal, and lost it under load as "directory not empty". func endBeltRun(t *testing.T, agent *Agent, double *beltRunDouble) { t.Helper() + agent.beltMu.Lock() + over := agent.beltRun.over + agent.beltMu.Unlock() close(double.release) - beltRunWaitFor(t, "the run to end", func() bool { - agent.beltMu.Lock() - defer agent.beltMu.Unlock() - return agent.beltRun == nil - }) + select { + case <-over: + case <-time.After(10 * time.Second): + t.Fatal("run did not finish settling after its engine returned") + } } // beltRunStoreAt is a fresh handle on the run's store, adopted by path — the diff --git a/internal/session/task_run_settle_test.go b/internal/session/task_run_settle_test.go index c45b129b4d..903cbd9570 100644 --- a/internal/session/task_run_settle_test.go +++ b/internal/session/task_run_settle_test.go @@ -163,6 +163,17 @@ func TestClosingAfterTheProgramExitedSaysItHadEnded(t *testing.T) { t.Fatalf("StartDelegate: %v", err) } <-double.entered + agent.beltMu.Lock() + over := agent.beltRun.over + agent.beltMu.Unlock() + defer func() { + close(double.release) + select { + case <-over: + case <-time.After(10 * time.Second): + t.Fatal("run did not finish settling after its engine returned") + } + }() double.mu.Lock() store := double.spec.Store double.mu.Unlock() @@ -179,8 +190,6 @@ func TestClosingAfterTheProgramExitedSaysItHadEnded(t *testing.T) { 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) - <-double.finished } // AND THE SAME ON THE REOPEN ROAD: a store left open over a program that had