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..a71d0de7bb 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,105 @@ 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, 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.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) + } +} + +// 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) { + 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 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.