diff --git a/modules/claude-code/cmd/claude-code/proposals.go b/modules/claude-code/cmd/claude-code/proposals.go index 21df7fff..f46cddde 100644 --- a/modules/claude-code/cmd/claude-code/proposals.go +++ b/modules/claude-code/cmd/claude-code/proposals.go @@ -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. diff --git a/modules/claude-code/cmd/claude-code/proposals_test.go b/modules/claude-code/cmd/claude-code/proposals_test.go index 6503c713..c1a3d1b8 100644 --- a/modules/claude-code/cmd/claude-code/proposals_test.go +++ b/modules/claude-code/cmd/claude-code/proposals_test.go @@ -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) + } +}