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..d44a548477 --- /dev/null +++ b/docs/changes/unreleased/1562-plandb-done-terminal-and-manager-ground.md @@ -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. 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 diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 3a50c1e6b0..223c206d45 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -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 "" 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 new file mode 100644 index 0000000000..952b2fe014 --- /dev/null +++ b/internal/plandb/done_terminal_test.go @@ -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) + } +} diff --git a/internal/plandb/store.go b/internal/plandb/store.go index b54b2167b9..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,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 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") + } + } + } +} 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/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_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.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..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 @@ -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) + } + }) + } +} 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 diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 65b26c50fa..2159e51db3 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); spec.parent == 0 && 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..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) { @@ -775,6 +810,28 @@ 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 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{