Skip to content
Closed
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
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 ~.

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.

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.
11 changes: 10 additions & 1 deletion internal/e2e/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions internal/manual/chat/how-tasks-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -3332,3 +3332,27 @@ 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.

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
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 "<id>" 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.
38 changes: 38 additions & 0 deletions internal/plandb/done_terminal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package plandb

import (
"reflect"
"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)
}

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")
}
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)
}
}
5 changes: 4 additions & 1 deletion internal/plandb/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -609,7 +613,6 @@ 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 {
// 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
Expand Down
2 changes: 1 addition & 1 deletion internal/run/bashworker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion internal/run/bashworker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
11 changes: 10 additions & 1 deletion internal/session/bashbelt_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions internal/session/bashbelt_worker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
}
3 changes: 1 addition & 2 deletions internal/session/planrow_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
16 changes: 7 additions & 9 deletions internal/session/prompts/bashworker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '<name pattern>' | xargs -P 4 grep -l <text>
rg --files -g '<name pattern>' <project> # bounded discovery
git grep -n "theSymbol" # one search instead of three
```

Expand All @@ -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
Expand Down
26 changes: 16 additions & 10 deletions internal/session/task_beat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 })
Expand Down
6 changes: 5 additions & 1 deletion internal/session/task_run_belt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 49 additions & 5 deletions internal/session/task_run_belt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1504,3 +1507,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)
}
})
}
}
Loading
Loading