diff --git a/modules/claude-code/cmd/claude-code/proposal_tools.go b/modules/claude-code/cmd/claude-code/proposal_tools.go index 495b17d2..47967c79 100644 --- a/modules/claude-code/cmd/claude-code/proposal_tools.go +++ b/modules/claude-code/cmd/claude-code/proposal_tools.go @@ -3,6 +3,7 @@ package main // The tools and the watch of the shared instructions proposed for the operator's approval (novox/hq ADR 0274). import ( + "encoding/json" "errors" "strings" "time" @@ -56,7 +57,18 @@ func (d *Proposing) watch(p Paths) { } } go persist("hearing the operator's answers", func() error { - return stdio.Subscribe(asks.Seat+".decided."+Module, func(stdio.Envelope) error { poke(); return nil }) + return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error { + var w asks.Warrant + if json.Unmarshal(e.Body, &w) == nil { + if line, err := HeardEnd(p, d.Store, w, d.Now()); err != nil { + say("taking the router's word on %s: %v", w.Ask, err) + } else if line != "" { + say("%s", line) + } + } + poke() + return nil + }) }, func(n int) { say("hearing the operator's answers%s", refusals(n)) }) go persist("watching the proposals", func() error { return stdio.State(ProposalsState).Watch("", func(stdio.StateChange) error { poke(); return nil }) diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index e9df3083..21df7fff 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -620,13 +620,26 @@ type ProposeInput struct { const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead: mesh-cli settings set claude-code " + "'{\"instructions\": {\"\": [\"line\", …]}}' [--node ] (novox/hq issue 340)" -// approverReady says whether a channel that proves who answered can carry an approval: a kind of the channel -// bench promising a choice, an exact rendering and a verified sender. The router judges again, live; this is -// only so a proposal no channel can carry is refused at once, in words. +// approverReady says why no channel that proves who answered can carry an approval, when this module can tell: +// the channel bench's kinds, where its membership names them, and none promising a choice, an exact rendering and +// a verified sender. **The router is the judge** (novox/hq ADR 0259 §4): it refuses an ask none of its ready +// channels can carry, on the decided subject, and Propose waits for that word. A module that only asks is issued +// no kinds (they are issued for a kinded bench a module uses or watches, and this module uses the operator +// channel alone), so an empty list says nothing — read as "none" it refused every proposal on the live mesh on +// 2026-10-10, with Telegram linked and proven. func approverReady(bus AskBus) string { kinds, err := bus.Kinds() - if err != nil { - return "the channels could not be read (" + err.Error() + ")" + if err != nil || len(kinds) == 0 { + return "" + } + channels := 0 + for _, k := range kinds { + if k.Seat == "channel" { + channels++ + } + } + if channels == 0 { + return "" } for _, k := range kinds { if k.Seat == "channel" && k.Promises("choice") && k.Promises("exact-render") && k.Promises("verified-sender") { @@ -732,7 +745,24 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time "Propose it again, or %s", err, AtTheTerminalInstead) } say("proposal %s (%s %s %s, fingerprint %s) asked of the operator", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint()) + // The router's word, before answering: it refuses at once an ask no channel can carry, and records one it took. + taken := "the router has not said yet whether it took the ask; claude_code_proposals shows it" + for deadline := time.Now().Add(RouterAnswersWithin); ; { + if w, ended := endedAsk(prop.ID); ended { + return nil, fmt.Errorf("the router did not ask the operator: the ask %s (%s). Nothing is active anywhere; %s", + w.Outcome, w.Words, AtTheTerminalInstead) + } + if entry, err := bus.Record(prop.ID); err == nil && entry != nil { + taken = "the router took the ask and shows it on the channels that can carry it" + break + } + if time.Now().After(deadline) { + break + } + time.Sleep(RouterAnswersEvery) + } return map[string]any{ + "router": taken, "proposed": prop.ID, "action": prop.Action, "reaches": prop.where(), "fingerprint": prop.Fingerprint(), "asked": "the operator is asked on a channel that proves who answers: \"" + ask.Headline + "\"", "expires": ask.Expires.Format(time.RFC3339), @@ -773,6 +803,61 @@ func readProposal(store ProposalStore, key string) (Proposal, bool) { return prop, true } +// How long Propose waits for the router's word on a new ask, and how often it looks. +var ( + RouterAnswersWithin = 8 * time.Second + RouterAnswersEvery = 250 * time.Millisecond +) + +// ended is the asks the router ended without a choice, as heard on the decided subject — which only the router may +// publish, for this module alone. A refused ask leaves no record of the router's, so this is where its refusal is +// read. +var ( + endedMu sync.Mutex + ended = map[string]asks.Warrant{} +) + +func endedAsk(id string) (asks.Warrant, bool) { + endedMu.Lock() + defer endedMu.Unlock() + w, ok := ended[id] + return w, ok +} + +// HeardEnd takes the router's word that one of this module's asks ended without a choice: refused, expired, +// cancelled or replaced. The proposal is discarded here at once. Nothing is applied on such a word, so it needs no +// proof beyond its subject; a choice is only ever taken from the router's record (Reconcile). +func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (string, error) { + if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !asks.UsableID(w.Ask) { + return "", nil + } + endedMu.Lock() + ended[w.Ask] = w + endedMu.Unlock() + reconcileMu.Lock() + defer reconcileMu.Unlock() + if _, known := readProposal(store, w.Ask); !known { + return "", nil + } + approved, err := ReadApproved(p) + if err != nil { + return "", err + } + if _, done := approved.Decided[w.Ask]; done { + return "", nil + } + d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome)} + if w.Words != "" { + d.Outcome += " (" + w.Words + ")" + } + approved.Decided[w.Ask] = d + delete(approved.Refused, w.Ask) + if err := writeApproved(p, approved); err != nil { + return "", err + } + return "proposal " + w.Ask + ": " + d.Outcome, nil +} + // ---- deciding, on every node ------------------------------------------------------------------------ // routerRecord is the router's record of one ask, as far as this module reads it. @@ -804,6 +889,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti w asks.Warrant } var ready []closed + discarded := false for _, k := range keys { prop, ok := readProposal(store, k) if !ok { @@ -813,6 +899,15 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti continue } entry, err := bus.Record(prop.ID) + if err == nil && entry == nil && now.After(prop.At.Add(ProposalLasts+time.Hour)) { + // Ended, and the router keeps no record of it: refused before it was taken, or kept past its time. Either + // way no answer can be proven, so it is discarded and nothing is applied. + approved.Decided[prop.ID] = ProposalDecision{At: now.UTC(), + Outcome: "discarded: the router keeps no record of its ask (refused before it was taken, or forgotten)"} + delete(approved.Refused, prop.ID) + discarded = true + continue + } if err != nil || entry == nil { continue // open, not yet recorded, or not readable now: asked again on the next look } @@ -823,7 +918,7 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti ready = append(ready, closed{prop, *rec.Warrant}) } sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) }) - changed, wrote := false, false + changed, wrote := false, discarded var said []string for _, c := range ready { d, applied, line, final := decide(p, &approved, c.prop, c.w, now) diff --git a/modules/claude-code/cmd/claude-code/proposals_test.go b/modules/claude-code/cmd/claude-code/proposals_test.go index 5b958d53..6503c713 100644 --- a/modules/claude-code/cmd/claude-code/proposals_test.go +++ b/modules/claude-code/cmd/claude-code/proposals_test.go @@ -642,3 +642,78 @@ func TestANodeSectionIsNotReplacedFromOtherNodes(t *testing.T) { t.Error("a removal for other nodes was taken for the section on ace") } } + +// A module that only asks is issued no channel kinds (they come with a kinded bench a module uses or watches): an +// empty list is no refusal, and the router, which judges, is asked (the live failure of 2026-10-10). +func TestAnAskerIssuedNoKindsLeavesTheJudgingToTheRouter(t *testing.T) { + p, _ := node(t, "novox") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + r.kinds = nil + out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, r, t0) + if err != nil { + t.Fatalf("refused with no kinds issued: %v", err) + } + if !strings.Contains(out["router"].(string), "took the ask") { + t.Errorf("router: %v", out["router"]) + } +} + +// The router's refusal reaches the proposer at once, and discards the proposal on every node that hears it. +func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) { + p, _ := node(t, "novox") + store := &memProposals{m: map[string]json.RawMessage{}} + r := newRouter() + r.kinds = nil + refusing := &refusingRouter{router: r, p: p, store: store} + RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond + _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, store, refusing, t0) + if err == nil || !strings.Contains(err.Error(), "no channel can carry any of its answers now") || + !strings.Contains(err.Error(), "mesh-cli settings set claude-code") { + t.Fatalf("refusal: %v", err) + } + if got := outcomeHere(t, p, refusing.id); !strings.HasPrefix(got, "discarded: the ask refused") { + t.Errorf("decided %q", got) + } + if changed, _, _ := Reconcile(p, store, refusing, nil, t0.Add(time.Hour)); changed { + t.Error("a refused proposal changed what renders") + } +} + +// A proposal whose ask ended and that the router keeps no record of is discarded, never left waiting. +func TestAnAskTheRouterKeepsNoRecordOfIsDiscardedAfterItsTime(t *testing.T) { + p, _ := node(t, "novox") + store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter() + id := proposeHandover(t, p, store, r) + delete(r.records, id) + if _, _, err := Reconcile(p, store, r, nil, t0.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if got := outcomeHere(t, p, id); got != "" { + t.Fatalf("decided before its time: %q", got) + } + if _, _, err := Reconcile(p, store, r, nil, t0.Add(ProposalLasts+2*time.Hour)); err != nil { + t.Fatal(err) + } + if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded: the router keeps no record") { + t.Errorf("decided %q", got) + } +} + +// refusingRouter refuses every ask as the router does when no channel can carry it: on the decided subject, with +// no record. +type refusingRouter struct { + *router + p Paths + store ProposalStore + id string +} + +func (r *refusingRouter) Publish(subject string, body any, id string) error { + r.id = id + go func() { + time.Sleep(50 * time.Millisecond) + _, _ = HeardEnd(r.p, r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, + Words: "no channel can carry any of its answers now: telegram is not linked", At: t0}, t0) + }() + return nil +}