diff --git a/AUDIT.md b/AUDIT.md index 95ee310..a6d5719 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -708,14 +708,32 @@ cooperation): confirmed by reading `cmd/orchestra/main.go`); an earlier draft of this prompt invented that endpoint and was corrected before landing, which is exactly the class of bug this audit exists to catch. -3. **Item 6 — `MarkdownChanges` deleted, not wired.** Confirmed zero - callers anywhere (including its own tests — there were none, despite - being listed as "believed accurate" in a prior progress.md snapshot). - Wiring it for real needs a design for what "adjacent task" means and - where the notice surfaces (brief? a new event type?), which is a real - feature, not a wiring fix — AUDIT.md explicitly allows "delete it and - record the deviation" as the alternative to half-implementing that. Taking - that option rather than bolting on an undesigned notification path. +3. **Item 6 — `MarkdownChanges` deleted 2026-07-27, then §6.3 rewired from + scratch 2026-07-27 (later same day).** The original `MarkdownChanges` + function had zero callers and zero tests (confirmed by grep before + deleting), so it was removed rather than half-wired, per this doc's own + "delete and record the deviation" option. A real implementation of §6.3 + ("on update, the orchestra injects a notice to agents whose current task + is adjacent") was built separately, decoupled from the deleted function: + `continuity.ConventionsHash(root)` hashes whichever of + `AGENTS.md`/`CLAUDE.md`/`VOCAB.md` exist at a path; `herdr.Session` gained + `ConventionsHash`, snapshotted from the fresh worktree at + `Coordinator.Start`; a new `Coordinator.checkConventions`, run every + `Monitor` tick, recomputes the hash of the *project's base repo* (via + `WorktreeSpec.Spec`, i.e. "adjacent" = same project) for every leased + session and compares it against that session's stored snapshot — a + mismatch means the shared docs were updated upstream since this session + started. On mismatch it calls a new optional `herdr.ConventionsNotifier` + capability (`CLIAdapter.NotifyConventionsChanged`, an `agent.prompt` telling + the agent to re-read the docs) and updates the stored hash so the notice + fires once per drift, not every tick. Deliberately does not touch the + brief or add a new event type — the notice is a direct in-pane nudge, not + a projection, matching the spec's phrasing ("injects a notice to agents"), + so there was no event-schema question to resolve first. Covered by + `TestConventionsDriftNotifiesActiveSession` + (internal/orchestrator/rotation_test.go): asserts zero notifications while + the base repo's docs are unchanged, then a notification once they diverge. + `go build`/`go vet`/`go test ./...` all pass. **Phase 4 item 2 — closed 2026-07-27.** The remaining gap after B5 was not "which harness" (Release was always harness-agnostic) — it was that *nothing, diff --git a/internal/continuity/continuity.go b/internal/continuity/continuity.go index d995796..ec2cd74 100644 --- a/internal/continuity/continuity.go +++ b/internal/continuity/continuity.go @@ -50,6 +50,31 @@ func TaskFileHash(root string) (string, error) { return hex.EncodeToString(sum[:]), nil } +// ConventionsFiles are the §6.3 shared docs ("Shared *.md ... all agents +// contribute") whose staleness the orchestra is responsible for tracking — +// never the agent, which only sees its own cached copy. +var ConventionsFiles = []string{"AGENTS.md", "CLAUDE.md", "VOCAB.md"} + +// ConventionsHash hashes the concatenation of whichever ConventionsFiles +// exist at root, in that fixed order, so the result changes iff any of their +// contents change (a file appearing/disappearing also changes it, since a +// length-prefixed marker precedes each file's bytes). A repo with none of +// these files hashes to a stable, comparable empty-set value rather than +// erroring — §6.3 is optional infrastructure, not every project uses it. +func ConventionsHash(root string) (string, error) { + h := sha256.New() + for _, name := range ConventionsFiles { + b, err := os.ReadFile(filepath.Join(root, name)) + if err != nil { + fmt.Fprintf(h, "%s:0\n", name) + continue + } + fmt.Fprintf(h, "%s:%d\n", name, len(b)) + h.Write(b) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + type Dirty struct { Path string `json:"path"` SHA256 string `json:"sha256"` diff --git a/internal/herdr/adapter.go b/internal/herdr/adapter.go index ce0941b..795e70b 100644 --- a/internal/herdr/adapter.go +++ b/internal/herdr/adapter.go @@ -135,6 +135,22 @@ type HandoffRequester interface { func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error { return a.Client.Prompt(ctx, s.PaneID, handoffPrompt, time.Minute) } + +// conventionsPrompt is §6.3's "orchestra injects a notice to agents whose +// current task is adjacent" — staleness is tracked here, not by trusting the +// agent's cached view of the shared docs. +const conventionsPrompt = `Notice from Orchestra: the shared project conventions (AGENTS.md / CLAUDE.md / VOCAB.md) have been updated since you started this task. Re-read them now before continuing, in case something you're relying on has changed.` + +// ConventionsNotifier is the optional capability rotate()'s convention-drift +// check uses; adapters without a live pane (tests, non-interactive harnesses) +// can omit it. +type ConventionsNotifier interface { + NotifyConventionsChanged(context.Context, Session) error +} + +func (a CLIAdapter) NotifyConventionsChanged(ctx context.Context, s Session) error { + return a.Client.Prompt(ctx, s.PaneID, conventionsPrompt, time.Minute) +} // Release reads the §6.1 handoff the agent wrote to HandoffFile at the // worktree root, validates its schema and anchor against the worktree's real // HEAD, uploads it to CAS, and only then releases herdr's claim on the pane diff --git a/internal/herdr/herdr.go b/internal/herdr/herdr.go index 3a7bc29..d63f012 100644 --- a/internal/herdr/herdr.go +++ b/internal/herdr/herdr.go @@ -157,6 +157,12 @@ type Session struct { // its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt // every tick while Release keeps waiting for the file to appear. HandoffRequested bool `json:"handoff_requested,omitempty"` + // ConventionsHash is continuity.ConventionsHash of the project's shared + // *.md docs (AGENTS.md/CLAUDE.md/VOCAB.md) at the time this session was + // last notified of (or started with) their content — §6.3's staleness + // tracking lives at the orchestra layer, never trusted from the agent's + // cached view. + ConventionsHash string `json:"conventions_hash,omitempty"` } func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error { diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 2342a40..b61bd11 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -382,6 +382,7 @@ func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.D case <-t.C: c.refreshSessionHealth(ctx) c.cleanupCompleted(ctx) + c.checkConventions(ctx) expired, err := c.expire(ctx) c.setMonitorHealth(err, len(expired)) if err != nil { @@ -420,6 +421,61 @@ func (c *Coordinator) cleanupCompleted(ctx context.Context) { } } +// checkConventions is §6.3: "on update, the orchestra injects a notice to +// agents whose current task is adjacent" — adjacency here is "same project's +// base repo," and staleness is tracked by comparing each session's own +// last-known continuity.ConventionsHash against the base repo's current one, +// never by trusting the agent to notice on its own. +func (c *Coordinator) checkConventions(ctx context.Context) { + spec, ok := c.Worktrees.(WorktreeSpec) + if !ok { + return + } + c.loadSessions() + c.mu.Lock() + sessions := make(map[string]herdr.Session, len(c.sessions)) + for id, s := range c.sessions { + sessions[id] = s + } + c.mu.Unlock() + changed := false + for taskID, session := range sessions { + t, ok := c.Store.Task(taskID) + if !ok || t.State != domain.StateLeased { + continue + } + repo, _, valid := spec.Spec(t) + if !valid { + continue + } + hash, err := continuity.ConventionsHash(repo) + if err != nil || hash == session.ConventionsHash { + continue + } + a, err := c.adapterFor(taskID, session) + if err != nil { + continue + } + notifier, ok := a.(herdr.ConventionsNotifier) + if !ok { + continue + } + if err := notifier.NotifyConventionsChanged(ctx, session); err != nil { + continue + } + session.ConventionsHash = hash + c.mu.Lock() + c.sessions[taskID] = session + c.mu.Unlock() + changed = true + } + if changed { + c.mu.Lock() + _ = c.saveSessionsLocked() + c.mu.Unlock() + } +} + func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) { // pane.exited is the low-latency path; lease expiry below remains the // authoritative backstop when herdr misses an exit notification. @@ -610,6 +666,11 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error { } s.HerdrID = p.HarnessID s.TaskFileSHA = taskFileSHA + // Best-effort, same caveat as taskFileSHA above: only meaningful for a + // worktree this process can read locally. Snapshots the shared-docs + // state this session starts trusting; checkConventions notices drift + // from here, not from whatever the agent's own cached view is (§6.3). + s.ConventionsHash, _ = continuity.ConventionsHash(w) c.mu.Lock() if c.sessions == nil { c.sessions = map[string]herdr.Session{} diff --git a/internal/orchestrator/rotation_test.go b/internal/orchestrator/rotation_test.go index 0fac2e5..2eee19b 100644 --- a/internal/orchestrator/rotation_test.go +++ b/internal/orchestrator/rotation_test.go @@ -476,3 +476,76 @@ func TestRotationRequestsHandoffBeforeReleasing(t *testing.T) { t.Fatal("rotate never called Release once the handoff file appeared") } } + +type specWorktrees struct{ wtPath, repoPath string } + +func (w specWorktrees) Create(context.Context, domain.Task) (string, error) { return w.wtPath, nil } +func (w specWorktrees) Spec(domain.Task) (string, string, bool) { return w.repoPath, "", true } + +type conventionsAdapter struct { + fakeAdapter + notifications int +} + +func (a *conventionsAdapter) NotifyConventionsChanged(context.Context, herdr.Session) error { + a.notifications++ + return nil +} + +// TestConventionsDriftNotifiesActiveSession guards §6.3's wiring: "on +// update, the orchestra injects a notice to agents whose current task is +// adjacent" — never left to the agent's own cached view. A session must not +// be notified while the base repo's shared docs match what it started with, +// and must be notified once they diverge. +func TestConventionsDriftNotifiesActiveSession(t *testing.T) { + repo := t.TempDir() + worktree := t.TempDir() + if err := os.WriteFile(repo+"/AGENTS.md", []byte("v1"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(worktree+"/AGENTS.md", []byte("v1"), 0644); err != nil { + t.Fatal(err) + } + + s, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: "t1", Surface: string(authz.System), Payload: mustJSON(map[string]any{ + "source": "jsonl", "external_id": "1", "project": "p", + })}); err != nil { + t.Fatal(err) + } + task := s.Tasks()[0] + a := &conventionsAdapter{fakeAdapter: fakeAdapter{occupancy: 0}} + c := &orchestrator.Coordinator{Store: s, Worktrees: specWorktrees{wtPath: worktree, repoPath: repo}, Adapters: adapters{a}, StatePath: t.TempDir() + "/sessions.json"} + + leaseEvt, err := s.Lease(task.ID, "h1", time.Minute) + if err != nil { + t.Fatal(err) + } + if err := c.Start(context.Background(), leaseEvt); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go c.Monitor(ctx, .8, time.Millisecond) + + time.Sleep(50 * time.Millisecond) + if a.notifications != 0 { + t.Fatalf("notified with no actual drift: notifications=%d", a.notifications) + } + + if err := os.WriteFile(repo+"/AGENTS.md", []byte("v2"), 0644); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) && a.notifications == 0 { + time.Sleep(time.Millisecond) + } + if a.notifications == 0 { + t.Fatal("session was never notified of the conventions-doc update") + } +} diff --git a/progress.md b/progress.md index 4cc303a..3778e75 100644 --- a/progress.md +++ b/progress.md @@ -137,6 +137,24 @@ Fixed so far: half-wired, per AUDIT.md's explicit "delete and record the deviation" option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`, `TestReleaseRefusesOnStaleDirtyFile` (internal/herdr/adapter_test.go). + **§6.3 rewired for real, 2026-07-27 (later same day):** the deleted + `MarkdownChanges` above was zero-caller dead code, but the underlying spec + requirement ("on update, the orchestra injects a notice to agents whose + current task is adjacent") wasn't abandoned — rebuilt independently. + `continuity.ConventionsHash(root)` hashes whichever of + `AGENTS.md`/`CLAUDE.md`/`VOCAB.md` exist at a path; `herdr.Session` gained + `ConventionsHash`, snapshotted from the fresh worktree at + `Coordinator.Start`; a new `Coordinator.checkConventions`, run every + `Monitor` tick, recomputes the hash of the project's *base repo* (via + `WorktreeSpec.Spec` — "adjacent" = same project) for every leased session + and compares it against that session's stored snapshot. A mismatch calls a + new optional `herdr.ConventionsNotifier` capability + (`CLIAdapter.NotifyConventionsChanged`, an in-pane `agent.prompt` telling + the agent to re-read the docs) and updates the stored hash so the notice + fires once per drift, not every tick. Covered by + `TestConventionsDriftNotifiesActiveSession` + (internal/orchestrator/rotation_test.go): asserts no notification while + the base repo is unchanged, then one once it diverges. **Was still open:** Phase 4 item 2 — nothing drove *any* harness to write `.orchestra-handoff.json`, since Release only validated a file whose existence was never solicited. **Closed 2026-07-27:** `rotate()` now checks