feat(continuity): wire §6.3 shared-docs staleness notice
MarkdownChanges was deleted as dead code, but the underlying spec requirement wasn't abandoned — rebuilt it independently. Adds continuity.ConventionsHash for AGENTS.md/CLAUDE.md/VOCAB.md, tracks a per-session snapshot on herdr.Session, and adds Coordinator.checkConventions (run every Monitor tick) which compares each active session's snapshot against its project's base repo and pushes an in-pane notice via a new herdr.ConventionsNotifier capability when they drift. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -708,14 +708,32 @@ cooperation):
|
|||||||
confirmed by reading `cmd/orchestra/main.go`); an earlier draft of this
|
confirmed by reading `cmd/orchestra/main.go`); an earlier draft of this
|
||||||
prompt invented that endpoint and was corrected before landing, which is
|
prompt invented that endpoint and was corrected before landing, which is
|
||||||
exactly the class of bug this audit exists to catch.
|
exactly the class of bug this audit exists to catch.
|
||||||
3. **Item 6 — `MarkdownChanges` deleted, not wired.** Confirmed zero
|
3. **Item 6 — `MarkdownChanges` deleted 2026-07-27, then §6.3 rewired from
|
||||||
callers anywhere (including its own tests — there were none, despite
|
scratch 2026-07-27 (later same day).** The original `MarkdownChanges`
|
||||||
being listed as "believed accurate" in a prior progress.md snapshot).
|
function had zero callers and zero tests (confirmed by grep before
|
||||||
Wiring it for real needs a design for what "adjacent task" means and
|
deleting), so it was removed rather than half-wired, per this doc's own
|
||||||
where the notice surfaces (brief? a new event type?), which is a real
|
"delete and record the deviation" option. A real implementation of §6.3
|
||||||
feature, not a wiring fix — AUDIT.md explicitly allows "delete it and
|
("on update, the orchestra injects a notice to agents whose current task
|
||||||
record the deviation" as the alternative to half-implementing that. Taking
|
is adjacent") was built separately, decoupled from the deleted function:
|
||||||
that option rather than bolting on an undesigned notification path.
|
`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
|
**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,
|
"which harness" (Release was always harness-agnostic) — it was that *nothing,
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ func TaskFileHash(root string) (string, error) {
|
|||||||
return hex.EncodeToString(sum[:]), nil
|
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 {
|
type Dirty struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
SHA256 string `json:"sha256"`
|
SHA256 string `json:"sha256"`
|
||||||
|
|||||||
@@ -135,6 +135,22 @@ type HandoffRequester interface {
|
|||||||
func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error {
|
func (a CLIAdapter) RequestHandoff(ctx context.Context, s Session) error {
|
||||||
return a.Client.Prompt(ctx, s.PaneID, handoffPrompt, time.Minute)
|
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
|
// 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
|
// 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
|
// HEAD, uploads it to CAS, and only then releases herdr's claim on the pane
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ type Session struct {
|
|||||||
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
|
// its §6.1 handoff (HandoffFile) — avoids re-sending the same prompt
|
||||||
// every tick while Release keeps waiting for the file to appear.
|
// every tick while Release keeps waiting for the file to appear.
|
||||||
HandoffRequested bool `json:"handoff_requested,omitempty"`
|
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 {
|
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ func (c *Coordinator) Monitor(ctx context.Context, hard float64, interval time.D
|
|||||||
case <-t.C:
|
case <-t.C:
|
||||||
c.refreshSessionHealth(ctx)
|
c.refreshSessionHealth(ctx)
|
||||||
c.cleanupCompleted(ctx)
|
c.cleanupCompleted(ctx)
|
||||||
|
c.checkConventions(ctx)
|
||||||
expired, err := c.expire(ctx)
|
expired, err := c.expire(ctx)
|
||||||
c.setMonitorHealth(err, len(expired))
|
c.setMonitorHealth(err, len(expired))
|
||||||
if err != nil {
|
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) {
|
func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
|
||||||
// pane.exited is the low-latency path; lease expiry below remains the
|
// pane.exited is the low-latency path; lease expiry below remains the
|
||||||
// authoritative backstop when herdr misses an exit notification.
|
// 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.HerdrID = p.HarnessID
|
||||||
s.TaskFileSHA = taskFileSHA
|
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()
|
c.mu.Lock()
|
||||||
if c.sessions == nil {
|
if c.sessions == nil {
|
||||||
c.sessions = map[string]herdr.Session{}
|
c.sessions = map[string]herdr.Session{}
|
||||||
|
|||||||
@@ -476,3 +476,76 @@ func TestRotationRequestsHandoffBeforeReleasing(t *testing.T) {
|
|||||||
t.Fatal("rotate never called Release once the handoff file appeared")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
@@ -137,6 +137,24 @@ Fixed so far:
|
|||||||
half-wired, per AUDIT.md's explicit "delete and record the deviation"
|
half-wired, per AUDIT.md's explicit "delete and record the deviation"
|
||||||
option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`,
|
option. New tests: `TestReleaseScratchCommitsDirtyFilesBeforeUpload`,
|
||||||
`TestReleaseRefusesOnStaleDirtyFile` (internal/herdr/adapter_test.go).
|
`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
|
**Was still open:** Phase 4 item 2 — nothing drove *any* harness to write
|
||||||
`.orchestra-handoff.json`, since Release only validated a file whose
|
`.orchestra-handoff.json`, since Release only validated a file whose
|
||||||
existence was never solicited. **Closed 2026-07-27:** `rotate()` now checks
|
existence was never solicited. **Closed 2026-07-27:** `rotate()` now checks
|
||||||
|
|||||||
Reference in New Issue
Block a user