Let an agent session ask the operator on Telegram and read the answer (issue 589, hq ADR 0332)
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request
A session that needs a decision could only ask in a terminal the operator may not watch. claude_code_ask raises the question under this module's name at the level approve, so only a channel that proves who answered carries it; claude_code_answer reads the chosen option back by its key from the router's own record, held to the question kept in the new questions state.
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
package main
|
||||
|
||||
// A question an agent session asks the operator, answered on a channel that proves who answered (novox/hq ADR 0332,
|
||||
// issue 589).
|
||||
//
|
||||
// A session that needs a yes, a no or a choice asks it with claude_code_ask instead of waiting at a terminal the
|
||||
// operator may not be watching. The ask is published under this module's name on the operator channel (ADR 0259):
|
||||
// the bus grants that subject to this module alone, so the asker is proven by the bus, and the router stays the judge
|
||||
// of answers, never an asker for others. Every option is at the level approve, so only a channel that proves who
|
||||
// answered (Telegram, today) carries it, and each option binds the digest of the question and of itself.
|
||||
//
|
||||
// The session reads the answer by the question's key with claude_code_answer: from the router's own record of the
|
||||
// ask, which only the router writes and only this module reads, held to the question as the `questions` state keeps
|
||||
// it (asks.Warrant.For and asks.Option.Performs). A record that does not hold is said as not proven, never as a
|
||||
// choice. An answer informs the session; it authorises nothing (ADR 0286 stays the way to an approved call).
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// QuestionsState is the module's state the questions are kept in.
|
||||
const QuestionsState = "questions"
|
||||
|
||||
// QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's.
|
||||
const QuestionPrefix = "q-"
|
||||
|
||||
// MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks
|
||||
// (asks.MostOpen), and one is kept for a proposed instruction section (ADR 0274), so a proposal always finds room.
|
||||
const MostQuestions = asks.MostOpen - 1
|
||||
|
||||
// The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the
|
||||
// router's bound for an approval, with the margin a proposal keeps), an hour when none is given.
|
||||
const (
|
||||
QuestionShortest = 5 * time.Minute
|
||||
QuestionLongest = ProposalLasts
|
||||
QuestionDefault = time.Hour
|
||||
)
|
||||
|
||||
// The bounds of what a question says.
|
||||
const (
|
||||
FewestQuestionOptions = 2
|
||||
MostContextBytes = 1500
|
||||
MostMeansBytes = 200
|
||||
MostWhyBytes = 300
|
||||
MostOnExpiryBytes = 200
|
||||
)
|
||||
|
||||
// How long one claude_code_answer call may wait for the answer, and how often it looks meanwhile.
|
||||
var (
|
||||
AnswerWaitsAtMost = 60 * time.Second
|
||||
AnswerLooksEvery = 2 * time.Second
|
||||
)
|
||||
|
||||
// QuestionOption is one answer a session offers.
|
||||
type QuestionOption struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Means string `json:"means"`
|
||||
Recommended bool `json:"recommended,omitempty"`
|
||||
}
|
||||
|
||||
// Question is one question, as the `questions` state keeps it. Every field the ask is composed from is here, so the
|
||||
// answer is held to exactly the question asked.
|
||||
type Question struct {
|
||||
ID string `json:"id"`
|
||||
Headline string `json:"headline"`
|
||||
Context string `json:"context"`
|
||||
Options []QuestionOption `json:"options"`
|
||||
Why string `json:"why"`
|
||||
OnExpiry string `json:"on-expiry"`
|
||||
By string `json:"by"`
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
Expires time.Time `json:"expires"`
|
||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||
Asked string `json:"asked,omitempty"`
|
||||
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
||||
// leaves no record of the router's to read it from. It never makes a choice.
|
||||
Ended string `json:"ended,omitempty"`
|
||||
}
|
||||
|
||||
// recommended is the option marked as recommended.
|
||||
func (q Question) recommended() (QuestionOption, bool) {
|
||||
for _, o := range q.Options {
|
||||
if o.Recommended {
|
||||
return o, true
|
||||
}
|
||||
}
|
||||
return QuestionOption{}, false
|
||||
}
|
||||
|
||||
// Act is what an answer binds: this question, whole, and the option chosen.
|
||||
func (q Question) Act(option string) asks.Act {
|
||||
act := asks.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline,
|
||||
"context": q.Context, "why": q.Why, "on-expiry": q.OnExpiry, "by": q.By, "from": q.From,
|
||||
"at": q.At.UTC().Format(time.RFC3339Nano), "expires": q.Expires.UTC().Format(time.RFC3339Nano),
|
||||
"options": strconv.Itoa(len(q.Options))}
|
||||
for i, o := range q.Options {
|
||||
act[fmt.Sprintf("option.%d", i)] = o.ID + "\n" + o.Label + "\n" + o.Means + "\n" + strconv.FormatBool(o.Recommended)
|
||||
}
|
||||
return act
|
||||
}
|
||||
|
||||
// Ask is the ask for a question, composed from it alone.
|
||||
func (q Question) Ask() asks.Ask {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.TrimSpace(q.Context))
|
||||
if rec, ok := q.recommended(); ok {
|
||||
fmt.Fprintf(&b, "\n\nRecommended: %s, because %s", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
|
||||
b.WriteString(".")
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nAsked by the agent session %s on %s through the claude-code module, at %s UTC. "+
|
||||
"Your answer is told to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
|
||||
a := asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: asks.Operator, Expires: q.Expires,
|
||||
OnExpiry: q.OnExpiry}
|
||||
for _, o := range q.Options {
|
||||
does := o.Means
|
||||
if o.Recommended {
|
||||
does = "Recommended. " + does
|
||||
}
|
||||
binds, _ := asks.ActDigest(q.Act(o.ID))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
var sessionName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$`)
|
||||
|
||||
// QuestionProblem says why a question cannot be asked, or "": every bound of ADR 0332 §2 that this module holds
|
||||
// itself. The words are held to the content rule by the router, which says its refusal back.
|
||||
func QuestionProblem(q Question) string {
|
||||
var problems []string
|
||||
add := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
|
||||
switch {
|
||||
case strings.TrimSpace(q.Headline) == "":
|
||||
add("it has no headline")
|
||||
case len([]rune(q.Headline)) > asks.HeadlineLength:
|
||||
add("its headline is %d characters, more than %d", len([]rune(q.Headline)), asks.HeadlineLength)
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(q.Context) == "":
|
||||
add("it gives no context: say in plain words what the operator decides and why it matters")
|
||||
case len(q.Context) > MostContextBytes:
|
||||
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
|
||||
}
|
||||
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
||||
}
|
||||
recommended, labels := 0, map[string]bool{}
|
||||
for i, o := range q.Options {
|
||||
switch {
|
||||
case strings.TrimSpace(o.Label) == "":
|
||||
add("option %d has no label", i+1)
|
||||
case len([]rune(o.Label)) > asks.LabelLength:
|
||||
add("option %d's label is longer than %d characters", i+1, asks.LabelLength)
|
||||
case labels[strings.ToLower(o.Label)]:
|
||||
add("two options are labelled %q", o.Label)
|
||||
}
|
||||
labels[strings.ToLower(o.Label)] = true
|
||||
if strings.TrimSpace(o.Means) == "" {
|
||||
add("option %d does not say what choosing it means", i+1)
|
||||
} else if len(o.Means) > MostMeansBytes {
|
||||
add("what option %d means is longer than %d bytes", i+1, MostMeansBytes)
|
||||
}
|
||||
if !asks.UsableID(o.ID) {
|
||||
add("option %d's id is not one", i+1)
|
||||
}
|
||||
if o.Recommended {
|
||||
recommended++
|
||||
}
|
||||
}
|
||||
if recommended != 1 {
|
||||
add("%d options are marked recommended; exactly one is", recommended)
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(q.Why) == "":
|
||||
add("it does not say why the recommended option is recommended")
|
||||
case len(q.Why) > MostWhyBytes:
|
||||
add("why the option is recommended is longer than %d bytes", MostWhyBytes)
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(q.OnExpiry) == "":
|
||||
add("it does not say what the session does if nobody answers")
|
||||
case len(q.OnExpiry) > MostOnExpiryBytes:
|
||||
add("what happens if nobody answers is longer than %d bytes", MostOnExpiryBytes)
|
||||
}
|
||||
if !sessionName.MatchString(q.By) {
|
||||
add("who asks is not a session's name: letters, digits, - and _, such as fix-589")
|
||||
}
|
||||
if !nodeName.MatchString(q.From) {
|
||||
add("where it comes from is not a node's name")
|
||||
}
|
||||
if life := q.Expires.Sub(q.At); life < QuestionShortest || life > QuestionLongest {
|
||||
add("it lasts %s; a question lasts %s to %s", life.Round(time.Minute), QuestionShortest, QuestionLongest)
|
||||
}
|
||||
for _, text := range []string{q.Headline, q.Context, q.Why, q.OnExpiry} {
|
||||
if what := invisible(text); what != "" {
|
||||
add("its words hold %s, which a phone does not show", what)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(problems, "; ")
|
||||
}
|
||||
|
||||
func newQuestionID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return QuestionPrefix + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// QuestionInput is what a session asks.
|
||||
type QuestionInput struct {
|
||||
Headline string
|
||||
Context string
|
||||
Options []QuestionOption
|
||||
Why string
|
||||
OnExpiry string
|
||||
By string
|
||||
Expires time.Duration
|
||||
}
|
||||
|
||||
// AskQuestion keeps a question and asks the operator. It answers at once with the question's key; the answer is read
|
||||
// with ReadAnswer.
|
||||
func AskQuestion(p Paths, in QuestionInput, store ProposalStore, bus AskBus, now time.Time) (map[string]any, error) {
|
||||
refuse := func(why string) (map[string]any, error) { return nil, errors.New(why + ". Nothing was asked") }
|
||||
by := strings.TrimSpace(in.By)
|
||||
if who, node, found := strings.Cut(by, "@"); found {
|
||||
if node != p.Node {
|
||||
return refuse(fmt.Sprintf("who asks names the node %q, and this is %s: ask on the node the session runs on", node, p.Node))
|
||||
}
|
||||
by = who
|
||||
}
|
||||
if by == "" {
|
||||
by = "unnamed"
|
||||
}
|
||||
life := in.Expires
|
||||
if life == 0 {
|
||||
life = QuestionDefault
|
||||
}
|
||||
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
||||
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
||||
Expires: now.UTC().Add(life)}
|
||||
for i, o := range in.Options {
|
||||
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
||||
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
||||
}
|
||||
if problem := QuestionProblem(q); problem != "" {
|
||||
return refuse(problem)
|
||||
}
|
||||
if open := openQuestions(store, bus, now); len(open) >= MostQuestions {
|
||||
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d at "+
|
||||
"once so a proposed instruction section always finds room; read them with claude_code_answer, or wait for "+
|
||||
"one to end", len(open), strings.Join(open, ", "), MostQuestions))
|
||||
}
|
||||
ask := q.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
if err := store.Create(q.ID, q); err != nil {
|
||||
return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err)
|
||||
}
|
||||
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
q.Asked = "not asked: " + err.Error()
|
||||
_ = store.Put(q.ID, q)
|
||||
return nil, fmt.Errorf("the operator could not be asked: %v", err)
|
||||
}
|
||||
say("question %s from the session %s asked of the operator", q.ID, q.By)
|
||||
taken := "the router has not said yet whether it took the question; claude_code_answer says"
|
||||
defer forgetQuestionEnd(q.ID)
|
||||
for deadline := time.Now().Add(RouterAnswersWithin); time.Now().Before(deadline); {
|
||||
if w, ended := questionEnd(q.ID); ended {
|
||||
return nil, fmt.Errorf("the router did not ask the operator: the question %s (%s)", w.Outcome, w.Words)
|
||||
}
|
||||
if entry, err := bus.Record(q.ID); err == nil && entry != nil {
|
||||
taken = "the router took the question and shows it where who answers is proven (Telegram)"
|
||||
break
|
||||
}
|
||||
time.Sleep(RouterAnswersEvery)
|
||||
}
|
||||
return map[string]any{
|
||||
"key": q.ID,
|
||||
"router": taken,
|
||||
"asked": "\"" + q.Headline + "\", with the answers " + labelsOf(q),
|
||||
"expires": q.Expires.Format(time.RFC3339),
|
||||
"next": "read the answer with claude_code_answer {\"key\": \"" + q.ID + "\"} (it may wait up to a minute); " +
|
||||
"keep working meanwhile, and if it expires do what you said: " + q.OnExpiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func labelsOf(q Question) string {
|
||||
var out []string
|
||||
for _, o := range q.Options {
|
||||
out = append(out, o.Label)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// openQuestions are the keys of the questions still waiting: asked, not ended, not expired.
|
||||
func openQuestions(store ProposalStore, bus AskBus, now time.Time) []string {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var open []string
|
||||
for _, k := range keys {
|
||||
q, ok := readQuestion(store, k)
|
||||
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
|
||||
continue
|
||||
}
|
||||
if entry, err := bus.Record(q.ID); err == nil && entry != nil {
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) == nil && rec.State != "" && rec.State != "open" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
open = append(open, q.ID)
|
||||
}
|
||||
sort.Strings(open)
|
||||
return open
|
||||
}
|
||||
|
||||
func readQuestion(store ProposalStore, key string) (Question, bool) {
|
||||
if !strings.HasPrefix(key, QuestionPrefix) {
|
||||
return Question{}, false
|
||||
}
|
||||
raw, found, err := store.Get(key)
|
||||
if err != nil || !found {
|
||||
return Question{}, false
|
||||
}
|
||||
var q Question
|
||||
if json.Unmarshal(raw, &q) != nil || q.ID != key {
|
||||
return Question{}, false
|
||||
}
|
||||
return q, true
|
||||
}
|
||||
|
||||
// The router's word that a question ended without a choice, as heard on the decided subject.
|
||||
var (
|
||||
questionEndsMu sync.Mutex
|
||||
questionEnds = map[string]asks.Warrant{}
|
||||
)
|
||||
|
||||
func questionEnd(id string) (asks.Warrant, bool) {
|
||||
questionEndsMu.Lock()
|
||||
defer questionEndsMu.Unlock()
|
||||
w, ok := questionEnds[id]
|
||||
return w, ok
|
||||
}
|
||||
|
||||
func forgetQuestionEnd(id string) {
|
||||
questionEndsMu.Lock()
|
||||
delete(questionEnds, id)
|
||||
questionEndsMu.Unlock()
|
||||
}
|
||||
|
||||
// HeardQuestionEnd takes the router's word that a question ended without a choice — refused, expired, cancelled or
|
||||
// replaced — and keeps it on the question, because a refused ask leaves no record of the router's. A choice is
|
||||
// never taken from this word: only from the router's record, held to the question (ReadAnswer).
|
||||
func HeardQuestionEnd(store ProposalStore, w asks.Warrant) {
|
||||
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) {
|
||||
return
|
||||
}
|
||||
q, known := readQuestion(store, w.Ask)
|
||||
if !known {
|
||||
return
|
||||
}
|
||||
if q.Ended == "" {
|
||||
q.Ended = string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
q.Ended += ": " + w.Words
|
||||
}
|
||||
_ = store.Put(q.ID, q)
|
||||
}
|
||||
// Kept on the question first, then said to a session waiting on it: what it reads next is already there.
|
||||
questionEndsMu.Lock()
|
||||
questionEnds[w.Ask] = w
|
||||
questionEndsMu.Unlock()
|
||||
}
|
||||
|
||||
// The answer's states, as claude_code_answer says them.
|
||||
const (
|
||||
AnswerWaiting = "waiting"
|
||||
AnswerChosen = "chosen"
|
||||
AnswerEnded = "ended"
|
||||
AnswerNotProven = "not proven"
|
||||
)
|
||||
|
||||
// ReadAnswer is where a question stands: waiting, chosen (and what), ended without a choice, or not proven.
|
||||
func ReadAnswer(store ProposalStore, bus AskBus, key string, now time.Time) (map[string]any, error) {
|
||||
q, ok := readQuestion(store, strings.TrimSpace(key))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no question %q is kept: the key is the one claude_code_ask gave", key)
|
||||
}
|
||||
out := map[string]any{"key": q.ID, "headline": q.Headline, "asked": q.At.Format(time.RFC3339),
|
||||
"expires": q.Expires.Format(time.RFC3339)}
|
||||
if q.Asked != "" {
|
||||
out["state"], out["words"] = AnswerEnded, q.Asked
|
||||
return out, nil
|
||||
}
|
||||
entry, err := bus.Record(q.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the router's record of the question could not be read now (%v); ask again", err)
|
||||
}
|
||||
var rec routerRecord
|
||||
if entry != nil {
|
||||
if err := json.Unmarshal(entry.Value, &rec); err != nil {
|
||||
out["state"], out["words"] = AnswerNotProven, "the router's record of it cannot be read: "+err.Error()
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case entry == nil && q.Ended != "":
|
||||
out["state"], out["words"] = AnswerEnded, q.Ended
|
||||
case entry == nil && now.After(q.Expires.Add(time.Hour)):
|
||||
out["state"], out["words"] = AnswerEnded, "the router keeps no record of it: refused before it was taken, or forgotten"
|
||||
case entry == nil, rec.State == "open", rec.Warrant == nil:
|
||||
out["state"] = AnswerWaiting
|
||||
if q.Ended != "" {
|
||||
out["words"] = "the router said it ended (" + q.Ended + "), and its record says it is open; read again"
|
||||
}
|
||||
default:
|
||||
w := *rec.Warrant
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
out["state"], out["words"] = AnswerEnded, string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
out["words"] = string(w.Outcome) + ": " + w.Words
|
||||
}
|
||||
if w.Outcome == asks.OutcomeExpired {
|
||||
out["now"] = "do what you said: " + q.OnExpiry
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if problem := QuestionProblem(q); problem != "" {
|
||||
out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem
|
||||
return out, nil
|
||||
}
|
||||
option, err := w.For(Module, q.Ask())
|
||||
if err == nil {
|
||||
err = option.Performs(q.Act(option.ID))
|
||||
}
|
||||
if err != nil {
|
||||
say("REFUSED the answer to question %s: %v", q.ID, err)
|
||||
out["state"], out["words"] = AnswerNotProven, "the router's record does not hold to the question asked: "+err.Error()
|
||||
return out, nil
|
||||
}
|
||||
chosen := map[string]any{"id": option.ID, "label": option.Label}
|
||||
for _, o := range q.Options {
|
||||
if o.ID == option.ID {
|
||||
chosen["means"], chosen["recommended"] = o.Means, o.Recommended
|
||||
}
|
||||
}
|
||||
out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, w.Says(), w.Channel,
|
||||
w.At.Format(time.RFC3339)
|
||||
out["note"] = "the operator's word to this session; it authorises no call that needs the operator's approval"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WaitForAnswer reads the answer, and while it is waiting reads it again until wait has passed.
|
||||
func WaitForAnswer(store ProposalStore, bus AskBus, key string, wait time.Duration, now func() time.Time) (map[string]any, error) {
|
||||
if wait > AnswerWaitsAtMost {
|
||||
wait = AnswerWaitsAtMost
|
||||
}
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
out, err := ReadAnswer(store, bus, key, now())
|
||||
if err != nil || out["state"] != AnswerWaiting || !time.Now().Before(deadline) {
|
||||
return out, err
|
||||
}
|
||||
time.Sleep(AnswerLooksEvery)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user