Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/codeaf/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
100 changes: 100 additions & 0 deletions cmd/codeaf/outputhint_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1096-do-scratch-note-gate.md
Original file line number Diff line number Diff line change
@@ -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-<n>-<slug>.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-<n>-<slug>.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.
Loading