diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 88250936..fe0b702e 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -388,6 +388,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c Description: "Read-only: what this node carries of the agent's memory between nodes (novox/hq ADR 0343) — the home kind it carries for (the operator's own homes, or an agent account's), each project's memory files with their fingerprint and the node and time of their last version, the files not carried and why, the conflicts still open (a file changed on two nodes, kept twice in one file, the newer first), and how many replaced or removed versions are kept. Fingerprints only, never the text; it changes nothing.", Run: func(map[string]any) (any, error) { return memoryCarrier.Status(), nil }}) out = append(out, proposalTools(p)...) + out = append(out, waitTools(p)...) judge := NewJudge(p) out = append(out, judgeTool(judge), judgedTool(judge)) return append(out, configTools(p, config, configView)...) @@ -463,6 +464,7 @@ func main() { proposing = &Proposing{Store: stateOf{stdio.State(ProposalsState)}, Bus: sdkAskBus{}, Record: recordWarrant, Now: time.Now, Write: writeManaged} go proposing.watch(p) + waiting = &Waiting{Store: keptWaits{stdio.State(WaitsState)}, Now: time.Now} memoryCarrier = NewMemoryCarrier(p, memoryStateOf{stdio.State(MemoryState)}, notifyOperator) go memoryCarrier.Run(func(handle func(key, op string, value json.RawMessage, revision uint64) error) error { return stdio.State(MemoryState).Watch("", func(c stdio.StateChange) error { diff --git a/modules/claude-code/cmd/claude-code/waits.go b/modules/claude-code/cmd/claude-code/waits.go new file mode 100644 index 00000000..3c7e7639 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/waits.go @@ -0,0 +1,483 @@ +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. +// +// 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. + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// WaitsState is the module's state the wait lists are kept in. +const WaitsState = "waits" + +// WaitsDir is where the mirrored files live under the agent's home. +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). +const MostWaits = 50 + +// The bounds of a wait's bound, in minutes (1629/AC5). +const ( + ShortestWaitBound = 1 + LongestWaitBound = 1440 +) + +// mostWaitAttempts bounds the compare-and-set retry loop over the `waits` state. +const mostWaitAttempts = 20 + +var waitSessionID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$`) + +// WaitRead is the verb a session reads to see where one wait stands. +type WaitRead struct { + Address string `json:"address"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +// Wait is one answer a session wrote down that it expects (novox/hq ADR 0489 §1-§2). +type Wait struct { + ID string `json:"id"` + By string `json:"by"` + What string `json:"what"` + Read WaitRead `json:"read"` + Family string `json:"family,omitempty"` + Bound int `json:"bound"` // minutes + At time.Time `json:"at"` + Watcher string `json:"watcher,omitempty"` + Said bool `json:"said,omitempty"` +} + +// PastBound says whether now is past this wait's bound. +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"` +} + +// WaitsFile is what the mirrored file holds, whole, on every change (1629/AC3): the session's open waits and +// the time they were written. +type WaitsFile struct { + Session string `json:"session"` + At time.Time `json:"at"` + Waits []Wait `json:"waits"` +} + +// WaitStore is the `waits` state: a value with its revision, created only when absent, updated only at the +// revision read. +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 +} + +// keptWaits is the `waits` state on the bus. +type keptWaits struct{ s stdio.KeptState } + +func (k keptWaits) Get(key string) (json.RawMessage, uint64, bool, error) { + e, err := k.s.Get(key) + if err != nil || e == nil { + return nil, 0, false, err + } + return e.Value, e.Revision, true, nil +} +func (k keptWaits) Create(key string, value any) error { + _, err := k.s.Create(key, value) + return err +} +func (k keptWaits) Update(key string, value any, revision uint64) error { + _, err := k.s.Update(key, value, revision) + return err +} + +// Waiting is what claude_code_wait reaches: the `waits` state and the clock. Nil outside a launch. +type Waiting struct { + Store WaitStore + Now func() time.Time +} + +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 } + +// waitsFilePath is where one session's waits are mirrored, under the agent's home. +func waitsFilePath(p Paths, session string) string { + return filepath.Join(p.Home, ".claude", filepath.FromSlash(WaitsDir), session+".json") +} + +func newWaitID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return WaitPrefix + hex.EncodeToString(b[:]) +} + +// addressShaped says whether s could be an address `mesh_call` takes: not blank, no whitespace, and a dot +// between two names (`.`, `/.`, `/.`). +func addressShaped(s string) bool { + s = strings.TrimSpace(s) + if s == "" || strings.ContainsAny(s, " \t\n\r") { + return false + } + dot := strings.LastIndex(s, ".") + return dot > 0 && dot < len(s)-1 +} + +func readSessionWaits(store WaitStore, key string) (SessionWaits, uint64, bool, error) { + raw, rev, found, err := store.Get(key) + if err != nil || !found { + return SessionWaits{}, 0, found, err + } + var sw SessionWaits + _ = json.Unmarshal(raw, &sw) + return sw, rev, true, nil +} + +// updateWaits reads one session'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) { + for i := 0; i < mostWaitAttempts; i++ { + sw, rev, found, err := readSessionWaits(store, key) + if err != nil { + return SessionWaits{}, fmt.Errorf("the wait list could not be read: %w", err) + } + next, err := mutate(sw) + if err != nil { + return SessionWaits{}, err + } + if !found { + err = store.Create(key, next) + } else { + err = store.Update(key, next, rev) + } + if err == nil { + return next, nil + } + if errors.Is(err, stdio.ErrChanged) { + continue + } + return SessionWaits{}, fmt.Errorf("the wait list could not be written: %w", err) + } + return SessionWaits{}, 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) + 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}) + 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) +} + +// sessionAndNode reads `by` as claude_code_ask does: who@node, where node is this one. +func sessionAndNode(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 who, nil + } + if by == "" { + return "", errors.New("by is required: your session's name, as who@node or who") + } + return by, nil +} + +// WaitInput is what claude_code_wait's add takes. +type WaitInput struct { + Session string + By string + What string + Read WaitRead + Family string + 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). +func AddWait(p Paths, in WaitInput, store WaitStore, now time.Time) (map[string]any, error) { + session := strings.TrimSpace(in.Session) + if !waitSessionID.MatchString(session) { + return nil, errors.New("session is required: the id the SessionStart hook named") + } + by, err := sessionAndNode(p, in.By) + if err != nil { + return nil, err + } + what := strings.TrimSpace(in.What) + if what == "" { + return nil, errors.New("what is required: say what you are waiting for") + } + if in.Bound < ShortestWaitBound || in.Bound > LongestWaitBound { + return nil, fmt.Errorf("bound_minutes is %d; it is %d to %d", in.Bound, ShortestWaitBound, LongestWaitBound) + } + address := strings.TrimSpace(in.Read.Address) + 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}, + 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) + } + sw.Session, sw.Node = session, p.Node + sw.Waits = append(append([]Wait(nil), sw.Waits...), w) + return sw, nil + }) + if err != nil { + return nil, err + } + if err := writeWaitsFile(p, session, sw, 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 + } + } + return -1 +} + +// 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) { + session, id, watcher = strings.TrimSpace(session), strings.TrimSpace(id), strings.TrimSpace(watcher) + if id == "" { + return nil, errors.New("id is required: the one add answered") + } + 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) + } + sw.Waits[i].Watcher = watcher + return sw, nil + }) + if err != nil { + return nil, err + } + if err := writeWaitsFile(p, session, sw, now); err != nil { + return nil, fmt.Errorf("the wait was watched, but its file could not be written: %w", err) + } + 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) { + session, id, outcome = strings.TrimSpace(session), 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) + } + sw.Waits = append(append([]Wait(nil), sw.Waits[:i]...), sw.Waits[i+1:]...) + return sw, nil + }) + if err != nil { + return nil, err + } + if err := writeWaitsFile(p, session, sw, 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) + 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) { + session, id = strings.TrimSpace(session), 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) + if i < 0 { + return sw, fmt.Errorf("no open wait %s for session %s", id, session) + } + 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) + } + sw.Waits[i].Said = true + return sw, nil + }) + if err != nil { + return nil, err + } + if err := writeWaitsFile(p, session, sw, now); err != nil { + return nil, fmt.Errorf("the wait was said, but its file could not be written: %w", err) + } + 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) { + session = strings.TrimSpace(session) + if session == "" { + return nil, errors.New("session is required: the id the SessionStart hook named") + } + sw, _, _, err := readSessionWaits(store, waitsKey(p, session)) + if err != nil { + return nil, fmt.Errorf("the wait list could not be read: %w", err) + } + out := []map[string]any{} + for _, w := range sw.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} + if len(w.Read.Arguments) > 0 { + entry["arguments"] = w.Read.Arguments + } + if w.Family != "" { + entry["family"] = w.Family + } + out = append(out, entry) + } + return map[string]any{"session": session, "open": out}, nil +} + +// 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) { + raw, err := os.ReadFile(path) + if err != nil { + return WaitsFile{}, err + } + var f WaitsFile + if err := json.Unmarshal(raw, &f); err != nil { + return WaitsFile{}, err + } + return f, nil +} + +// waitTools is claude_code_wait: add, watched, done, said and list, over one session's wait list (novox/hq +// ADR 0489). +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.", + 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"), + "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{ + "address": str("the address, as mesh_call takes it"), + "arguments": map[string]any{"type": "object", "description": "its arguments"}, + }}, + "family": str("add: the event family mesh_wait can wait on, when there is one"), + "bound_minutes": map[string]any{"type": "integer", "description": "add: minutes until this wait's bound, 1 to 1440"}, + "id": str("watched, done or said: the id add answered"), + "watcher": str("watched: what follows this wait"), + "outcome": str("done: what the wait ended with"), + }, + Run: func(a map[string]any) (any, error) { + if waiting == nil { + return nil, notLaunched + } + session := strArg(a, "session") + now := time.Now() + if waiting.Now != nil { + now = waiting.Now() + } + switch op := strArg(a, "op"); op { + case "add": + read := WaitRead{} + if m, ok := a["read"].(map[string]any); ok { + read.Address = strArg(m, "address") + if args, ok := m["arguments"].(map[string]any); ok { + read.Arguments = args + } + } + bound := 0 + 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, + Family: strArg(a, "family"), Bound: bound}, waiting.Store, now) + case "watched": + return WatchedWait(p, session, strArg(a, "id"), strArg(a, "watcher"), waiting.Store, now) + case "done": + return DoneWait(p, session, strArg(a, "id"), strArg(a, "outcome"), waiting.Store, now) + case "said": + return SaidWait(p, session, strArg(a, "id"), waiting.Store, now) + case "list": + return ListWaits(p, session, waiting.Store, now) + default: + return nil, fmt.Errorf("op is add, watched, done, said or list, 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 new file mode 100644 index 00000000..59f4f0a8 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/waits_test.go @@ -0,0 +1,266 @@ +package main + +// A session's wait list (novox/hq ADR 0489, issue 1629). + +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) +} + +// 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 { + t.Helper() + out, err := AddWait(p, WaitInput{Session: session, By: "fix-1629@" + 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, "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)) + 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", 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() + id := addApproveWait(t, p, store, "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") { + 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 { + t.Fatal(err) + } + out, _ := ListWaits(p, "sess-2", 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 { + t.Fatalf("said past the bound: %v", err) + } + out, _ = ListWaits(p, "sess-2", 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 { + t.Fatal(err) + } + out, _ = ListWaits(p, "sess-2", 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 { + 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() + id1 := addApproveWait(t, p, store, "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: "fix-1629@" + p.Node, 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", 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 session +// after a /clear (the same session id lists them); a different session id lists none of them. +func TestWaitsSurviveARestartAndAClearButNotCrossSessions(t *testing.T) { + p, _ := node(t, "g14") + store := newMemWaits() + addApproveWait(t, p, store, "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)) + 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)) + if err != nil || len(out["open"].([]map[string]any)) != 1 { + t.Fatalf("not read again for the same session: %v %v", out, err) + } + + // A different session id: none of them. + out, err = ListWaits(p, "sess-other", 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) + } +} + +// 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. +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 session. + 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) + } +} + +// 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"}) + 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"}) + if isErr { + t.Fatalf("done: %s", text) + } +} diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index b869d012..3b62d219 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -28,7 +28,8 @@ "config", "proposals", "questions", - "memory" + "memory", + "waits" ], "reads": [ "claude-licence-manager.bindings" @@ -67,6 +68,7 @@ "claude_code_proposals", "claude_code_ask", "claude_code_answer", + "claude_code_wait", "claude_code_settings_get", "claude_code_settings_set", "claude_code_settings_clear",