diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/askagain_tool.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/askagain_tool.go new file mode 100644 index 00000000..762a44b1 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/askagain_tool.go @@ -0,0 +1,100 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + + "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents" + stdio "git.novox.be/novox/mesh-sdk/go" + + "mesh-delivery-assigner/internal/assign" + "mesh-delivery-assigner/internal/buckets" + "mesh-delivery-assigner/internal/puts" +) + +// askAgainTool asks one of the assigner's asks again by hand (issue 1966, ADR 0668 §5): the recovery of an ask that ended at +// the operator channel without an answer when the event that would have asked it again was not heard. +const askAgainTool = "assigner_ask_again" + +// asksState and answersState are the assigner's asks and the teller's answers, as the runtime names them. +const ( + asksState = "asks" + answersState = buckets.TellerModule + ".answers" +) + +// askAgainByHand writes the ask at key again under a new asking, by the rules the teller's ask-ended writes it by (assign.AskedAgain: +// a proposal's authorising ask and a put's record ask are not), so the teller asks it again; an asking the operator answered +// is refused, naming the answer. The act is recorded through the seat mesh-hand-acts. +func askAgainByHand(st store, ask func(string, any) (json.RawMessage, error), key, why, by string, now time.Time) (any, error) { + switch { + case key == "": + return nil, errors.New("name the ask's key, as assigner_read_asks lists it") + case why == "": + return nil, errors.New("a hand ask again says why") + case by == "" || by == stdio.Unproven: + return nil, errors.New("a hand ask again names who asks it, and the runtime proved no caller") + } + current, _, found, err := st.get(asksState, key) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("the assigner holds no ask under %s: assigner_read_asks lists them", key) + } + _, routerAsk, err := assign.DecodeAsk(key, current) + if err != nil { + return nil, err + } + if raw, _, found, err := st.get(answersState, key); err == nil && found { + if a, err := meshdeliveryclient.DecodeAnswer(raw); err == nil && strings.HasPrefix(a.Warrant, routerAsk.ID+"@") { + return nil, fmt.Errorf("the asking %s was answered %q (%s): it is not asked again", routerAsk.ID, a.Option, a.Words) + } + } + var put *puts.Put + if k, err := asks.ParseAnyAskKey(key); err == nil && k.Kind == asks.AskKindRecord { + if pkey, err := puts.Key(k.Node, k.Module); err == nil { + if raw, _, found, err := st.get(putsState, pkey); err == nil && found { + if p, err := puts.Decode(raw); err == nil { + put = &p + } + } + } + } + value, refused := assign.AskedAgain(tellerevents.Ended{Key: key, Ask: routerAsk.ID, Outcome: occ.OutcomeCancelled}, current, put, now) + if refused != "" { + return nil, errors.New(refused) + } + if err := st.put(asksState, key, value); err != nil { + return nil, fmt.Errorf("the ask %s is not written again: %w", key, err) + } + _, next, _ := assign.DecodeAsk(key, value) + taken, rerr := meshhandactsclient.Recorder{Ask: ask}.Record(meshhandactsclient.Request{What: askAgainTool + " " + key, Why: why + " (" + by + ")", Cause: "ask-again"}) + recorded := taken.Log + if rerr != nil { + recorded = "not recorded: " + rerr.Error() + } + return map[string]any{"key": key, "ended": routerAsk.ID, "asked": next.ID, "by": by, "hand-act": recorded}, nil +} + +// askAgainTools is the tool over the runtime's states. +func askAgainTools(st store, ask func(string, any) (json.RawMessage, error), now func() time.Time) []stdio.Tool { + return []stdio.Tool{{Name: askAgainTool, Description: "Ask one of the assigner's asks of the operator again (issue 1966): the ask " + + "under key is written again under a new asking, which the teller asks on the operator channel, as when the channel says it " + + "ended without an answer. Refused for an asking the operator answered, a proposal's authorising ask (the seat's verb again " + + "asks it) and a record ask a put asked. Recorded as a hand act.", + Input: schema(map[string]string{"key": "the ask's key, as assigner_read_asks lists it", "why": "why it is asked again"}, + []string{"key", "why"}), + Run: func(a map[string]any) (any, error) { + key, _ := a["key"].(string) + why, _ := a["why"].(string) + return askAgainByHand(st, ask, key, why, stdio.CallerOf(a), now()) + }}} +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index f760fc08..64b17792 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + stdio "git.novox.be/novox/mesh-sdk/go" "mesh-delivery-assigner/internal/buckets" "mesh-delivery-assigner/internal/puts" "slices" @@ -127,6 +128,31 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) { if a2.Ask.ID == a1.Ask.ID { t.Errorf("the ask written again after a lift and a restart has the id %s of the first", a1.Ask.ID) } + // Issue 1966, ADR 0668 §5: assigner_ask_again writes the hold again under a new asking and records the hand act; the + // asking the operator answered is refused, naming the answer; a proposal's authorising ask is refused by its rule. + answersKV := &memKV{values: map[string][]byte{}} + st := memStore{asksState: asksKV, answersState: answersKV} + var recorded []string + record := func(key string, body any) (json.RawMessage, error) { + raw, _ := json.Marshal(body) + recorded = append(recorded, key+" "+string(raw)) + return json.RawMessage(`{}`), nil + } + now := time.Date(2026, 10, 25, 3, 0, 0, 0, time.UTC) + out, err := askAgainByHand(st, record, key, "the channel lost it", "lead@shanks", now) + a3, derr := decodeAsk(key, asksKV.values[key]) + if err != nil || derr != nil || a3.Ask.ID == a2.Ask.ID || len(recorded) != 1 || !strings.Contains(recorded[0], askAgainTool+" "+key) { + t.Fatalf("ask again: %v, %v; asked %s (was %s), recorded %v", out, err, a3.Ask.ID, a2.Ask.ID, recorded) + } + answersKV.values[key], _ = mdc.EncodeAnswer(mdc.Answer{Option: "keep", Warrant: a3.Ask.ID + "@2026-10-25T03:01:00Z", Words: "the operator"}) + if _, err := askAgainByHand(st, record, key, "again", "lead@shanks", now); err == nil || !strings.Contains(err.Error(), "keep") { + t.Errorf("an answered asking is asked again: %v", err) + } + for _, c := range []struct{ key, by, why string }{{key, stdio.Unproven, "a caller"}, {"hold.none", "lead@shanks", "no ask"}} { + if _, err := askAgainByHand(st, record, c.key, "w", c.by, now); err == nil { + t.Errorf("%s: asked again", c.why) + } + } } // 1906/H4: an answer counts only for the asking it answered: an answer to an earlier hold of the same fingerprint, or to an ask diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go index 877b6c1e..d81e9acf 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -404,6 +404,7 @@ func serveTools() error { tools = append(tools, viewTools(os.Getenv, keptSource, time.Now)...) // What each node's engine reads, as the assigner uses it (ADR 0618 §3): its own state engine-reads, asked again through it. tools = append(tools, enginereads.Tool(engineReadsTool, "the assigner", enginereads.KeptState(stdio.State(engineReadsState)), false, time.Now)) + tools = append(tools, askAgainTools(runtimeStore{}, stdio.Ask, time.Now)...) return stdio.Serve("", tools) } diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 7c0a697c..c1ceeaea 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -96,8 +96,8 @@ func TestManifest(t *testing.T) { // which lists and asks again what each node's engine reads. if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts", "assignments", "seat-holders", "wants", "assigner_read_assignments", "assigner_read_seat_holders", "assigner_read_wants", "assigner_read_findings", "assigner_read_asks", "assigner_read_differences", "assigner_read_copies", - "assigner_first_views", "assigner_first_view_again", "assigner_engine_reads", "assigner_seat_list", "assigner_seat_list_again"}) { - t.Errorf("the assigner serves tools %v, want its three put tools, three first views, six read tools, assigner_engine_reads and the seat list's two (ADR 0636 §7)", m.Tools) + "assigner_first_views", "assigner_first_view_again", "assigner_engine_reads", "assigner_seat_list", "assigner_seat_list_again", "assigner_ask_again"}) { + t.Errorf("the assigner serves tools %v, want its three put tools, three first views, six read tools, assigner_engine_reads, the seat list's two (ADR 0636 §7) and assigner_ask_again (ADR 0668 §5)", m.Tools) } if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs { t.Errorf("runs-as = %q: the bundle runs on the assigner's own account (ADR 0621 §1)", m.RunsAs) diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index bb459b24..e594b25c 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -89,7 +89,8 @@ "assigner_first_view_again", "assigner_engine_reads", "assigner_seat_list", - "assigner_seat_list_again" + "assigner_seat_list_again", + "assigner_ask_again" ], "state": [ { "name": "assignments", "history": 64 },