claude-code: an ask taken after its refusal is still answered; the wait is bounded (issue 370 review)
mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered

A refusal heard on the decided subject is kept as heard, and a record of a person's choice found later is
still taken, so an ask the router's queue offered again is not lost in silence. A heard end must match the
proposal's ask; the wait stops reading at its deadline; the heard ends are forgotten when Propose returns.
This commit is contained in:
jochen
2026-10-10 13:08:09 +02:00
parent aafda6e870
commit 774f18afcf
2 changed files with 51 additions and 15 deletions
@@ -448,10 +448,13 @@ type ProposalDecision struct {
Says string `json:"says,omitempty"`
// Recorded says the controller's hand-act log holds the warrant; Record is why not, while it does not.
// Warranted says a person's choice held here: Approve or Decline, recorded in the hand-act log.
Warranted bool `json:"warranted,omitempty"`
Recorded bool `json:"recorded,omitempty"`
Record string `json:"record,omitempty"`
LastTry time.Time `json:"last-try,omitempty"`
Warranted bool `json:"warranted,omitempty"`
Recorded bool `json:"recorded,omitempty"`
// Heard says the decision came from the router's word on the decided subject, not its record: a record of a
// choice found later is still taken.
Heard bool `json:"heard,omitempty"`
Record string `json:"record,omitempty"`
LastTry time.Time `json:"last-try,omitempty"`
}
// Approved is this node's own record: the sections it renders, and every proposal it decided.
@@ -747,7 +750,8 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
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); ; {
defer func() { endedMu.Lock(); delete(ended, prop.ID); endedMu.Unlock() }()
for deadline := time.Now().Add(RouterAnswersWithin); time.Now().Before(deadline); {
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)
@@ -756,9 +760,6 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
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{
@@ -831,14 +832,15 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !asks.UsableID(w.Ask) {
return "", nil
}
reconcileMu.Lock()
defer reconcileMu.Unlock()
prop, known := readProposal(store, w.Ask)
if !known || (w.About != "" && w.About != prop.Ask().About) {
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
@@ -846,7 +848,9 @@ func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (stri
if _, done := approved.Decided[w.Ask]; done {
return "", nil
}
d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome)}
// Heard, not read from a record: Reconcile still reads the router's record of it, so an ask the router took after
// all (its queue offered it again once a channel was ready) is answered as any other.
d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
if w.Words != "" {
d.Outcome += " (" + w.Words + ")"
}
@@ -895,10 +899,20 @@ func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now ti
if !ok {
continue
}
if _, done := approved.Decided[prop.ID]; done {
d, done := approved.Decided[prop.ID]
if done && !d.Heard {
continue
}
entry, err := bus.Record(prop.ID)
if done {
// Discarded on the router's word alone: only a record of a person's choice reopens it.
var rec routerRecord
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.Warrant != nil &&
rec.Warrant.Outcome == asks.OutcomeChosen {
ready = append(ready, closed{prop, *rec.Warrant})
}
continue
}
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.
@@ -665,7 +665,9 @@ func TestTheRoutersRefusalIsSaidAndDiscards(t *testing.T) {
r := newRouter()
r.kinds = nil
refusing := &refusingRouter{router: r, p: p, store: store}
within, every := RouterAnswersWithin, RouterAnswersEvery
RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond
t.Cleanup(func() { RouterAnswersWithin, RouterAnswersEvery = within, every })
_, 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") {
@@ -717,3 +719,23 @@ func (r *refusingRouter) Publish(subject string, body any, id string) error {
}()
return nil
}
// A refusal heard, and then the same ask taken after all and approved: the approval is taken, not lost in silence.
func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
p, _ := node(t, "novox")
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
id := proposeHandover(t, p, store, r)
if _, err := HeardEnd(p, store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused, Words: "no channel", At: t0}, t0); err != nil {
t.Fatal(err)
}
if got := outcomeHere(t, p, id); !strings.HasPrefix(got, "discarded") {
t.Fatalf("decided %q", got)
}
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
if changed, _, _ := Reconcile(p, store, r, nil, t0.Add(time.Hour)); !changed {
t.Fatal("the approval of an ask taken after its refusal was lost")
}
if got := outcomeHere(t, p, id); got != "approved" {
t.Errorf("decided %q", got)
}
}