From 5d62d0d61b12c3c236c4fe5ed7f991111f1949d7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 17 Sep 2026 03:03:09 -0400 Subject: [PATCH 1/3] codeaf: a run's own note is offered outside the tree it is judged in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leafOutputHint invited a job root to write its overflow document at a workspace-relative name (exec.SuggestPathFor's task-2-x1-toolchain-checks.md shape), so a run working in a person's directory left that note untracked at the root of their working tree. The delivery gate holds the run's record — the artifact registry, joined by jobArtifacts, plus the delivered tree — against the request, read the note as part of what was handed over, and refused the delivery; the run then ended incomplete over complete work. There is no separate mechanical clean-tree check to exempt, so the fix is the writer's: where the workspace belongs to the person, the root's file invitation points at the run's scratch beside its recorders and job logs, and the message keeps carrying the deliverable. --- cmd/codeaf/chat.go | 17 ++++++++ cmd/codeaf/outputhint_test.go | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/cmd/codeaf/chat.go b/cmd/codeaf/chat.go index ec201ac6e2..151ec64c87 100644 --- a/cmd/codeaf/chat.go +++ b/cmd/codeaf/chat.go @@ -2567,6 +2567,13 @@ func withOpenFindings(graph *store.Store, node store.Node, brief string) string // scratch instead, which for an errand working in someone's project is not // their directory at all. // +// A root working in the person's own directory gets that same scratch. Its +// result still reaches the person in its final message, which the delivery law +// makes the deliverable; the file is a second copy, and a second copy filed in +// the tree they are working in is not theirs — it is an untracked note their +// own tools and their own eyes then have to explain. See outputClause, which +// offers this address for the overflow a message cannot carry. +// // The address is keyed on the node's own id, which is the only identity here // that is unique per node. It used to be keyed on the creation sequence and the // title, and neither is: one splice stamps its whole subtree with one sequence @@ -3262,6 +3269,16 @@ func outcomeRecords(outcome *exec.Outcome) []string { func leafOutputHint(node store.Node, title string, space *exec.Workspace) (hint string, intermediate bool) { suggested := exec.SuggestPathFor(node.ID, title) if node.Parent == store.RootID { + if space != nil && space.PersonalRoot() { + _, shown, err := space.ScratchPath(suggested) + if err != nil { + return "", false + } + if space.DirectoryAt(shown) { + return "", false + } + return shown, false + } if space != nil && space.DirectoryAt(suggested) { return "", false } diff --git a/cmd/codeaf/outputhint_test.go b/cmd/codeaf/outputhint_test.go index c83c3fd05b..0fdd2297f9 100644 --- a/cmd/codeaf/outputhint_test.go +++ b/cmd/codeaf/outputhint_test.go @@ -1,6 +1,7 @@ package main import ( + "os" "path/filepath" "strings" "testing" @@ -53,6 +54,79 @@ func TestOnlyTheDeliverableOwnerIsInvitedIntoTheWorkspace(t *testing.T) { } } +// A run working in the person's own directory must not file its own notes +// there. The root leaf keeps its voice — its final message is still what the +// person reads — but the file it is offered goes to the run's scratch, beside +// its recorders and its job logs, so a note the ask never named never turns up +// as an untracked file in the tree the delivery is judged in. +func TestARootLeafInAPersonsDirectoryPutsItsNotesInScratch(t *testing.T) { + workspaceRoot := t.TempDir() + scratch := filepath.Join(t.TempDir(), "scratch") + space, err := exec.NewWorkspace(workspaceRoot) + if err != nil { + t.Fatal(err) + } + space = space.WithScratch(scratch).OwnedByPerson() + + owner := store.Node{ID: "task-2-x1", Parent: store.RootID, CreatedSeq: 7, Title: "toolchain checks"} + hint, intermediate := leafOutputHint(owner, owner.Title, space) + if intermediate { + t.Fatal("the job root was treated as a handoff") + } + if !filepath.IsAbs(hint) || !strings.HasPrefix(hint, scratch+string(filepath.Separator)) { + t.Fatalf("the root leaf's note was pointed at %q, want an absolute path under the run's scratch %q", hint, scratch) + } + + // The invitation, followed: the file lands under scratch, and neither the + // tree the person named nor the record the delivery gate reads holds it. + // The leaf's own write makes the directory first, which is what the write + // tool does for any offered path. + if err := os.MkdirAll(filepath.Dir(hint), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hint, []byte("checks ran green"), 0o600); err != nil { + t.Fatal(err) + } + space.WatchTree(owner.ID) + space.Record(owner.ID, hint) + space.RecordChanges(owner.ID) + if got := space.Artifacts(owner.ID); len(got) != 0 { + t.Fatalf("a note written at the run's own hint was recorded as the job's work: %v", got) + } + left, err := os.ReadDir(workspaceRoot) + if err != nil { + t.Fatal(err) + } + if len(left) != 0 { + t.Fatalf("the working tree the person named was left with: %v", left) + } +} + +// A directory already sitting at the offered name still withdraws the +// invitation, now measured at the scratch spelling a personal root is handed. +func TestADirectoryAtTheRootsScratchPathWithdrawsTheInvitation(t *testing.T) { + space, err := exec.NewWorkspace(t.TempDir()) + if err != nil { + t.Fatal(err) + } + space = space.WithScratch(filepath.Join(t.TempDir(), "scratch")).OwnedByPerson() + owner := store.Node{ID: "job", Parent: store.RootID, CreatedSeq: 7, Title: "pr 482 code review"} + first, _ := leafOutputHint(owner, owner.Title, space) + if first == "" { + t.Fatal("the owner was offered nothing before the directory existed") + } + if err := os.MkdirAll(first, 0o700); err != nil { + t.Fatal(err) + } + hint, intermediate := leafOutputHint(owner, owner.Title, space) + if hint != "" { + t.Fatalf("a directory at the offered path still earned an invitation: %q", hint) + } + if intermediate { + t.Fatal("the withdrawn owner lost its deliverable shape") + } +} + // A chat window's workspace is the run's own directory, so scratch and // workspace are the same place and there is nowhere else to point. The // distinction that survives is the one that matters: an intermediate leaf is From e6778ac20f3d5ef25d41345943c185cc16ff6738 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 17 Sep 2026 03:42:25 -0400 Subject: [PATCH 2/3] codeaf: a person's own file still joins the record the gate reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestARootLeafInAPersonsDirectoryPutsItsNotesInScratch baselined the tree after the note was already written, so its landing read could never have observed anything: the record stayed empty because of the ordering, not because of the address. The baseline now happens first, in the executor's own order, and a hint that ever points into the tree again fails on the observation, not only on the spelling. And the other half of the law is pinned down: a file that turns up in the person's tree while the leaf runs — even at a name wearing the run's own task--.md shape — is still observed and still lands in the record the delivery gate is held to. Only the run's own note moved; nothing else about what the tree says was loosened. Co-Authored-By: codeaf --- cmd/codeaf/outputhint_test.go | 36 ++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/outputhint_test.go b/cmd/codeaf/outputhint_test.go index 0fdd2297f9..a71d0de7bb 100644 --- a/cmd/codeaf/outputhint_test.go +++ b/cmd/codeaf/outputhint_test.go @@ -77,17 +77,18 @@ func TestARootLeafInAPersonsDirectoryPutsItsNotesInScratch(t *testing.T) { t.Fatalf("the root leaf's note was pointed at %q, want an absolute path under the run's scratch %q", hint, scratch) } - // The invitation, followed: the file lands under scratch, and neither the - // tree the person named nor the record the delivery gate reads holds it. - // The leaf's own write makes the directory first, which is what the write - // tool does for any offered path. + // The invitation, followed, in the executor's own order: the tree is + // baselined before the worker moves, and the landing read files whatever + // changed — so a hint that ever points into the tree again fails here on + // the observation, not only on the address. The leaf's own write makes the + // directory first, which is what the write tool does for any offered path. + space.WatchTree(owner.ID) if err := os.MkdirAll(filepath.Dir(hint), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(hint, []byte("checks ran green"), 0o600); err != nil { t.Fatal(err) } - space.WatchTree(owner.ID) space.Record(owner.ID, hint) space.RecordChanges(owner.ID) if got := space.Artifacts(owner.ID); len(got) != 0 { @@ -102,6 +103,31 @@ func TestARootLeafInAPersonsDirectoryPutsItsNotesInScratch(t *testing.T) { } } +// Only the run's own note moved. A file that turns up in the person's tree +// while the leaf runs — at a name that happens to wear the run's own naming +// shape — is still the tree's answer to what changed, and the record the +// delivery gate reads still holds it: the two are told apart by where the run +// was invited to write, never by the spelling of a name. +func TestAPersonsOwnUntrackedFileStillJoinsTheRecord(t *testing.T) { + workspaceRoot := t.TempDir() + space, err := exec.NewWorkspace(workspaceRoot) + if err != nil { + t.Fatal(err) + } + space = space.WithScratch(filepath.Join(t.TempDir(), "scratch")).OwnedByPerson() + + owner := store.Node{ID: "task-2-x1", Parent: store.RootID, CreatedSeq: 7, Title: "toolchain checks"} + space.WatchTree(owner.ID) + if err := os.WriteFile(filepath.Join(workspaceRoot, "task-9-y1-shelf-readings.md"), []byte("running notes"), 0o600); err != nil { + t.Fatal(err) + } + space.RecordChanges(owner.ID) + got := space.Artifacts(owner.ID) + if len(got) != 1 || got[0] != "task-9-y1-shelf-readings.md" { + t.Fatalf("a person's untracked file fell out of the record the gate reads: %v", got) + } +} + // A directory already sitting at the offered name still withdraws the // invitation, now measured at the scratch spelling a personal root is handed. func TestADirectoryAtTheRootsScratchPathWithdrawsTheInvitation(t *testing.T) { From 3189c4091019974ea5ed1fb15032c9a82472a082 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 17 Sep 2026 04:06:46 -0400 Subject: [PATCH 3/3] docs: the change note for the root's overflow file moving out of the tree --- .../unreleased/1096-do-scratch-note-gate.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/changes/unreleased/1096-do-scratch-note-gate.md diff --git a/docs/changes/unreleased/1096-do-scratch-note-gate.md b/docs/changes/unreleased/1096-do-scratch-note-gate.md new file mode 100644 index 0000000000..fe27a31df4 --- /dev/null +++ b/docs/changes/unreleased/1096-do-scratch-note-gate.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: a job root working in the person's directory is offered its overflow file outside that tree +pr: 1096 +surface: [engine, resident] +invalidates: + - "`leafOutputHint` offered a job root the workspace-relative `exec.SuggestPathFor` address while pointing every intermediate leaf at the run's scratch. Under `codeaf do`, whose workspace is `OwnedByPerson`, a root that took the offer left an untracked `task--.md` at the root of the person's tree; `Workspace.RecordChanges` observed it, `jobArtifacts` joined it into the record the delivery gate is held to, and the gate refused a delivery whose work was complete and committed, ending the run incomplete. A personal root now resolves that address through `Workspace.ScratchPath`, the same place an intermediate leaf writes, and the directory-already-there withdrawal is measured at the scratch spelling." + - "Nothing is exempted by filename. `Workspace.record` still drops only paths outside the root, so a file that turns up in the person's tree while a leaf runs joins the record whatever it is named, including a name wearing the run's own `task--.md` shape." +--- + +A job root's deliverable is its final message; the file was only ever the second +copy for overflow a message cannot carry, and a second copy filed in the tree +the delivery is judged in is an untracked file the person's own tools then have +to explain.