mesh/merge-gate head 0f159a166200 merged into trunk 7a7a4034852a — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 0f159a166200 merged into trunk 7a7a4034852a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The Stop hook treated a missing waits file exactly like one that could not be read: a notice on every stop of every session with no waits — the normal state, not an exceptional one. DecideStop now treats os.ErrNotExist as the ordinary case of no open waits, silently; the notice is still said for a file that exists but cannot be read or does not parse. ADR 0490 §1 keeps waits under a name, but the file a hook reads is kept per session id: writing it from only the name an op just acted on meant a second name bound to the same session id had its waits silently drop out of enforcement the moment the first name was touched, and adopting one name away emptied the id's file outright even when another name was still bound to it. Every write now rewrites a session's file from the union of every name currently bound to it (sessionWaitsUnion), adopt's rewrite of the superseded id included. Also: each atomic write gets its own temp file name, so two writes to the same path can never interleave on one; adopt clears the refusal counter of both the superseded and the adopting session, so a fresh three-strikes count starts under the new binding; and list is read-only again — it no longer claims an unbound name or refuses a session that is not (yet) the one bound to it, and instead says who currently holds the name.
578 lines
22 KiB
Go
578 lines
22 KiB
Go
package main
|
|
|
|
// A session's wait list (novox/hq ADR 0489, ADR 0490, issues 1629, 1744).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// memWaits is the `waits` state: values with revisions, create-only and compare-and-set, as the bus keeps it.
|
|
type memWaits struct {
|
|
mu sync.Mutex
|
|
m map[string]json.RawMessage
|
|
rev map[string]uint64
|
|
n uint64
|
|
}
|
|
|
|
func newMemWaits() *memWaits {
|
|
return &memWaits{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
|
|
}
|
|
|
|
func (s *memWaits) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
raw, ok := s.m[key]
|
|
return raw, s.rev[key], ok, nil
|
|
}
|
|
func (s *memWaits) put(key string, value any) error {
|
|
raw, err := json.Marshal(value)
|
|
s.n++
|
|
s.m[key], s.rev[key] = raw, s.n
|
|
return err
|
|
}
|
|
func (s *memWaits) Create(key string, value any) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.m[key]; ok {
|
|
return stdio.ErrChanged
|
|
}
|
|
return s.put(key, value)
|
|
}
|
|
func (s *memWaits) Update(key string, value any, revision uint64) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.rev[key] != revision {
|
|
return stdio.ErrChanged
|
|
}
|
|
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, 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: 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 {
|
|
t.Fatalf("add: %v", err)
|
|
}
|
|
return out["id"].(string)
|
|
}
|
|
|
|
// 1629/AC1: add answers an id; list answers the open waits with their age and whether each is past its bound.
|
|
func TestAddAnswersAnIDAndListShowsItsAgeAndPastBound(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
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", "fix-1629@g14", store, t0.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
open := out["open"].([]map[string]any)
|
|
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", "fix-1629@g14", store, t0.Add(11*time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
open = out["open"].([]map[string]any)
|
|
if open[0]["past_bound"] != true {
|
|
t.Fatalf("past its 10-minute bound after 11 minutes: %+v", open[0])
|
|
}
|
|
}
|
|
|
|
// 1629/AC2: watched, done and said change the wait; said is refused for a wait not past its bound; done removes
|
|
// it from the open list.
|
|
func TestWatchedDoneAndSaidChangeTheWait(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
by := "fix-1629@g14"
|
|
id := addApproveWait(t, p, store, "fix-1629", "sess-2", t0)
|
|
|
|
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", by, id, "mesh_wait on mesh-delivery.decided", store, t0.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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", by, id, store, t0.Add(11*time.Minute)); err != nil {
|
|
t.Fatalf("said past the bound: %v", err)
|
|
}
|
|
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", by, id, "approved", store, t0.Add(13*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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", by, id, "approved", store, t0.Add(15*time.Minute)); err == nil {
|
|
t.Fatal("done on an already-done wait was not refused")
|
|
}
|
|
}
|
|
|
|
// 1629/AC3: every change rewrites the session's waits file whole, atomically; it holds exactly the open waits
|
|
// and the write time.
|
|
func TestEveryChangeRewritesTheWaitsFileWhole(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
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)
|
|
}
|
|
if f.Session != "sess-3" || len(f.Waits) != 1 || f.Waits[0].ID != id1 || !f.At.Equal(t0.UTC()) {
|
|
t.Fatalf("%+v", f)
|
|
}
|
|
// Written whole: no .mesh-tmp left beside it.
|
|
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: by, What: "a second wait",
|
|
Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 5}, store, t0.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
|
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", by, id1, "approved", store, t0.Add(2*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err = readWaitsFile(waitsFilePath(p, "sess-3"))
|
|
if err != nil || len(f.Waits) != 1 || f.Waits[0].ID != id2["id"] {
|
|
t.Fatalf("the file does not hold exactly the open waits: %+v %v", f, err)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
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", 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)
|
|
}
|
|
|
|
// 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 name: %v %v", out, err)
|
|
}
|
|
|
|
// 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 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 name, naming the argument.
|
|
func TestAddRefusesOutsideItsBounds(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
base := func() WaitInput {
|
|
return WaitInput{Session: "sess-5", By: "fix-1629@" + p.Node, What: "something", Read: WaitRead{Address: "mesh-delivery.recheck"}, Bound: 10}
|
|
}
|
|
for name, change := range map[string]struct {
|
|
mutate func(*WaitInput)
|
|
want string
|
|
}{
|
|
"empty what": {func(in *WaitInput) { in.What = "" }, "what is required"},
|
|
"bound zero": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
|
"bound under 1": {func(in *WaitInput) { in.Bound = 0 }, "bound_minutes"},
|
|
"bound over 1440": {func(in *WaitInput) { in.Bound = 1441 }, "bound_minutes"},
|
|
"empty address": {func(in *WaitInput) { in.Read.Address = "" }, "read.address"},
|
|
"address no dot": {func(in *WaitInput) { in.Read.Address = "notanaddress" }, "read.address"},
|
|
"address has spaces": {func(in *WaitInput) { in.Read.Address = "not an address" }, "read.address"},
|
|
} {
|
|
in := base()
|
|
change.mutate(&in)
|
|
store := newMemWaits()
|
|
if _, err := AddWait(p, in, store, t0); err == nil || !strings.Contains(err.Error(), change.want) {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// More than 50 open waits per name.
|
|
store := newMemWaits()
|
|
for i := 0; i < MostWaits; i++ {
|
|
if _, err := AddWait(p, base(), store, t0); err != nil {
|
|
t.Fatalf("wait %d: %v", i, err)
|
|
}
|
|
}
|
|
if _, err := AddWait(p, base(), store, t0); err == nil || !strings.Contains(err.Error(), "at most 50 open waits") {
|
|
t.Fatalf("the 51st wait was not refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// Review round 1 item (b): list, watched, done and said refuse a session id that is not a plain one (the
|
|
// 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, by, store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
|
t.Errorf("list %q: %v", bad, err)
|
|
}
|
|
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, 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, by, "w-1", store, t0); err == nil || !strings.Contains(err.Error(), "session") {
|
|
t.Errorf("said %q: %v", bad, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1629/AC6: the tool is served through the module's own dispatch.
|
|
func TestTheWaitToolIsServedThroughTheModulesDispatch(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-6", "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)
|
|
}
|
|
var added map[string]any
|
|
if err := json.Unmarshal([]byte(text), &added); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, _ := added["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("no id answered: %s", text)
|
|
}
|
|
|
|
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", "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 := 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)
|
|
}
|
|
|
|
// list, read-only (review round 2 item c), never refuses: it just says who holds the name.
|
|
out, err := ListWaits(p, "sess-new", by, store, t0.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("list from a different session was refused: %v", err)
|
|
}
|
|
held, _ := out["held-by"].(map[string]any)
|
|
if held == nil || held["session"] != "sess-old" {
|
|
t.Fatalf("list did not say who holds the name: %v", out)
|
|
}
|
|
}
|
|
|
|
// 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 := AddWait(p, WaitInput{Session: "sess-old", By: by, What: "another", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
|
Bound: 10}, 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)
|
|
}
|
|
}
|
|
|
|
// ---- Review round 2: a session id's mirrored file is the union of every name bound to it -----------------
|
|
|
|
// Review round 2, item 2 (ADR 0490 §1): one session id with two different names bound to it holds both
|
|
// names' waits in its one mirrored file, not only the name an op last acted on.
|
|
func TestASessionsFileHoldsEveryNameBoundToIt(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
|
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
|
|
|
f, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids := map[string]bool{}
|
|
for _, w := range f.Waits {
|
|
ids[w.ID] = true
|
|
}
|
|
if len(f.Waits) != 2 || !ids[idX] || !ids[idY] {
|
|
t.Fatalf("the session's file does not hold both names' waits: %+v", f)
|
|
}
|
|
}
|
|
|
|
// Review round 2, item 2: adopting one of two names bound to the same session away keeps that session's
|
|
// file holding the other name's waits — it is never emptied wholesale.
|
|
func TestAdoptingOneNameAwayKeepsTheSessionsOtherNamesWaits(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
idX := addApproveWait(t, p, store, "fix-x", "sess-shared", t0)
|
|
idY := addApproveWait(t, p, store, "fix-y", "sess-shared", t0.Add(time.Minute))
|
|
|
|
if _, err := AdoptName(p, "fix-y@g14", "sess-y-new", store, t0.Add(2*time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sharedFile, err := readWaitsFile(waitsFilePath(p, "sess-shared"))
|
|
if err != nil || len(sharedFile.Waits) != 1 || sharedFile.Waits[0].ID != idX {
|
|
t.Fatalf("sess-shared should still hold fix-x's wait: %+v %v", sharedFile, err)
|
|
}
|
|
newFile, err := readWaitsFile(waitsFilePath(p, "sess-y-new"))
|
|
if err != nil || len(newFile.Waits) != 1 || newFile.Waits[0].ID != idY {
|
|
t.Fatalf("sess-y-new should hold fix-y's wait: %+v %v", newFile, err)
|
|
}
|
|
}
|
|
|
|
// Review round 2, item b: adopt clears the refusal counter of both the earlier id and the new one, so a
|
|
// fresh three-strikes count starts under the new binding.
|
|
func TestAdoptClearsTheRefusalCounterOfBothIDs(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
by := "fix-1629@g14"
|
|
addApproveWait(t, p, store, "fix-1629", "sess-old", t0)
|
|
waitsDir := WaitsDirPath(p)
|
|
if err := writeRefusalCounters(waitsDir, "sess-old", map[string]int{"w-1": 2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeRefusalCounters(waitsDir, "sess-new", map[string]int{"w-2": 3}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := AdoptName(p, by, "sess-new", store, t0.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c := readRefusalCounters(waitsDir, "sess-old"); len(c) != 0 {
|
|
t.Errorf("sess-old's counter was not cleared: %v", c)
|
|
}
|
|
if c := readRefusalCounters(waitsDir, "sess-new"); len(c) != 0 {
|
|
t.Errorf("sess-new's counter was not cleared: %v", c)
|
|
}
|
|
}
|
|
|
|
// Review round 2, item c: list is read-only — it never binds an unbound name, and never refuses a
|
|
// mismatched session; it answers the name's waits and who currently holds it.
|
|
func TestListNeverBindsAndAnswersWhoHoldsTheName(t *testing.T) {
|
|
p, _ := node(t, "g14")
|
|
store := newMemWaits()
|
|
by := "fix-1629@g14"
|
|
|
|
// An unbound name: list answers no waits, and binds nothing (a later add from a different session still
|
|
// succeeds, as the first caller for an unbound name).
|
|
out, err := ListWaits(p, "sess-reader", by, store, t0)
|
|
if err != nil || len(out["open"].([]map[string]any)) != 0 {
|
|
t.Fatalf("an unbound name: %v %v", out, err)
|
|
}
|
|
if _, found, _ := readSessionBinding(store, waitsKey(p, "fix-1629")); found {
|
|
t.Fatal("list bound an unbound name")
|
|
}
|
|
if _, err := AddWait(p, WaitInput{Session: "sess-first", By: by, What: "x", Read: WaitRead{Address: "mesh-delivery.recheck"},
|
|
Bound: 10}, store, t0); err != nil {
|
|
t.Fatalf("add after a read-only list: %v", err)
|
|
}
|
|
|
|
// A name bound to another session: list still answers (never refused), and says who holds it.
|
|
out, err = ListWaits(p, "sess-other", by, store, t0.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("list for a name held by another session was refused: %v", err)
|
|
}
|
|
if len(out["open"].([]map[string]any)) != 1 {
|
|
t.Fatalf("list did not answer the name's waits: %v", out)
|
|
}
|
|
held, _ := out["held-by"].(map[string]any)
|
|
if held == nil || held["session"] != "sess-first" {
|
|
t.Fatalf("list did not say who holds the name: %v", out)
|
|
}
|
|
}
|
|
|
|
// readSessionBinding is a thin test helper reading the raw NamedWaits at key, without claiming it.
|
|
func readSessionBinding(store WaitStore, key string) (NamedWaits, bool, error) {
|
|
nw, _, found, err := readNamedWaits(store, key)
|
|
return nw, found, err
|
|
}
|