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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user