D2: assigner_ask_again asks one of the assigner's asks of the operator again by hand (issue 1966, ADR 0668 §5)

The recovery of an ask that ended at the channel unheard needed a restart. The tool writes the key
again under a new asking by the ask-ended rules (a proposal's authorising ask and a put's record ask
are refused), refuses an asking the operator answered, naming the answer, and records the hand act
through the seat mesh-hand-acts. One tool added to the manifest.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-10 03:16:28 +01:00
parent c3b240bdfa
commit f02a740b10
5 changed files with 131 additions and 3 deletions
@@ -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())
}}}
}
@@ -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
@@ -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)
}
@@ -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)
+2 -1
View File
@@ -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 },