diff --git a/modules/claude-code/cmd/claude-code/session_start.go b/modules/claude-code/cmd/claude-code/session_start.go index 84326dd5..3685ef1f 100644 --- a/modules/claude-code/cmd/claude-code/session_start.go +++ b/modules/claude-code/cmd/claude-code/session_start.go @@ -1,15 +1,16 @@ package main -// The plugin's SessionStart hook (novox/hq ADR 0489 §1, issue 1744): `claude-code session-start `, -// run by the agent's own account on every start, resume, /clear and /compact. The tool runner never tells a -// session its own id (only a hook's stdin carries session_id), so a session cannot name itself to -// claude_code_wait until this hook has told it. It also lists the session's still-open waits, so a session -// that /clear-ed sees what it was watching before. +// The plugin's SessionStart hook (novox/hq ADR 0489 §1, ADR 0490 §2, issue 1744): `claude-code session-start +// `, run by the agent's own account on every start, resume, /clear and /compact. The tool runner +// never tells a session its own id (only a hook's stdin carries session_id), so a session cannot name itself +// to claude_code_wait until this hook has told it. It also lists this session id's own still-open waits (if +// any name is already bound to it) and, always, every name on this node that still holds an open wait, so a +// session with a brand new id (a /clear gives one) can see a name it may want to adopt. // -// It reads the session's mirrored waits file directly — the same local read the Stop hook makes -// (readWaitsFile, waits.go) — never calling back into this module: this hook runs on every start, and must -// not wait on the bus to do it. A missing or malformed file is not an error here: there is simply nothing -// open to report. +// It reads the session's mirrored waits file and the node-wide index directly — the same local reads the +// Stop hook and claude_code_wait make (readWaitsFile, readNodeIndex, waits.go) — never calling back into +// this module: this hook runs on every start, and must not wait on the bus to do it. A missing or malformed +// file is not an error here: there is simply nothing open to report. import ( "encoding/json" @@ -25,29 +26,35 @@ type SessionStartInput struct { } // SessionStartMessage is the line the hook prints: the session's id and how to give it to claude_code_wait, -// then each still-open wait from before (1744/AC1, AC2). Pure of IO beyond the one file read. +// then each still-open wait already bound to this session id (1744/AC1, AC2), then every name on this node +// that still holds an open wait, with how to adopt it (ADR 0490 §2). Pure of IO beyond the two file reads. func SessionStartMessage(sessionID, waitsDir string, now time.Time) string { var b strings.Builder fmt.Fprintf(&b, "This session's id is %s; give it as `session` to claude_code_wait.\n", sessionID) - f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)) - - if err != nil || len(f.Waits) == 0 { - return b.String() - } - b.WriteString("Waits still open from before this session's last start, resume, /clear or /compact:\n") - for _, w := range f.Waits { - age := now.Sub(w.At).Round(time.Second) - state := "no watcher yet" - if w.Watcher != "" { - state = "watched" - } - if w.PastBound(now) { - state += ", past its bound" - if w.Said { - state += ", said" + if f, err := readWaitsFile(waitsFileIn(waitsDir, sessionID)); err == nil && len(f.Waits) > 0 { + b.WriteString("Waits already bound to this session's own id:\n") + for _, w := range f.Waits { + age := now.Sub(w.At).Round(time.Second) + state := "no watcher yet" + if w.Watcher != "" { + state = "watched" } + if w.PastBound(now) { + state += ", past its bound" + if w.Said { + state += ", said" + } + } + fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state) + } + } + if idx, err := readNodeIndex(nodeIndexPathIn(waitsDir)); err == nil && len(idx.Names) > 0 { + b.WriteString("Open waits on this node, by name:\n") + for _, n := range idx.Names { + fmt.Fprintf(&b, " %s: %d open wait(s), last called %s. If this is you: claude_code_wait "+ + "{\"op\":\"adopt\",\"by\":%q,\"session\":%q}\n", n.Name, n.Open, n.LastCall.Round(time.Second).Format(time.RFC3339), + n.Name+"@"+idx.Node, sessionID) } - fmt.Fprintf(&b, " %s — %s (%s ago, %s)\n", w.ID, w.What, age, state) } return b.String() } diff --git a/modules/claude-code/cmd/claude-code/session_start_test.go b/modules/claude-code/cmd/claude-code/session_start_test.go index 45379776..a1082709 100644 --- a/modules/claude-code/cmd/claude-code/session_start_test.go +++ b/modules/claude-code/cmd/claude-code/session_start_test.go @@ -116,6 +116,31 @@ func TestSessionStartRejectsAnUnsafeSessionID(t *testing.T) { // 1744/AC4 is checked together with 1743/AC7 in terminal_only_test.go's // TestThePluginsOnlyHooksAreTheGuardTheSessionStartHookAndTheStopHook. +// ADR 0490 §2: every session start lists every name on this node with an open wait, from the node-wide +// index, with the exact adopt call to make if it is yours — read directly, never by calling back into this +// module. +func TestSessionStartListsEveryNameOnTheNodeWithOpenWaits(t *testing.T) { + waitsDir := t.TempDir() + lastCall := time.Date(2026, 10, 21, 9, 55, 0, 0, time.UTC) + idx := NodeIndex{Node: "g14", At: lastCall, Names: []NodeIndexEntry{ + {Name: "fix-1629", Open: 2, CurrentSession: "sess-old", LastCall: lastCall}, + }} + raw, err := indented(idx) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(waitsDir, "_index.json"), string(raw)) + + msg := SessionStartMessage("sess-brand-new", waitsDir, lastCall.Add(5*time.Minute)) + if !strings.Contains(msg, "fix-1629") || !strings.Contains(msg, "2 open wait") { + t.Fatalf("the name and its count are not listed:\n%s", msg) + } + want := `claude_code_wait {"op":"adopt","by":"fix-1629@g14","session":"sess-brand-new"}` + if !strings.Contains(msg, want) { + t.Fatalf("the exact adopt call is not given:\n%s\nwant it to contain:\n%s", msg, want) + } +} + func writeWaitsFixture(t *testing.T, path string, f WaitsFile) { t.Helper() raw, err := indented(f) diff --git a/modules/claude-code/cmd/claude-code/waits.go b/modules/claude-code/cmd/claude-code/waits.go index 8f0e24a1..f44711ad 100644 --- a/modules/claude-code/cmd/claude-code/waits.go +++ b/modules/claude-code/cmd/claude-code/waits.go @@ -1,18 +1,28 @@ package main -// A session's wait list (novox/hq ADR 0489): what a session still expects an answer to — an Approve, a -// delivery's end, anything it started and must act on when it ends (follow-what-you-start) — so the -// plugin's Stop hook can refuse to end its turn while one is open and nothing watches it. +// A session's wait list (novox/hq ADR 0489, as changed by ADR 0490): what a session still expects an answer +// to — an Approve, a delivery's end, anything it started and must act on when it ends +// (follow-what-you-start) — so the plugin's Stop hook can refuse to end its turn while one is open and +// nothing watches it. // -// Kept in the module's `waits` state under the session's id and this node (so the same session reads its -// waits again after a /clear, and after the module restarts), and mirrored whole, atomically, to a file the -// hooks read without calling back into this module: /.claude/nox-mesh/waits/.json. That -// path is under the agent's home (the manifest's `agent-home` resource), which this process — launched as -// the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent account of -// its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already carries such -// a write through the sudo helper the guard's binary also serves, exactly as grant.go's WriteCredentials -// does for the agent's own credentials file. Where the node has no agent account of its own, fsys is the -// plain filesystem and the write reaches the operator's own home directly. +// Kept in the module's `waits` state under the session's name and this node (ADR 0490 §1) — `by`, as +// claude_code_ask takes it, never the session id, which Claude Code hands a session a new one of at a +// /clear. Every call also gives `session`, the caller's current id; the module keeps the binding of a name +// to the session that last called for it, with that call's time, so the same name's waits are found again +// across a /clear once the new session has adopted it. +// +// The waits file a hook reads is kept per session id, not per name, and holds the waits of whichever name is +// currently bound to that id (empty for an id nothing is bound to): written whole, atomically, on every +// change, and rewritten for both ids when a binding moves (AdoptName). A second file, the node-wide index, +// lists every name on this node that still holds an open wait, so the SessionStart hook can tell a session +// with a brand new id what it might want to adopt, without calling back into this module. +// +// Every file lives under the agent's home (the manifest's `agent-home` resource), which this process — +// launched as the operator's account (novox/hq ADR 0193) — cannot itself write when the node names an agent +// account of its own (novox/hq ADR 0266): every write below goes through `fsys` (homefs.go), which already +// carries such a write through the sudo helper the guard's binary also serves, exactly as grant.go's +// WriteCredentials does for the agent's own credentials file. Where the node has no agent account of its +// own, fsys is the plain filesystem and the write reaches the operator's own home directly. import ( "crypto/rand" @@ -23,6 +33,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "time" @@ -38,7 +49,7 @@ const WaitsDir = "nox-mesh/waits" // WaitPrefix starts every wait's id. const WaitPrefix = "w-" -// MostWaits is how many open waits one session may hold at once (novox/hq ADR 0489, 1629/AC5). +// MostWaits is how many open waits one name may hold at once (novox/hq ADR 0489, 1629/AC5). const MostWaits = 50 // The bounds of a wait's bound, in minutes (1629/AC5). @@ -53,8 +64,8 @@ const mostWaitAttempts = 20 var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) // requireSessionID trims session and refuses it unless it is a plain id matching waitSessionID — every op -// holds to this, not only add (review round 1, item b), so a session id is never used to build a path from -// anything but a safe name (no "/", so it cannot name a file outside the waits directory). +// holds to this (review round 1, item b), so a session id is never used to build a path from anything but a +// safe name (no "/", so it cannot name a file outside the waits directory). func requireSessionID(session string) (string, error) { session = strings.TrimSpace(session) if !waitSessionID.MatchString(session) { @@ -69,7 +80,8 @@ type WaitRead struct { Arguments map[string]any `json:"arguments,omitempty"` } -// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). +// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). By is the name it was +// added under. type Wait struct { ID string `json:"id"` By string `json:"by"` @@ -87,27 +99,50 @@ func (w Wait) PastBound(now time.Time) bool { return now.Sub(w.At) > time.Duration(w.Bound)*time.Minute } -// SessionWaits is what the `waits` state keeps for one session: its open waits. -type SessionWaits struct { - Session string `json:"session"` - Node string `json:"node"` - Waits []Wait `json:"waits"` +// NamedWaits is what the `waits` state keeps for one name on this node (novox/hq ADR 0490 §1): its open +// waits, and the binding to whichever session currently answers to that name — empty until the first call +// names it, by add or by adopt. +type NamedWaits struct { + Name string `json:"name"` + Node string `json:"node"` + CurrentSession string `json:"currentSession,omitempty"` + LastCall time.Time `json:"lastCall,omitempty"` + Waits []Wait `json:"waits"` } -// WaitsFile is what the mirrored file holds, whole, on every change (1629/AC3): the session's open waits and -// the time they were written. +// WaitsFile is what one session id's mirrored file holds, whole, on every change (1629/AC3): the open waits +// of whichever name is currently bound to this session, and the time they were written. type WaitsFile struct { Session string `json:"session"` At time.Time `json:"at"` Waits []Wait `json:"waits"` } +// NodeIndexEntry is one name's open waits, as the node-wide index lists it for the SessionStart hook +// (novox/hq ADR 0490 §2). +type NodeIndexEntry struct { + Name string `json:"name"` + Open int `json:"open"` + CurrentSession string `json:"currentSession,omitempty"` + LastCall time.Time `json:"lastCall"` +} + +// NodeIndex is the node-wide index file's whole content (novox/hq ADR 0490 §2): every name on this node that +// still holds an open wait, written by the tool on every change a name's waits or binding takes, so the +// SessionStart hook can say "if this is you, adopt" without calling back into this module. +type NodeIndex struct { + Node string `json:"node"` + At time.Time `json:"at"` + Names []NodeIndexEntry `json:"names"` +} + // WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the -// revision read. +// revision read; Keys lists every name kept, for the node-wide index. type WaitStore interface { Get(key string) (json.RawMessage, uint64, bool, error) Create(key string, value any) error Update(key string, value any, revision uint64) error + Keys() ([]string, error) } // keptWaits is the `waits` state on the bus. @@ -128,6 +163,7 @@ func (k keptWaits) Update(key string, value any, revision uint64) error { _, err := k.s.Update(key, value, revision) return err } +func (k keptWaits) Keys() ([]string, error) { return k.s.Keys() } // Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch. type Waiting struct { @@ -137,9 +173,9 @@ type Waiting struct { var waiting *Waiting -// waitsKey is the state's key for one session's waits: its id and this node (novox/hq ADR 0489 §1), so two -// nodes never collide over the same session id. -func waitsKey(p Paths, session string) string { return p.Node + "/" + session } +// waitsKey is the state's key for one name's waits: its name and this node (novox/hq ADR 0490 §1), so two +// nodes never collide over the same name. +func waitsKey(p Paths, name string) string { return p.Node + "/" + name } // WaitsDirPath is where this node's sessions' waits are mirrored, under the agent's home: the directory the // plugin's SessionStart and Stop hooks (1744, 1743) are given on their command line. @@ -147,11 +183,15 @@ func WaitsDirPath(p Paths) string { return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir)) } -// waitsFilePath is where one session's waits are mirrored. +// waitsFilePath is where one session id's mirrored waits are. func waitsFilePath(p Paths, session string) string { return filepath.Join(WaitsDirPath(p), session+".json") } +// nodeIndexPath is the node-wide index's file, beside the per-session ones; its name can never collide with +// a session id, since waitSessionID never allows a leading "_". +func nodeIndexPath(p Paths) string { return filepath.Join(WaitsDirPath(p), "_index.json") } + func newWaitID() string { var b [8]byte _, _ = rand.Read(b[:]) @@ -169,27 +209,27 @@ func addressShaped(s string) bool { return dot > 0 && dot < len(s)-1 } -func readSessionWaits(store WaitStore, key string) (SessionWaits, uint64, bool, error) { +func readNamedWaits(store WaitStore, key string) (NamedWaits, uint64, bool, error) { raw, rev, found, err := store.Get(key) if err != nil || !found { - return SessionWaits{}, 0, found, err + return NamedWaits{}, 0, found, err } - var sw SessionWaits - _ = json.Unmarshal(raw, &sw) - return sw, rev, true, nil + var nw NamedWaits + _ = json.Unmarshal(raw, &nw) + return nw, rev, true, nil } -// updateWaits reads one session's waits, lets mutate change them, and writes the result back at the +// updateNamedWaits reads one name's waits, lets mutate change them, and writes the result back at the // revision read — created when there was none yet — retried when another write won the race. -func updateWaits(store WaitStore, key string, mutate func(SessionWaits) (SessionWaits, error)) (SessionWaits, error) { +func updateNamedWaits(store WaitStore, key string, mutate func(NamedWaits) (NamedWaits, error)) (NamedWaits, error) { for i := 0; i < mostWaitAttempts; i++ { - sw, rev, found, err := readSessionWaits(store, key) + nw, rev, found, err := readNamedWaits(store, key) if err != nil { - return SessionWaits{}, fmt.Errorf("the wait list could not be read: %w", err) + return NamedWaits{}, fmt.Errorf("the wait list could not be read: %w", err) } - next, err := mutate(sw) + next, err := mutate(nw) if err != nil { - return SessionWaits{}, err + return NamedWaits{}, err } if !found { err = store.Create(key, next) @@ -202,23 +242,22 @@ func updateWaits(store WaitStore, key string, mutate func(SessionWaits) (Session if errors.Is(err, stdio.ErrChanged) { continue } - return SessionWaits{}, fmt.Errorf("the wait list could not be written: %w", err) + return NamedWaits{}, fmt.Errorf("the wait list could not be written: %w", err) } - return SessionWaits{}, errors.New("the wait list kept changing elsewhere; try again") + return NamedWaits{}, errors.New("the wait list kept changing elsewhere; try again") } -// writeWaitsFile rewrites the session's mirrored file whole, atomically (temp file, then rename): it holds -// exactly the open waits and the time of this write (1629/AC3). -func writeWaitsFile(p Paths, session string, sw SessionWaits, now time.Time) error { - path := waitsFilePath(p, session) +// writeSessionFile rewrites one session id's mirrored file whole, atomically (temp file, then rename): it +// holds exactly the open waits given and the time of this write (1629/AC3). +func writeSessionFile(p Paths, sessionID string, waits []Wait, now time.Time) error { + path := waitsFilePath(p, sessionID) if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - waits := sw.Waits if waits == nil { waits = []Wait{} } - raw, err := indented(WaitsFile{Session: session, At: now.UTC(), Waits: waits}) + raw, err := indented(WaitsFile{Session: sessionID, At: now.UTC(), Waits: waits}) if err != nil { return err } @@ -229,12 +268,51 @@ func writeWaitsFile(p Paths, session string, sw SessionWaits, now time.Time) err return fsys.Rename(tmp, path) } -// sessionAndNode reads `by` as claude_code_ask does: who@node, where node is this one. -func sessionAndNode(p Paths, by string) (string, error) { +// writeNodeIndex rewrites the node-wide index whole, atomically, from every name's current state on this +// node (novox/hq ADR 0490 §2): a full scan of the `waits` state, bounded by how many names are ever used on +// one node at once. Best-effort for its caller: a name's own file is already correct by the time this runs. +func writeNodeIndex(p Paths, store WaitStore, now time.Time) error { + keys, err := store.Keys() + if err != nil { + return fmt.Errorf("the node-wide wait index could not be read: %w", err) + } + prefix := p.Node + "/" + idx := NodeIndex{Node: p.Node, At: now.UTC(), Names: []NodeIndexEntry{}} + for _, k := range keys { + name, ok := strings.CutPrefix(k, prefix) + if !ok { + continue + } + nw, _, found, err := readNamedWaits(store, k) + if err != nil || !found || len(nw.Waits) == 0 { + continue + } + idx.Names = append(idx.Names, NodeIndexEntry{Name: name, Open: len(nw.Waits), + CurrentSession: nw.CurrentSession, LastCall: nw.LastCall.UTC()}) + } + sort.Slice(idx.Names, func(i, j int) bool { return idx.Names[i].Name < idx.Names[j].Name }) + path := nodeIndexPath(p) + if err := fsys.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + raw, err := indented(idx) + if err != nil { + return err + } + tmp := path + ".mesh-tmp" + if err := fsys.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return fsys.Rename(tmp, path) +} + +// callerName reads `by` as claude_code_ask does: who@node, where node is this one — the name a wait list is +// kept under (novox/hq ADR 0490 §1), never the session id. +func callerName(p Paths, by string) (string, error) { by = strings.TrimSpace(by) if who, node, found := strings.Cut(by, "@"); found { if node != p.Node { - return "", fmt.Errorf("by names the node %q, and this is %s: add the wait on the node the session runs on", node, p.Node) + return "", fmt.Errorf("by names the node %q, and this is %s: call on the node the session runs on", node, p.Node) } return who, nil } @@ -244,6 +322,30 @@ func sessionAndNode(p Paths, by string) (string, error) { return by, nil } +// bindCaller checks the binding for name against session, refusing when another, more recently bound +// session holds it (novox/hq ADR 0490 §3) — naming that session and when it last called, and how to take the +// name back with adopt — and otherwise (re)binds it to session at now, so a name nothing has bound yet is +// claimed by the first session that calls for it, add included. +func bindCaller(nw *NamedWaits, name, node, by, session string, now time.Time) error { + if nw.CurrentSession != "" && nw.CurrentSession != session { + return fmt.Errorf("the name %q now answers to session %s (it last called at %s); adopt it again if this "+ + "is you: claude_code_wait {\"op\":\"adopt\",\"by\":%q,\"session\":%q}", name, nw.CurrentSession, + nw.LastCall.UTC().Format(time.RFC3339), by, session) + } + nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, node, session, now.UTC() + return nil +} + +// findWait is the index of id in waits, or -1. +func findWait(waits []Wait, id string) int { + for i, w := range waits { + if w.ID == id { + return i + } + } + return -1 +} + // WaitInput is what claude_code_wait's add takes. type WaitInput struct { Session string @@ -254,14 +356,14 @@ type WaitInput struct { Bound int } -// AddWait keeps one more wait for a session, refusing outside ADR 0489's bounds (1629/AC5), and rewrites the -// session's mirrored file (1629/AC3). +// AddWait keeps one more wait for a name, refusing outside ADR 0489's bounds (1629/AC5) or when another +// session has since taken the name over (ADR 0490 §3), and rewrites the caller's mirrored file (1629/AC3). func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) { session, err := requireSessionID(in.Session) if err != nil { return nil, err } - by, err := sessionAndNode(p, in.By) + name, err := callerName(p, in.By) if err != nil { return nil, err } @@ -276,43 +378,42 @@ func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string] if !addressShaped(address) { return nil, fmt.Errorf("read.address %q is not an address: the verb that reads where this wait stands, as mesh_call takes it", in.Read.Address) } - w := Wait{ID: newWaitID(), By: by, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments}, + w := Wait{ID: newWaitID(), By: name, What: what, Read: WaitRead{Address: address, Arguments: in.Read.Arguments}, Family: strings.TrimSpace(in.Family), Bound: in.Bound, At: now.UTC()} - key := waitsKey(p, session) - sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) { - if len(sw.Waits) >= MostWaits { - return sw, fmt.Errorf("%d waits already wait for session %s; at most %d open waits per session", len(sw.Waits), session, MostWaits) + key := waitsKey(p, name) + nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) { + if err := bindCaller(&nw, name, p.Node, in.By, session, now); err != nil { + return nw, err } - sw.Session, sw.Node = session, p.Node - sw.Waits = append(append([]Wait(nil), sw.Waits...), w) - return sw, nil + if len(nw.Waits) >= MostWaits { + return nw, fmt.Errorf("%d waits already wait for %s; at most %d open waits per name", len(nw.Waits), name, MostWaits) + } + nw.Waits = append(append([]Wait(nil), nw.Waits...), w) + return nw, nil }) if err != nil { return nil, err } - if err := writeWaitsFile(p, session, sw, now); err != nil { + if err := writeSessionFile(p, session, nw.Waits, now); err != nil { return nil, fmt.Errorf("the wait was kept, but its file could not be written: %w", err) } - say("wait %s added for session %s: %s", w.ID, session, what) - return map[string]any{"id": w.ID, "session": session, "open": len(sw.Waits)}, nil -} - -// findWait is the index of id in sw.Waits, or -1. -func findWait(sw SessionWaits, id string) int { - for i, w := range sw.Waits { - if w.ID == id { - return i - } + if err := writeNodeIndex(p, store, now); err != nil { + say("writing the node-wide wait index after %s added for %s: %v", w.ID, name, err) } - return -1 + say("wait %s added for %s (session %s): %s", w.ID, name, session, what) + return map[string]any{"id": w.ID, "name": name, "session": session, "open": len(nw.Waits)}, nil } // WatchedWait marks a wait as followed: by mesh_wait on its family, or a watcher started for it (1629/AC2). -func WatchedWait(p Paths, session, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) { +func WatchedWait(p Paths, session, by, id, watcher string, store WaitStore, now time.Time) (map[string]any, error) { session, err := requireSessionID(session) if err != nil { return nil, err } + name, err := callerName(p, by) + if err != nil { + return nil, err + } id, watcher = strings.TrimSpace(id), strings.TrimSpace(watcher) if id == "" { return nil, errors.New("id is required: the one add answered") @@ -320,97 +421,127 @@ func WatchedWait(p Paths, session, id, watcher string, store WaitStore, now time if watcher == "" { return nil, errors.New("watcher is required: what follows this wait (mesh_wait on its family, or a watcher agent)") } - key := waitsKey(p, session) - sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) { - i := findWait(sw, id) - if i < 0 { - return sw, fmt.Errorf("no open wait %s for session %s", id, session) + key := waitsKey(p, name) + nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) { + if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil { + return nw, err } - sw.Waits[i].Watcher = watcher - return sw, nil + i := findWait(nw.Waits, id) + if i < 0 { + return nw, fmt.Errorf("no open wait %s for %s", id, name) + } + nw.Waits[i].Watcher = watcher + return nw, nil }) if err != nil { return nil, err } - if err := writeWaitsFile(p, session, sw, now); err != nil { + if err := writeSessionFile(p, session, nw.Waits, now); err != nil { return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err) } + _ = writeNodeIndex(p, store, now) return map[string]any{"id": id, "watcher": watcher}, nil } // DoneWait removes a wait from the open list: it ended, with outcome (1629/AC2). -func DoneWait(p Paths, session, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) { +func DoneWait(p Paths, session, by, id, outcome string, store WaitStore, now time.Time) (map[string]any, error) { session, err := requireSessionID(session) if err != nil { return nil, err } + name, err := callerName(p, by) + if err != nil { + return nil, err + } id, outcome = strings.TrimSpace(id), strings.TrimSpace(outcome) if id == "" { return nil, errors.New("id is required: the one add answered") } - key := waitsKey(p, session) - sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) { - i := findWait(sw, id) - if i < 0 { - return sw, fmt.Errorf("no open wait %s for session %s", id, session) + key := waitsKey(p, name) + nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) { + if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil { + return nw, err } - sw.Waits = append(append([]Wait(nil), sw.Waits[:i]...), sw.Waits[i+1:]...) - return sw, nil + i := findWait(nw.Waits, id) + if i < 0 { + return nw, fmt.Errorf("no open wait %s for %s", id, name) + } + nw.Waits = append(append([]Wait(nil), nw.Waits[:i]...), nw.Waits[i+1:]...) + return nw, nil }) if err != nil { return nil, err } - if err := writeWaitsFile(p, session, sw, now); err != nil { + if err := writeSessionFile(p, session, nw.Waits, now); err != nil { return nil, fmt.Errorf("the wait was done, but its file could not be written: %w", err) } - say("wait %s for session %s done: %s", id, session, outcome) + _ = writeNodeIndex(p, store, now) + say("wait %s for %s (session %s) done: %s", id, name, session, outcome) return map[string]any{"id": id, "done": true, "outcome": outcome}, nil } // SaidWait marks a wait past its bound as told to the operator: refused while it is still within its bound // (1629/AC2). -func SaidWait(p Paths, session, id string, store WaitStore, now time.Time) (map[string]any, error) { +func SaidWait(p Paths, session, by, id string, store WaitStore, now time.Time) (map[string]any, error) { session, err := requireSessionID(session) if err != nil { return nil, err } + name, err := callerName(p, by) + if err != nil { + return nil, err + } id = strings.TrimSpace(id) if id == "" { return nil, errors.New("id is required: the one add answered") } - key := waitsKey(p, session) - sw, err := updateWaits(store, key, func(sw SessionWaits) (SessionWaits, error) { - i := findWait(sw, id) + key := waitsKey(p, name) + nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) { + if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil { + return nw, err + } + i := findWait(nw.Waits, id) if i < 0 { - return sw, fmt.Errorf("no open wait %s for session %s", id, session) + return nw, fmt.Errorf("no open wait %s for %s", id, name) } - if !sw.Waits[i].PastBound(now) { - return sw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id) + if !nw.Waits[i].PastBound(now) { + return nw, fmt.Errorf("wait %s is not past its bound yet: said is for telling the operator about one that is", id) } - sw.Waits[i].Said = true - return sw, nil + nw.Waits[i].Said = true + return nw, nil }) if err != nil { return nil, err } - if err := writeWaitsFile(p, session, sw, now); err != nil { + if err := writeSessionFile(p, session, nw.Waits, now); err != nil { return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err) } + _ = writeNodeIndex(p, store, now) return map[string]any{"id": id, "said": true}, nil } -// ListWaits answers a session's open waits, with their age and whether they are past their bound (1629/AC1). -func ListWaits(p Paths, session string, store WaitStore, now time.Time) (map[string]any, error) { +// ListWaits answers a name's open waits, with their age and whether they are past their bound (1629/AC1). +func ListWaits(p Paths, session, by string, store WaitStore, now time.Time) (map[string]any, error) { session, err := requireSessionID(session) if err != nil { return nil, err } - sw, _, _, err := readSessionWaits(store, waitsKey(p, session)) + name, err := callerName(p, by) if err != nil { - return nil, fmt.Errorf("the wait list could not be read: %w", err) + return nil, err + } + key := waitsKey(p, name) + nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) { + if err := bindCaller(&nw, name, p.Node, by, session, now); err != nil { + return nw, err + } + return nw, nil + }) + if err != nil { + return nil, err } out := []map[string]any{} - for _, w := range sw.Waits { + for _, w := range nw.Waits { entry := map[string]any{"id": w.ID, "what": w.What, "by": w.By, "address": w.Read.Address, "bound_minutes": w.Bound, "at": w.At.Format(time.RFC3339), "age_seconds": int(now.Sub(w.At).Seconds()), "past_bound": w.PastBound(now), "watcher": w.Watcher, "said": w.Said} @@ -422,13 +553,63 @@ func ListWaits(p Paths, session string, store WaitStore, now time.Time) (map[str } out = append(out, entry) } - return map[string]any{"session": session, "open": out}, nil + return map[string]any{"session": session, "name": name, "open": out}, nil +} + +// AdoptName binds name to session, always taking it over from whoever held it (novox/hq ADR 0490 §3): the +// mirrored files of both the session it is taken from and the one adopting it are rewritten at once, so a +// session that no longer owns a name never finds its old waits in its own file, and the new one does right +// away — no waiting on a bound, unlike the Stop and SessionStart hooks' own. +func AdoptName(p Paths, by, session string, store WaitStore, now time.Time) (map[string]any, error) { + session, err := requireSessionID(session) + if err != nil { + return nil, err + } + name, err := callerName(p, by) + if err != nil { + return nil, err + } + key := waitsKey(p, name) + var oldSession string + var oldLastCall time.Time + nw, err := updateNamedWaits(store, key, func(nw NamedWaits) (NamedWaits, error) { + oldSession, oldLastCall = nw.CurrentSession, nw.LastCall + nw.Name, nw.Node, nw.CurrentSession, nw.LastCall = name, p.Node, session, now.UTC() + return nw, nil + }) + if err != nil { + return nil, err + } + tookOver := oldSession != "" && oldSession != session + if tookOver { + if err := writeSessionFile(p, oldSession, nil, now); err != nil { + return nil, fmt.Errorf("%s was adopted, but the session it was taken from could not be told: %w", name, err) + } + } + if err := writeSessionFile(p, session, nw.Waits, now); err != nil { + return nil, fmt.Errorf("the name was adopted, but its file could not be written: %w", err) + } + if err := writeNodeIndex(p, store, now); err != nil { + say("writing the node-wide wait index after %s was adopted: %v", name, err) + } + answer := map[string]any{"name": name, "session": session, "open": len(nw.Waits)} + if tookOver { + answer["took-over-from"] = map[string]any{"session": oldSession, "last-called": oldLastCall.UTC().Format(time.RFC3339)} + say("%s adopted by session %s, taken over from %s (last called %s)", name, session, oldSession, + oldLastCall.UTC().Format(time.RFC3339)) + } else { + say("%s adopted by session %s", name, session) + } + return answer, nil } // waitsFileIn is where one session's mirrored waits are, given a hook's own waits-dir argument: the hooks // have no launch environment, only their command line, so they never recompute this from Paths. func waitsFileIn(waitsDir, session string) string { return filepath.Join(waitsDir, session+".json") } +// nodeIndexPathIn is the node-wide index file, given a hook's own waits-dir argument. +func nodeIndexPathIn(waitsDir string) string { return filepath.Join(waitsDir, "_index.json") } + // readWaitsFile reads one session's mirrored file, as the hooks do: a plain local read, as the session's own // account (never through fsys or the module's state), and never calling back into this module. func readWaitsFile(path string) (WaitsFile, error) { @@ -443,27 +624,46 @@ func readWaitsFile(path string) (WaitsFile, error) { return f, nil } -// waitTools is claude_code_wait: add, watched, done, said and list, over one session's wait list (novox/hq -// ADR 0489). +// readNodeIndex reads the node-wide index, as the SessionStart hook does: a plain local read, never through +// fsys or the module's state, and never calling back into this module. +func readNodeIndex(path string) (NodeIndex, error) { + raw, err := os.ReadFile(path) + if err != nil { + return NodeIndex{}, err + } + var idx NodeIndex + if err := json.Unmarshal(raw, &idx); err != nil { + return NodeIndex{}, err + } + return idx, nil +} + +// waitTools is claude_code_wait: add, watched, done, said, list and adopt, over a name's wait list (novox/hq +// ADR 0489, ADR 0490). func waitTools(p Paths) []stdio.Tool { notLaunched := errors.New("this module was not launched with the bus; no wait can be kept") return []stdio.Tool{ {Name: "claude_code_wait", - Description: "Your session's wait list (novox/hq ADR 0489): what you still expect an answer to — an Approve, a " + - "delivery's end, anything you started and must act on when it ends. The plugin's Stop hook refuses to end " + - "your turn while a wait here is open and nothing follows it. op is add, watched, done, said or list; session " + - "is always required (the id the SessionStart hook named, or printed again after a /clear). add takes by " + - "(your session's name, as who@node or who), what (what you are waiting for), read (the verb that answers " + - "where it stands: {address, arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes " + - "(1 to 1440) and answers an id; at most 50 waits are open per session. watched takes id and watcher (what " + - "follows it: mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes " + - "it from the open list. said takes id and marks a wait past its bound as told to the operator — refused " + - "while it is still within its bound. list answers the open waits, with their age and whether each is past " + - "its bound.", + Description: "Your wait list, kept under your session's name (novox/hq ADR 0489, ADR 0490): what you " + + "still expect an answer to — an Approve, a delivery's end, anything you started and must act on when " + + "it ends. The plugin's Stop hook refuses to end your turn while a wait here is open and nothing " + + "follows it. op is add, watched, done, said, list or adopt; session (your current session id, named " + + "by the SessionStart hook) and by (your session's name, as who@node or who) are always required. A " + + "name is bound to whichever session last called for it; if your own id changed (a /clear) you must " + + "adopt your name again before it answers to you — the SessionStart hook lists every name on this " + + "node with an open wait and how. adopt takes the name over at once, however recently another " + + "session called for it; that session is told so, naming yours, the next time it calls. add takes " + + "what (what you are waiting for), read (the verb that answers where it stands: {address, " + + "arguments}), family (optional, the event mesh_wait can wait on) and bound_minutes (1 to 1440), and " + + "answers an id; at most 50 waits are open per name. watched takes id and watcher (what follows it: " + + "mesh_wait on its family, or a watcher agent you started). done takes id and outcome, and removes it " + + "from the open list. said takes id and marks a wait past its bound as told to the operator — " + + "refused while it is still within its bound. list answers the open waits, with their age and " + + "whether each is past its bound.", Input: map[string]any{ - "op": str("add, watched, done, said or list"), - "session": str("required: the session's id"), - "by": str("add: your session's name, as who@node or who"), + "op": str("add, watched, done, said, list or adopt"), + "session": str("required: your session's current id"), + "by": str("required: your session's name, as who@node or who"), "what": str("add: what you are waiting for, in plain words"), "read": map[string]any{"type": "object", "description": "add: the verb that reads where this wait stands", "properties": map[string]any{ @@ -480,7 +680,7 @@ func waitTools(p Paths) []stdio.Tool { if waiting == nil { return nil, notLaunched } - session := strArg(a, "session") + session, by := strArg(a, "session"), strArg(a, "by") now := time.Now() if waiting.Now != nil { now = waiting.Now() @@ -498,18 +698,20 @@ func waitTools(p Paths) []stdio.Tool { if n, ok := a["bound_minutes"].(float64); ok { bound = int(n) } - return AddWait(p, WaitInput{Session: session, By: strArg(a, "by"), What: strArg(a, "what"), Read: read, + return AddWait(p, WaitInput{Session: session, By: by, What: strArg(a, "what"), Read: read, Family: strArg(a, "family"), Bound: bound}, waiting.Store, now) case "watched": - return WatchedWait(p, session, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now) + return WatchedWait(p, session, by, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now) case "done": - return DoneWait(p, session, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now) + return DoneWait(p, session, by, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now) case "said": - return SaidWait(p, session, strArg(a, "id"), waiting.Store, now) + return SaidWait(p, session, by, strArg(a, "id"), waiting.Store, now) case "list": - return ListWaits(p, session, waiting.Store, now) + return ListWaits(p, session, by, waiting.Store, now) + case "adopt": + return AdoptName(p, by, session, waiting.Store, now) default: - return nil, fmt.Errorf("op is add, watched, done, said or list, not %q", op) + return nil, fmt.Errorf("op is add, watched, done, said, list or adopt, not %q", op) } }}, } diff --git a/modules/claude-code/cmd/claude-code/waits_test.go b/modules/claude-code/cmd/claude-code/waits_test.go index 195c71dd..cfcafe26 100644 --- a/modules/claude-code/cmd/claude-code/waits_test.go +++ b/modules/claude-code/cmd/claude-code/waits_test.go @@ -1,6 +1,6 @@ package main -// A session's wait list (novox/hq ADR 0489, issue 1629). +// A session's wait list (novox/hq ADR 0489, ADR 0490, issues 1629, 1744). import ( "encoding/json" @@ -53,11 +53,20 @@ func (s *memWaits) Update(key string, value any, revision uint64) error { } return s.put(key, value) } +func (s *memWaits) Keys() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, 0, len(s.m)) + for k := range s.m { + out = append(out, k) + } + return out, nil +} -// addApproveWait adds the wait fixture the operator's Approve is kept under. -func addApproveWait(t *testing.T, p Paths, store WaitStore, session string, at time.Time) string { +// addApproveWait adds the wait fixture the operator's Approve is kept under, for name on session. +func addApproveWait(t *testing.T, p Paths, store WaitStore, name, session string, at time.Time) string { t.Helper() - out, err := AddWait(p, WaitInput{Session: session, By: "fix-1629@" + p.Node, What: "the operator's Approve of pull request 42", + out, err := AddWait(p, WaitInput{Session: session, By: name + "@" + p.Node, What: "the operator's Approve of pull request 42", Read: WaitRead{Address: "mesh-delivery.recheck", Arguments: map[string]any{"pr": 42}}, Family: "mesh-delivery.decided", Bound: 10}, store, at) if err != nil { @@ -70,11 +79,11 @@ func addApproveWait(t *testing.T, p Paths, store WaitStore, session string, at t func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) { p, _ := node(t, "g14") store := newMemWaits() - id := addApproveWait(t, p, store, "sess-1", t0) + id := addApproveWait(t, p, store, "fix-1629", "sess-1", t0) if !strings.HasPrefix(id, WaitPrefix) { t.Fatalf("id %q does not start with %q", id, WaitPrefix) } - out, err := ListWaits(p, "sess-1", store, t0.Add(time.Minute)) + out, err := ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(time.Minute)) if err != nil { t.Fatal(err) } @@ -82,7 +91,7 @@ func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) { if len(open) != 1 || open[0]["id"] != id || open[0]["past_bound"] != false || open[0]["age_seconds"] != 60 { t.Fatalf("%+v", open) } - out, err = ListWaits(p, "sess-1", store, t0.Add(11*time.Minute)) + out, err = ListWaits(p, "sess-1", "fix-1629@g14", store, t0.Add(11*time.Minute)) if err != nil { t.Fatal(err) } @@ -97,35 +106,36 @@ func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) { func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) { p, _ := node(t, "g14") store := newMemWaits() - id := addApproveWait(t, p, store, "sess-2", t0) + by := "fix-1629@g14" + id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0) - if _, err := SaidWait(p, "sess-2", id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") { + if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "not past its bound") { t.Fatalf("said before the bound: %v", err) } - if _, err := WatchedWait(p, "sess-2", id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil { + if _, err := WatchedWait(p, "sess-2", by, id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil { t.Fatal(err) } - out, _ := ListWaits(p, "sess-2", store, t0.Add(2*time.Minute)) + out, _ := ListWaits(p, "sess-2", by, store, t0.Add(2*time.Minute)) open := out["open"].([]map[string]any) if open[0]["watcher"] != "mesh_wait on mesh-delivery.decided" { t.Fatalf("watcher not kept: %+v", open[0]) } - if _, err := SaidWait(p, "sess-2", id, store, t0.Add(11*time.Minute)); err != nil { + if _, err := SaidWait(p, "sess-2", by, id, store, t0.Add(11*time.Minute)); err != nil { t.Fatalf("said past the bound: %v", err) } - out, _ = ListWaits(p, "sess-2", store, t0.Add(12*time.Minute)) + out, _ = ListWaits(p, "sess-2", by, store, t0.Add(12*time.Minute)) open = out["open"].([]map[string]any) if open[0]["said"] != true { t.Fatalf("said not kept: %+v", open[0]) } - if _, err := DoneWait(p, "sess-2", id, "approved", store, t0.Add(13*time.Minute)); err != nil { + if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(13*time.Minute)); err != nil { t.Fatal(err) } - out, _ = ListWaits(p, "sess-2", store, t0.Add(14*time.Minute)) + out, _ = ListWaits(p, "sess-2", by, store, t0.Add(14*time.Minute)) if len(out["open"].([]map[string]any)) != 0 { t.Fatalf("done did not remove it from the open list: %v", out) } - if _, err := DoneWait(p, "sess-2", id, "approved", store, t0.Add(15*time.Minute)); err == nil { + if _, err := DoneWait(p, "sess-2", by, id, "approved", store, t0.Add(15*time.Minute)); err == nil { t.Fatal("done on an already-done wait was not refused") } } @@ -135,7 +145,8 @@ func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) { func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) { p, _ := node(t, "g14") store := newMemWaits() - id1 := addApproveWait(t, p, store, "sess-3", t0) + by := "fix-1629@g14" + id1 := addApproveWait(t, p, store, "fix-1629", "sess-3", t0) f, err := readWaitsFile(waitsFilePath(p, "sess-3")) if err != nil { t.Fatal(err) @@ -147,7 +158,7 @@ func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) { if _, err := os.Stat(waitsFilePath(p, "sess-3") + ".mesh-tmp"); !os.IsNotExist(err) { t.Fatalf("a temp file was left beside it: %v", err) } - id2, err := AddWait(p, WaitInput{Session: "sess-3", By: "fix-1629@" + p.Node, What: "a second wait", + id2, err := AddWait(p, WaitInput{Session: "sess-3", By: by, What: "a second wait", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute)) if err != nil { t.Fatal(err) @@ -156,7 +167,7 @@ func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) { if err != nil || len(f.Waits) != 2 || !f.At.Equal(t0.Add(time.Minute).UTC()) { t.Fatalf("%+v %v", f, err) } - if _, err := DoneWait(p, "sess-3", id1, "approved", store, t0.Add(2*time.Minute)); err != nil { + if _, err := DoneWait(p, "sess-3", by, id1, "approved", store, t0.Add(2*time.Minute)); err != nil { t.Fatal(err) } f, err = readWaitsFile(waitsFilePath(p, "sess-3")) @@ -165,36 +176,37 @@ func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) { } } -// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same session -// after a /clear (the same session id lists them); a different session id lists none of them. -func TestWaitsSurviveARestartAndAClearButNotCrossSessions(t *testing.T) { +// 1629/AC4: the waits are read again after the module "restarts" (the state is kept) and for the same name +// after a /clear that keeps calling with the same session id; a different name lists none of them. +func TestWaitsSurviveARestartAndAreNeverSeenByAnotherName(t *testing.T) { p, _ := node(t, "g14") store := newMemWaits() - addApproveWait(t, p, store, "sess-4", t0) + by := "fix-1629@g14" + addApproveWait(t, p, store, "fix-1629", "sess-4", t0) // A restart: a fresh Waiting over the same store reads the same waits. restarted := &Waiting{Store: store, Now: func() time.Time { return t0.Add(time.Minute) }} _ = restarted - out, err := ListWaits(p, "sess-4", store, t0.Add(time.Minute)) + out, err := ListWaits(p, "sess-4", by, store, t0.Add(time.Minute)) if err != nil || len(out["open"].([]map[string]any)) != 1 { t.Fatalf("not read again after a restart: %v %v", out, err) } - // A /clear: the same session id still lists it. - out, err = ListWaits(p, "sess-4", store, t0.Add(2*time.Minute)) + // The same session id, called again: still lists it. + out, err = ListWaits(p, "sess-4", by, store, t0.Add(2*time.Minute)) if err != nil || len(out["open"].([]map[string]any)) != 1 { - t.Fatalf("not read again for the same session: %v %v", out, err) + t.Fatalf("not read again for the same name: %v %v", out, err) } - // A different session id: none of them. - out, err = ListWaits(p, "sess-other", store, t0.Add(2*time.Minute)) + // A different name, same node: none of them. + out, err = ListWaits(p, "sess-other", "fix-other@g14", store, t0.Add(2*time.Minute)) if err != nil || len(out["open"].([]map[string]any)) != 0 { - t.Fatalf("another session id saw them: %v %v", out, err) + t.Fatalf("another name saw them: %v %v", out, err) } } // 1629/AC5: add refuses an empty what, a bound under 1 or over 1440 minutes, a read address that is not an -// address, and more than 50 open waits per session, naming the argument. +// address, and more than 50 open waits per name, naming the argument. func TestAddRefusesOutsideItsBounds(t *testing.T) { p, _ := node(t, "g14") base := func() WaitInput { @@ -220,7 +232,7 @@ func TestAddRefusesOutsideItsBounds(t *testing.T) { } } - // More than 50 open waits per session. + // More than 50 open waits per name. store := newMemWaits() for i := 0; i < MostWaits; i++ { if _, err := AddWait(p, base(), store, t0); err != nil { @@ -236,18 +248,19 @@ func TestAddRefusesOutsideItsBounds(t *testing.T) { // same waitSessionID add already holds to), naming "session" — not silently treating it as just-another-key. func TestListWatchedDoneAndSaidRefuseABadSessionID(t *testing.T) { p, _ := node(t, "g14") + by := "fix-1629@g14" for _, bad := range []string{"", "a/b", "../x"} { store := newMemWaits() - if _, err := ListWaits(p, bad, store, t0); err == nil || !strings.Contains(err.Error(), "session") { + if _, err := ListWaits(p, bad, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") { t.Errorf("list %q: %v", bad, err) } - if _, err := WatchedWait(p, bad, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") { + if _, err := WatchedWait(p, bad, by, "w-1", "watcher", store, t0); err == nil || !strings.Contains(err.Error(), "session") { t.Errorf("watched %q: %v", bad, err) } - if _, err := DoneWait(p, bad, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") { + if _, err := DoneWait(p, bad, by, "w-1", "outcome", store, t0); err == nil || !strings.Contains(err.Error(), "session") { t.Errorf("done %q: %v", bad, err) } - if _, err := SaidWait(p, bad, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") { + if _, err := SaidWait(p, bad, by, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") { t.Errorf("said %q: %v", bad, err) } } @@ -275,13 +288,172 @@ func TestTheWaitToolIsServedThroughTheModulesDispatch(t *testing.T) { t.Fatalf("no id answered: %s", text) } - text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6"}) + text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "list", "session": "sess-6", "by": "fix-1629@g14"}) if isErr || !strings.Contains(text, id) { t.Fatalf("list: %v %s", isErr, text) } - text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "id": id, "outcome": "approved"}) + text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "done", "session": "sess-6", "by": "fix-1629@g14", + "id": id, "outcome": "approved"}) if isErr { t.Fatalf("done: %s", text) } } + +// ---- ADR 0490: a name's waits survive a session's id changing, by adopting ----------------------------- + +// ADR 0490 §1: a name nothing has bound yet is claimed by the first session that calls for it (add), without +// a separate adopt first. +func TestAFirstAddBindsAnUnboundNameAtOnce(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + id := addApproveWait(t, p, store, "fix-1629", "sess-a", t0) + out, err := ListWaits(p, "sess-a", "fix-1629@g14", store, t0) + if err != nil || len(out["open"].([]map[string]any)) != 1 || out["open"].([]map[string]any)[0]["id"] != id { + t.Fatalf("%v %v", out, err) + } +} + +// ADR 0490 §1/§3: once a name is bound to a session, a different, non-adopted session calling for the same +// name (any op) is refused, naming the session that holds it and when it last called. +func TestADifferentSessionIsRefusedWithoutAdopting(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + by := "fix-1629@g14" + addApproveWait(t, p, store, "fix-1629", "sess-old", t0) + + if _, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute)); err == nil || + !strings.Contains(err.Error(), "sess-old") || !strings.Contains(err.Error(), "adopt") { + t.Fatalf("a different session was not refused, or did not name the one it holds to: %v", err) + } + if _, err := AddWait(p, WaitInput{Session: "sess-new", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"}, + Bound: 10}, store, t0.Add(time.Minute)); err == nil || !strings.Contains(err.Error(), "sess-old") { + t.Fatalf("add from a different session was not refused: %v", err) + } +} + +// ADR 0490 §2/§3: adopt right after a simulated /clear binds the name to the new session id at once; the +// mirrored file of the old session id is emptied and the new one holds the waits, with no delay. +func TestAdoptRightAfterAClearBindsAtOnce(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + by := "fix-1629@g14" + id := addApproveWait(t, p, store, "fix-1629", "sess-old", t0) + + out, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if out["session"] != "sess-new" || out["open"] != 1 { + t.Fatalf("%v", out) + } + took, _ := out["took-over-from"].(map[string]any) + if took == nil || took["session"] != "sess-old" { + t.Fatalf("the answer did not name the earlier id: %v", out) + } + + oldFile, err := readWaitsFile(waitsFilePath(p, "sess-old")) + if err != nil || len(oldFile.Waits) != 0 { + t.Fatalf("the old session's file still holds waits: %+v %v", oldFile, err) + } + newFile, err := readWaitsFile(waitsFilePath(p, "sess-new")) + if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != id { + t.Fatalf("the new session's file does not hold the waits: %+v %v", newFile, err) + } + + // The new session can act on the name at once: no 10-minute rule. + if _, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute)); err != nil { + t.Fatalf("the new session could not act right after adopting: %v", err) + } +} + +// ADR 0490 §3: adopt of a name bound to another id always takes it over, however recently that id called — +// no bound protects it. +func TestAdoptAlwaysTakesOverEvenJustCalled(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + by := "fix-1629@g14" + addApproveWait(t, p, store, "fix-1629", "sess-fresh", t0) + // sess-fresh calls again right before being adopted away from. + if _, err := ListWaits(p, "sess-fresh", by, store, t0.Add(10*time.Second)); err != nil { + t.Fatal(err) + } + out, err := AdoptName(p, by, "sess-taker", store, t0.Add(11*time.Second)) + if err != nil { + t.Fatalf("adopt was refused even though it always takes over: %v", err) + } + if out["session"] != "sess-taker" { + t.Fatalf("%v", out) + } +} + +// ADR 0490 §3: the earlier id's next claude_code_wait call (any op) answers that its name now answers to +// another session, naming it. +func TestTheEarlierIDIsToldOnItsNextCall(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + by := "fix-1629@g14" + addApproveWait(t, p, store, "fix-1629", "sess-old", t0) + if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil { + t.Fatal(err) + } + _, err := ListWaits(p, "sess-old", by, store, t0.Add(2*time.Minute)) + if err == nil || !strings.Contains(err.Error(), "sess-new") || !strings.Contains(err.Error(), "adopt") { + t.Fatalf("the earlier id's next call did not name the new session: %v", err) + } +} + +// ADR 0490 §2: the node-wide index lists every name on this node with an open wait, for the SessionStart +// hook to read directly. +func TestTheNodeWideIndexListsEveryNameWithOpenWaits(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + addApproveWait(t, p, store, "fix-1629", "sess-x", t0) + addApproveWait(t, p, store, "fix-1744", "sess-y", t0) + if _, err := DoneWait(p, "sess-x", "fix-1629@g14", wantsOnlyWait(t, store, p, "fix-1629", "sess-x"), "done", + store, t0.Add(time.Minute)); err != nil { + t.Fatal(err) + } + + idx, err := readNodeIndex(nodeIndexPath(p)) + if err != nil { + t.Fatal(err) + } + if idx.Node != "g14" || len(idx.Names) != 1 || idx.Names[0].Name != "fix-1744" || idx.Names[0].Open != 1 || + idx.Names[0].CurrentSession != "sess-y" { + t.Fatalf("%+v", idx) + } +} + +// wantsOnlyWait is the id of the one wait a fixture added, read back through list. +func wantsOnlyWait(t *testing.T, store WaitStore, p Paths, name, session string) string { + t.Helper() + out, err := ListWaits(p, session, name+"@"+p.Node, store, t0) + if err != nil { + t.Fatal(err) + } + open := out["open"].([]map[string]any) + if len(open) != 1 { + t.Fatalf("%v", open) + } + return open[0]["id"].(string) +} + +// claude_code_wait op=adopt, served through the module's own dispatch (1629/AC6, ADR 0490). +func TestAdoptIsServedThroughTheModulesDispatch(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }} + t.Cleanup(func() { waiting = nil }) + s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{}) + + text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1629@g14", + "what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10}) + if isErr { + t.Fatalf("add: %s", text) + } + text, isErr = s.call(t, "claude_code_wait", map[string]any{"op": "adopt", "session": "sess-new", "by": "fix-1629@g14"}) + if isErr || !strings.Contains(text, "sess-old") { + t.Fatalf("adopt: %v %s", isErr, text) + } +}