diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index b02a26ec4..be28d5c0e 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -592,12 +592,15 @@ func TestAnAskIsRefusedInWordsToItsAsker(t *testing.T) { } } all := strings.Join(refused, "\n") - for _, want := range []string{"d-1: the ask is refused: an ask that destroys lasts at most", "d-2: its words carry", - "d-6: its asker already has 3 open asks"} { + // A fourth offered ask is never refused (ADR 0666 §12): it is taken and the asker's oldest is held back. + for _, want := range []string{"d-1: the ask is refused: an ask that destroys lasts at most", "d-2: its words carry"} { if !strings.Contains(all, want) { t.Errorf("not refused: %q in\n%s", want, all) } } + if strings.Contains(all, "d-6") || store.asks["mesh-delivery.d-6"].State != StateOpen || store.asks["mesh-delivery.d-3"].State != StateHeldBack { + t.Errorf("the fourth ask was refused or the oldest not held back: d-6 %s, d-3 %s", store.asks["mesh-delivery.d-6"].State, store.asks["mesh-delivery.d-3"].State) + } if _, kept := store.asks["mesh-delivery.d-1"]; kept { t.Error("a refused ask was recorded") } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 4e0a9c5ee..0cba1d7b7 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -65,6 +65,9 @@ type AskRecord struct { Warrant *oc.Warrant `json:"warrant,omitempty"` // Told: the warrant, or the end, was published to the asker. Told bool `json:"told,omitempty"` + // HeldBackSince is, while the ask is held back, since when: kept open behind newer asks of its asker, its tickets + // withdrawn, offered again when one of its asker's offered asks ends (novox/hq ADR 0666 §12). + HeldBackSince time.Time `json:"held-back-since,omitempty"` // HeldSince and HeldWhy say, while the ask is held, since when and why no channel can carry it: per kind, the // reason from the judgement that judges an answer. The asker reads them in this record, as it reads // the state (the asks protocol has no word for a held ask on `decided`, which means an ask ended). @@ -558,23 +561,24 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { h.refuseAsk(asker, a, "its whole words carry "+refusal.String()+", which may not leave the mesh on any channel: withhold it") return nil } + // A fourth offered ask is taken, never refused: the asker's oldest offered one is held back once this one is recorded + // (holdBackOldest). An ask with the same `about` replaces the open one, a held-back one too. open, replaced := 0, "" h.mu.Lock() for k, e := range h.asks { - if e.rec.Asker != asker || (e.rec.State != StateOpen && e.rec.State != StateHeld) { + if e.rec.Asker != asker || !liveState(e.rec.State) { continue } if a.About != "" && e.rec.Ask.About == a.About { replaced = k continue } - open++ + if e.rec.State != StateHeldBack { + open++ + } } h.mu.Unlock() - if open >= occ.MostOpen { - h.refuseAsk(asker, a, fmt.Sprintf("its asker already has %d open asks", open)) - return nil - } + holdBack := open >= occ.MostOpen rec := AskRecord{Asker: asker, Ask: a, State: StateOpen, Opened: now, Tickets: h.ticketsFor(a)} if len(rec.Tickets) == 0 { // Held, not refused (ADR 0397): the ask breaks no rule, and the operator is asked when a channel can carry it. @@ -605,6 +609,9 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { } else { h.keepAsk(key, rec, 0) } + if holdBack { + h.holdBackOldest(asker, key) + } if rec.State == StateHeld { h.Logf("[messenger] %s asks %q (%s): held, not shown: %s", asker, a.Headline, a.ID, rec.HeldWhy) return nil @@ -897,12 +904,14 @@ func (h *Holder) closeAsk(key string, outcome string, words string, w *oc.Warran rec, rev = *got, r } } - if rec.State != StateOpen && rec.State != StateHeld { + if !liveState(rec.State) { h.keepAsk(key, rec, rev) return ErrChanged } now := h.Now() + freed := rec.State == StateOpen || rec.State == StateHeld // a slot of the asker's offered asks, unless a newer ask takes it rec.State = string(outcome) + rec.HeldBackSince = time.Time{} rec.HeldWhy = "" rec.Closed = now if t, ok := rec.Tickets[used]; ok { @@ -926,6 +935,9 @@ func (h *Holder) closeAsk(key string, outcome string, words string, w *oc.Warran rev = newRev } h.keepAsk(key, rec, rev) + if freed && outcome != occ.OutcomeReplaced { + defer h.offerHeldBack(rec.Asker) + } h.sayClosed(key, rec, outcome, words) if rec.Asker == SelfAsker { // The router's own ask: it does what was decided, and tells nobody else (selfact.go). @@ -1016,7 +1028,7 @@ func (h *Holder) endLeftovers() { if r.Origin != "ask" || r.Ask == "" || !r.Cleared.IsZero() { continue } - if e := h.asks[r.Ask]; e != nil && e.rec.State != StateOpen && e.rec.Record == r.Key { + if e := h.asks[r.Ask]; e != nil && !liveState(e.rec.State) && e.rec.Record == r.Key { ended = append(ended, e.rec) } } @@ -1081,9 +1093,9 @@ func (h *Holder) sweepAsks(now time.Time) { var expired, untold []string for k, e := range h.asks { switch { - case (e.rec.State == StateOpen || e.rec.State == StateHeld) && !now.Before(askExpires(e.rec.Ask)): + case liveState(e.rec.State) && !now.Before(askExpires(e.rec.Ask)): expired = append(expired, k) - case e.rec.State != StateOpen && e.rec.State != StateHeld && !e.rec.Told && e.rec.Warrant != nil: + case !liveState(e.rec.State) && !e.rec.Told && e.rec.Warrant != nil: untold = append(untold, k) } } @@ -1195,6 +1207,8 @@ func (h *Holder) judge(kind string, c oc.Choice, key string, root func(KindInfo) return "chosen", nil } switch { + case rec.State == StateHeldBack: + return h.refuseChoice(kind, c, "the question waits behind newer questions of its asker, and is offered again when one of them ends") case !issued: return h.refuseChoice(kind, c, "it belongs to no ask this router holds") case t.Kind != kind: @@ -1386,6 +1400,10 @@ type AskView struct { // HeldSince and HeldWhy: while the ask is held, since when and why no channel can carry it yet (ADR 0397 §4). HeldSince string `json:"held_since,omitempty"` HeldWhy string `json:"held_why,omitempty"` + // HeldBackSince and Behind: while the ask is held back (ADR 0666 §12), since when and the keys of the newer asks of its + // asker it waits behind, in the names of the seat operator-channel's asks answer. + HeldBackSince string `json:"held-since,omitempty"` + Behind []string `json:"behind,omitempty"` } // AsksNow is every ask, open first, newest first. @@ -1400,6 +1418,9 @@ func (h *Holder) AsksNow() []AskView { if e.rec.State == StateHeld { v.HeldSince, v.HeldWhy = e.rec.HeldSince.UTC().Format(time.RFC3339), e.rec.HeldWhy } + if e.rec.State == StateHeldBack { + v.HeldBackSince, v.Behind = e.rec.HeldBackSince.UTC().Format(time.RFC3339), h.behindLocked(k, e.rec) + } kinds := map[string]bool{} for _, t := range e.rec.Tickets { kinds[t.Kind] = true @@ -1418,7 +1439,7 @@ func (h *Holder) AsksNow() []AskView { out = append(out, v) } sort.Slice(out, func(i, j int) bool { - oi, oj := out[i].State == StateOpen || out[i].State == StateHeld, out[j].State == StateOpen || out[j].State == StateHeld + oi, oj := liveState(out[i].State), liveState(out[j].State) if oi != oj { return oi } @@ -1479,7 +1500,7 @@ func (h *Holder) sayClosed(key string, rec AskRecord, outcome string, words stri } } -// AsksQuery narrows what the tool `operator-channel.asks` answers (issue 595): Key one ask, State open (open and held) or +// AsksQuery narrows what the tool `operator-channel.asks` answers (issue 595): Key one ask, State open (open, held and held-back) or // ended (all takes both), Since the asks opened at or after that time, Bound the most bytes of the answer's asks. With no // argument at all the query is the open asks within DefaultAsksBound (issue 1974), and the answer says so. type AsksQuery struct { @@ -1543,7 +1564,7 @@ func (q AsksQuery) Narrow(all []AskView) AsksAnswer { } var kept []AskView for _, v := range all { - open := v.State == StateOpen || v.State == StateHeld + open := liveState(v.State) switch { case q.Key != "" && v.Key != q.Key, q.State == "open" && !open, diff --git a/modules/messenger/cmd/messenger/held_test.go b/modules/messenger/cmd/messenger/held_test.go index b21b916a3..b04abc95f 100644 --- a/modules/messenger/cmd/messenger/held_test.go +++ b/modules/messenger/cmd/messenger/held_test.go @@ -105,17 +105,17 @@ func TestAHeldAskExpiresAsAnyOpenAskDoes(t *testing.T) { } } -func TestAHeldAskCountsTowardTheOpenAsksOfItsAsker(t *testing.T) { - h, _, _, c, _, w := newRouter(t) +func TestAHeldAskCountsTowardTheOffersOfItsAskerAndTheOldestIsHeldBack(t *testing.T) { + h, _, _, c, store, w := newRouter(t) withoutVerifiedSender(h) for i := 1; i <= 4; i++ { a := heldAsk(c, "d-"+string(rune('0'+i))) a.About = "about-" + string(rune('0'+i)) submit(t, h, "mesh-delivery", a) + c.pass(time.Second) } - got := w.of("mesh-delivery") - if len(got) != 1 || got[0].Outcome != occ.OutcomeRefused || !strings.Contains(got[0].Words, "already has 3 open asks") { - t.Fatalf("%+v", got) + if got := w.of("mesh-delivery"); len(got) != 0 || store.asks["mesh-delivery.d-1"].State != StateHeldBack || store.asks["mesh-delivery.d-4"].State != StateHeld { + t.Fatalf("%+v: a fourth is taken, never refused; the oldest is held back (%s)", got, store.asks["mesh-delivery.d-1"].State) } } diff --git a/modules/messenger/cmd/messenger/holdback.go b/modules/messenger/cmd/messenger/holdback.go new file mode 100644 index 000000000..b9aa3a018 --- /dev/null +++ b/modules/messenger/cmd/messenger/holdback.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "sort" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" +) + +// An ask held back (novox/hq ADR 0666 §12, design 46 § 6): an asker has at most MostOpen asks offered at once; a fourth +// is offered and nothing is refused, so no ask is hidden: the asker's oldest offered ask is held back, its tickets +// withdrawn and its message edited to say it waits behind newer asks, and it is offered again, with new tickets, when +// one of the asker's offered asks ends. `held` is another state, an ask no ready channel can carry (ADR 0397). + +// StateHeldBack is an ask kept open behind newer asks of its asker. +const StateHeldBack = "held-back" + +// liveState is whether an ask has not ended: offered, held for want of a channel, or held back. +func liveState(state string) bool { + return state == StateOpen || state == StateHeld || state == StateHeldBack +} + +// behindLocked are the keys of the newer offered asks of the asker of rec, oldest first; h.mu is held. +func (h *Holder) behindLocked(key string, rec AskRecord) []string { + var behind []string + for k, e := range h.asks { + if k != key && e.rec.Asker == rec.Asker && (e.rec.State == StateOpen || e.rec.State == StateHeld) && e.rec.Opened.After(rec.Opened) { + behind = append(behind, k) + } + } + sort.Slice(behind, func(i, j int) bool { + a, b := h.asks[behind[i]].rec, h.asks[behind[j]].rec + if !a.Opened.Equal(b.Opened) { + return a.Opened.Before(b.Opened) + } + return behind[i] < behind[j] + }) + return behind +} + +// pickLocked is the key of the asker's asks in one of the states, the oldest first (by opening, then key); "" when +// there is none, never except; h.mu is held. +func (h *Holder) pickLocked(asker, except string, states ...string) string { + pick := "" + for k, e := range h.asks { + if e.rec.Asker != asker || k == except { + continue + } + in := false + for _, s := range states { + in = in || e.rec.State == s + } + if !in { + continue + } + if pick == "" || e.rec.Opened.Before(h.asks[pick].rec.Opened) || (e.rec.Opened.Equal(h.asks[pick].rec.Opened) && k < pick) { + pick = k + } + } + return pick +} + +// offeredCount is how many of the asker's asks are offered now: open, or held for want of a channel. +func (h *Holder) offeredCount(asker string) int { + h.mu.Lock() + defer h.mu.Unlock() + n := 0 + for _, e := range h.asks { + if e.rec.Asker == asker && (e.rec.State == StateOpen || e.rec.State == StateHeld) { + n++ + } + } + return n +} + +// freshAsk is an ask's record read again from the store, where there is one. +func (h *Holder) freshAsk(key string) (AskRecord, uint64, bool) { + e := h.askOf(key) + if e == nil { + return AskRecord{}, 0, false + } + rec, rev := e.rec, e.rev + if h.Asks != nil { + if got, r, err := h.Asks.GetAsk(key); err == nil && got != nil { + rec, rev = *got, r + } + } + return rec, rev, true +} + +// holdBackOldest holds back the asker's oldest offered ask other than except (the newest, just taken): its tickets +// are withdrawn, it stays open, and its own message is edited to say it waits. One held for want of a channel counts as offered +// and is held back the same way. The work lock is held. +func (h *Holder) holdBackOldest(asker, except string) { + h.mu.Lock() + key := h.pickLocked(asker, except, StateOpen, StateHeld) + h.mu.Unlock() + if key == "" { + return + } + rec, rev, ok := h.freshAsk(key) + if !ok || (rec.State != StateOpen && rec.State != StateHeld) { + return + } + now := h.Now() + rec.State, rec.HeldBackSince, rec.Tickets, rec.HeldSince, rec.HeldWhy = StateHeldBack, now, nil, time.Time{}, "" + if !h.saveHeld(key, rec, rev) { + return + } + h.Logf("[messenger] %s's ask %q (%s) is held back behind its newer asks: it is offered again when one ends", asker, rec.Ask.Headline, rec.Ask.ID) + h.mu.Lock() + r := h.open[rec.Record] + h.mu.Unlock() + if r == nil || r.Origin != "ask" { + return // a condition's message is the newer ask's to show + } + h.mu.Lock() + r.Answered = "Waits behind newer questions of " + asker + ".\nIt is offered again, with its answers, when one of them ends." + h.mu.Unlock() + h.edit(r, "answered", false) + h.persist(r) +} + +// offerHeldBack offers the asker's oldest held-back ask again when a slot is free: new tickets, its message edited back +// to the question. The work lock is held. +func (h *Holder) offerHeldBack(asker string) { + if h.offeredCount(asker) >= occ.MostOpen { + return + } + h.mu.Lock() + key := h.pickLocked(asker, "", StateHeldBack) + h.mu.Unlock() + if key != "" { + h.offerOneHeldBack(key) + } +} + +// offerOneHeldBack offers one held-back ask again now. +func (h *Holder) offerOneHeldBack(key string) { + rec, rev, ok := h.freshAsk(key) + if !ok || rec.State != StateHeldBack { + return + } + now := h.Now() + rec.HeldBackSince = time.Time{} + rec.Tickets = h.ticketsFor(rec.Ask) + rec.State = StateOpen + if len(rec.Tickets) == 0 { + rec.Tickets, rec.State, rec.HeldSince, rec.HeldWhy = nil, StateHeld, now, h.holdWhy(rec.Ask) + } + if !h.saveHeld(key, rec, rev) { + return + } + h.Logf("[messenger] %s's ask %q (%s) is offered again", rec.Asker, rec.Ask.Headline, rec.Ask.ID) + if rec.State != StateOpen { + return + } + h.mu.Lock() + r := h.open[rec.Record] + h.mu.Unlock() + if r != nil && r.Origin == "ask" { + h.mu.Lock() + r.Answered, r.Ask = "", key + h.mu.Unlock() + h.offerAsk(r) + h.persist(r) + return + } + rec.Record = h.recordFor(rec.Asker, key, rec.Ask) + h.showAsk(key, rec, now) +} + +// AskAgain is the seat operator-channel's verb ask-again (G §394, §450): offer a held-back ask now, holding back the +// asker's oldest offered ask in its place when the asker already has the most offered. It answers the ask's state after it, +// or the error that says why it was not offered. +func (h *Holder) AskAgain(key string) oc.AskAgainAnswer { + h.work.Lock() + defer h.work.Unlock() + h.mu.Lock() + h.init() + h.mu.Unlock() + rec, _, ok := h.freshAsk(key) + switch { + case !ok: + return oc.AskAgainAnswer{Error: fmt.Sprintf("this router holds no ask %q: its key is ., as operator-channel.asks lists", key)} + case rec.State != StateHeldBack: + return oc.AskAgainAnswer{Error: fmt.Sprintf("the ask %s is %s, not held back: only a held-back ask is offered again", key, rec.State)} + } + if h.offeredCount(rec.Asker) >= occ.MostOpen { + h.holdBackOldest(rec.Asker, key) + } + h.offerOneHeldBack(key) + got, _, _ := h.freshAsk(key) + return oc.AskAgainAnswer{State: string(got.State)} +} diff --git a/modules/messenger/cmd/messenger/holdback_test.go b/modules/messenger/cmd/messenger/holdback_test.go new file mode 100644 index 000000000..5b92f1d2f --- /dev/null +++ b/modules/messenger/cmd/messenger/holdback_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" +) + +// ADR 0666 §12, design 46 §6 and test 71: an asker has at most three asks offered at once; a fourth is offered and the +// oldest is held back (its tickets withdrawn, its message edited, its record open), offered again with new tickets +// when one of the three ends, offered now by messenger_ask_again; an ask with the same `about` replaces a held-back +// one, and a Decline ends an ask whose asker may ask again at once. +func TestNoAskIsHiddenTheOldestOfFourIsHeldBackAndOfferedAgain(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + ask := func(id, about string) { + a := ackAsk(c, id) + a.About = about + submit(t, h, "mesh-delivery", a) + c.pass(time.Second) + settle(h, c) + } + state := func(id string) string { return store.asks["mesh-delivery."+id].State } + for i, id := range []string{"d-1", "d-2", "d-3"} { + ask(id, "about-"+string(rune('1'+i))) + } + oldTicket := "" + for tk := range store.asks["mesh-delivery.d-1"].Tickets { + oldTicket = tk + } + ask("d-4", "about-4") + // M1: the fourth reaches the channel, the oldest is held back, none is refused. + if len(w.of("mesh-delivery")) != 0 || state("d-4") != StateOpen || state("d-1") != StateHeldBack { + t.Fatalf("refused %+v; d-4 %s, d-1 %s", w.of("mesh-delivery"), state("d-4"), state("d-1")) + } + if rec := store.asks["mesh-delivery.d-1"]; len(rec.Tickets) != 0 || rec.HeldBackSince.IsZero() { + t.Errorf("held back %+v: tickets withdrawn, since set", rec) + } + if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Note, "Waits behind newer questions of mesh-delivery") { + t.Errorf("its message was not edited to say it waits: %v %+v", ok, m) + } + if _, err := h.Chosen("telegram", choiceOf(oldTicket, "42", c.now())); err == nil || !strings.Contains(err.Error(), "waits behind newer questions") || len(w.of("mesh-delivery")) != 0 { + t.Errorf("a tap of a withdrawn ticket: %v %+v", err, w.of("mesh-delivery")) + } + // M3: asks lists it with since when and the newer asks it waits behind; open includes it. + q, _ := AsksQueryOf(map[string]any{"key": "mesh-delivery.d-1"}) + raw, _ := json.Marshal(q.Narrow(h.AsksNow())) + for _, want := range []string{`"state":"held-back"`, `"held-since":"`, `"behind":["mesh-delivery.d-2","mesh-delivery.d-3","mesh-delivery.d-4"]`} { + if !strings.Contains(string(raw), want) { + t.Errorf("asks lacks %s: %s", want, raw) + } + } + if q, _ = AsksQueryOf(nil); !strings.Contains(string(mustJSON(q.Narrow(h.AsksNow()))), "mesh-delivery.d-1") { + t.Error("the open asks leave out the held-back one") + } + // M5: an ask with the same about replaces a held-back one; the three offered stay three, so the oldest of them is held back. + ask("d-5", "about-1") + if state("d-1") != string(occ.OutcomeReplaced) || state("d-2") != StateHeldBack || state("d-5") != StateOpen || state("d-3") != StateOpen { + t.Fatalf("after d-5 about the held-back ask's thing: d-1 %s d-2 %s d-3 %s d-5 %s", state("d-1"), state("d-2"), state("d-3"), state("d-5")) + } + // M2: when one of the offered ends, the oldest held back is offered again with new tickets and its message asks again. + if a := h.AskAgain("mesh-delivery.d-3"); !strings.Contains(a.Error, "not held back") { + t.Errorf("ask-again of an offered ask: %+v", a) + } + if err := h.TakeAsk("cancel", "mesh-delivery", []byte(`{"id":"d-3"}`)); err != nil { + t.Fatal(err) + } + settle(h, c) + if rec := store.asks["mesh-delivery.d-2"]; rec.State != StateOpen || len(rec.Tickets) == 0 || !rec.HeldBackSince.IsZero() { + t.Fatalf("d-2 after d-3 ended: %+v", rec) + } + if m := tg.edits["2"]; len(m.Actions) == 0 || strings.Contains(m.Note, "Waits behind") { + t.Errorf("its message does not ask again: %+v", m) + } + // M4: ask-again by hand offers a held-back ask now, holding back the oldest offered one; the seat operator-channel's ask-again (G §450). + ask("d-6", "about-6") + ask("d-7", "about-7") + held := "" + for _, id := range []string{"d-2", "d-4", "d-5", "d-6", "d-7"} { + if state(id) == StateHeldBack { + held = id + } + } + if held == "" { + t.Fatal("no ask is held back after two more") + } + if a := h.AskAgain("mesh-delivery.d-none"); a.Error == "" || a.State != "" { + t.Errorf("ask-again of an ask the router holds none of: %+v", a) + } + if a := h.AskAgain("mesh-delivery." + held); a.Error != "" || a.State != string(StateOpen) || state(held) != StateOpen { + t.Fatalf("%s is %s; ask-again answered %+v", held, state(held), a) + } + n := 0 + for _, id := range []string{"d-2", "d-4", "d-5", "d-6", "d-7"} { + if state(id) == StateHeldBack { + n++ + } + } + if n != 2 || state("d-5") != StateHeldBack { + t.Errorf("%d asks held back after ask-again (d-5 %s): the oldest offered one took its place", n, state("d-5")) + } + // A Decline ends an ask; its asker may ask again at once, and nothing is refused. + for _, wt := range w.of("mesh-delivery") { + if wt.Outcome == occ.OutcomeRefused { + t.Errorf("refused: %+v", wt) + } + } +} + +func mustJSON(v any) []byte { raw, _ := json.Marshal(v); return raw } diff --git a/modules/messenger/cmd/messenger/main.go b/modules/messenger/cmd/messenger/main.go index fd4654725..e115da0f1 100644 --- a/modules/messenger/cmd/messenger/main.go +++ b/modules/messenger/cmd/messenger/main.go @@ -741,7 +741,7 @@ func tools(h *Holder, l *listening) []stdio.Tool { "most bytes of the answer.", Input: map[string]any{ "key": str("one ask, by its key (asker.id); none for the open asks"), - "state": str("open (open and held), ended or all; none is open when no key or since is given, else both"), + "state": str("open (open, held and held-back), ended or all; none is open when no key or since is given, else both"), "since": str("only the asks opened at or after this time, RFC 3339"), "bound": map[string]any{"type": "integer", "description": "the most bytes of the asks in the answer; said when it cuts the answer short"}}, Run: func(a map[string]any) (any, error) { @@ -751,6 +751,15 @@ func tools(h *Holder, l *listening) []stdio.Tool { } return q.Narrow(h.AsksNow()), nil }}, + {Name: "operator-channel." + oc.VerbAskAgain, + Description: "Offer a held-back ask again now (G §394, §450): an asker has at most three asks offered at once, and a fourth " + + "holds its oldest back behind it (operator-channel.asks lists it with state held-back, since when and the newer asks it waits behind). " + + "It is offered again by itself when one of the asker's asks ends; this offers it now, holding back the asker's oldest offered ask in its " + + "place. Answers the ask's state, or why it was not offered (an ask that is not held back).", + Input: map[string]any{"key": str("the held-back ask, by its key (asker.id)")}, + Run: func(a map[string]any) (any, error) { + return h.AskAgain(strArg(a, "key")), nil + }}, {Name: "messenger_identities", Description: "The accounts linked as the operator, one per channel kind: the account, the name its " + "service shows, when it was linked, and from when it can approve (an hour after linking).", diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index ffb17331b..354ed6f63 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -68,7 +68,7 @@ func readManifest(t *testing.T) (manifest, string) { func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { // 1217/AC6: condition beside ask and cancel m, _ := readManifest(t) - verbs := []string{"open", "history", "notify", "tell", "asks"} + verbs := []string{"open", "history", "notify", "tell", "asks", oc.VerbAskAgain} want := []seat{ {Name: oc.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"}, ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}}, diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index 00ad0328e..2d2a82112 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -3,6 +3,7 @@ module messenger go 1.26.0 require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 @@ -12,6 +13,7 @@ require ( ) require ( + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.1 // indirect diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 7fb4aba5c..c66ff9074 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -12,7 +12,8 @@ "history", "notify", "tell", - "asks" + "asks", + "ask-again" ], "accepts": [ "ask", @@ -66,7 +67,8 @@ "history", "notify", "tell", - "asks" + "asks", + "ask-again" ] } ],