The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
666 lines
26 KiB
Go
666 lines
26 KiB
Go
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"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"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 questions alone never fill them, so one is left for a proposed instruction section (ADR 0274)
|
|
// while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by
|
|
// create or by compare-and-set, so two nodes asking at once cannot both take the last one.
|
|
const MostQuestions = asks.MostOpen - 1
|
|
|
|
// A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires:
|
|
// the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can
|
|
// take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it
|
|
// cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it
|
|
// when it is taken (asks.Ask.Check), so the slot is free and the question has ended.
|
|
|
|
// slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it.
|
|
func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) }
|
|
|
|
// slot is what a slot key holds: the question holding it.
|
|
type slot struct {
|
|
Question string `json:"question"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// 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 = asks.SummaryLength
|
|
MostMeansBytes = 200
|
|
MostWhyBytes = 120
|
|
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"`
|
|
}
|
|
|
|
// QuestionStore is the `questions` state: a value with its revision, created only when absent, updated only at the
|
|
// revision read.
|
|
type QuestionStore interface {
|
|
Get(key string) (json.RawMessage, uint64, bool, error)
|
|
Create(key string, value any) error
|
|
Update(key string, value any, revision uint64) error
|
|
Put(key string, value any) error
|
|
}
|
|
|
|
// keptQuestions is the `questions` state on the bus.
|
|
type keptQuestions struct{ s stdio.KeptState }
|
|
|
|
func (k keptQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
e, err := k.s.Get(key)
|
|
if err != nil || e == nil {
|
|
return nil, 0, false, err
|
|
}
|
|
return e.Value, e.Revision, true, nil
|
|
}
|
|
func (k keptQuestions) Create(key string, value any) error {
|
|
_, err := k.s.Create(key, value)
|
|
return err
|
|
}
|
|
func (k keptQuestions) Update(key string, value any, revision uint64) error {
|
|
_, err := k.s.Update(key, value, revision)
|
|
return err
|
|
}
|
|
func (k keptQuestions) Put(key string, value any) error { _, err := k.s.Put(key, value); return err }
|
|
|
|
// 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"`
|
|
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
|
|
Form int `json:"form,omitempty"`
|
|
// 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, in the form it was asked in.
|
|
func (q Question) Ask() asks.Ask {
|
|
if q.Form == AskGlance {
|
|
return q.glanceAsk()
|
|
}
|
|
return q.listedAsk()
|
|
}
|
|
|
|
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
|
|
// answer and why as its one point; who asked and what the answer does are its Details.
|
|
func (q Question) glanceAsk() asks.Ask {
|
|
explanation := strings.TrimSpace(q.Context)
|
|
if rec, ok := q.recommended(); ok {
|
|
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
|
|
}
|
|
details := fmt.Sprintf("Asked 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"))
|
|
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
|
|
}
|
|
|
|
func (q Question) withOptions(a asks.Ask) asks.Ask {
|
|
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, 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
|
|
}
|
|
|
|
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
|
|
func (q Question) listedAsk() 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([]rune(q.Context)) > MostContextBytes:
|
|
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
|
|
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
|
|
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
|
|
}
|
|
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)
|
|
}
|
|
if problem := questionWordsProblem(q); problem != "" {
|
|
add("%s", problem)
|
|
}
|
|
if len(problems) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(problems, "; ")
|
|
}
|
|
|
|
// questionWordsProblem says why a question's words may not be kept at all, or "": anything shaped like a secret, or a
|
|
// character a phone does not show, in any word the operator is shown — the headline, the context, why, what happens
|
|
// on expiry, and each option's label and meaning. Judged before anything is kept: the `questions` state is read by
|
|
// every node, and the router judges the content rule only after the question was kept.
|
|
func questionWordsProblem(q Question) string {
|
|
fields := []struct{ name, text string }{{"the headline", q.Headline}, {"the context", q.Context}, {"why", q.Why},
|
|
{"what happens on expiry", q.OnExpiry}}
|
|
for i, o := range q.Options {
|
|
fields = append(fields, struct{ name, text string }{fmt.Sprintf("option %d's label", i+1), o.Label},
|
|
struct{ name, text string }{fmt.Sprintf("what option %d means", i+1), o.Means})
|
|
}
|
|
for _, f := range fields {
|
|
for _, re := range secretShaped {
|
|
if re.MatchString(f.text) {
|
|
return f.name + " holds something shaped like a secret, which is never kept or shown on a phone"
|
|
}
|
|
}
|
|
if what := invisible(f.text); what != "" {
|
|
return f.name + " holds " + what + ", which a phone does not show"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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 within seconds, once the router has said whether it
|
|
// took the question, with the question's key; the answer is read with ReadAnswer.
|
|
func AskQuestion(p Paths, in QuestionInput, store QuestionStore, 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 == "" {
|
|
return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14")
|
|
}
|
|
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), Form: AskGlance}
|
|
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)
|
|
}
|
|
ask := q.Ask()
|
|
if err := ask.Check(now); err != nil {
|
|
return refuse(err.Error())
|
|
}
|
|
// The question is kept before its slot is taken, so a slot always names a question another node can judge.
|
|
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 open, err := takeSlot(store, bus, q, now); err != nil {
|
|
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
|
|
return refuse(err.Error())
|
|
} else if open != nil {
|
|
_ = store.Put(q.ID, tombstone(q, "not asked: two questions were open"))
|
|
return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+
|
|
"at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+
|
|
"claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions))
|
|
}
|
|
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
|
_ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error()))
|
|
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, ", ")
|
|
}
|
|
|
|
// questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's
|
|
// record or not recorded yet. An unreadable record is open: a slot is never freed on a guess.
|
|
func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) {
|
|
q, ok, err := readQuestionErr(store, id)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) {
|
|
return false, nil
|
|
}
|
|
entry, err := bus.Record(q.ID)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
if entry == nil {
|
|
return true, nil
|
|
}
|
|
var rec routerRecord
|
|
if json.Unmarshal(entry.Value, &rec) != nil {
|
|
return true, nil
|
|
}
|
|
return rec.State == "" || rec.State == "open", nil
|
|
}
|
|
|
|
// takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer
|
|
// waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries
|
|
// the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a
|
|
// slot or its question cannot be read.
|
|
func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) {
|
|
mine := slot{Question: q.ID, At: q.At}
|
|
var open []string
|
|
for n := 1; n <= MostQuestions; n++ {
|
|
key := slotKey(n)
|
|
raw, rev, found, err := store.Get(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
|
}
|
|
if !found {
|
|
if err := store.Create(key, mine); err == nil {
|
|
return nil, nil
|
|
} else if !errors.Is(err, stdio.ErrChanged) {
|
|
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
|
}
|
|
if raw, rev, found, err = store.Get(key); err != nil || !found {
|
|
return nil, fmt.Errorf("the open questions could not be counted, so none more is asked")
|
|
}
|
|
}
|
|
var held slot
|
|
if json.Unmarshal(raw, &held) == nil && held.Question != "" {
|
|
waits, err := questionOpen(store, bus, held.Question, now)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
|
}
|
|
if waits {
|
|
open = append(open, held.Question)
|
|
continue
|
|
}
|
|
}
|
|
if err := store.Update(key, mine, rev); err == nil {
|
|
return nil, nil
|
|
} else if !errors.Is(err, stdio.ErrChanged) {
|
|
return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err)
|
|
}
|
|
open = append(open, "one taken just now")
|
|
}
|
|
sort.Strings(open)
|
|
return open, nil
|
|
}
|
|
|
|
// tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and
|
|
// why it ended — none of its words, which the router judged unfit to leave the mesh or never saw.
|
|
func tombstone(q Question, ended string) Question {
|
|
return Question{ID: q.ID, By: q.By, From: q.From, At: q.At, Expires: q.Expires, Ended: ended}
|
|
}
|
|
|
|
func readQuestion(store QuestionStore, key string) (Question, bool) {
|
|
q, ok, _ := readQuestionErr(store, key)
|
|
return q, ok
|
|
}
|
|
|
|
func readQuestionErr(store QuestionStore, key string) (Question, bool, error) {
|
|
if !strings.HasPrefix(key, QuestionPrefix) {
|
|
return Question{}, false, nil
|
|
}
|
|
raw, _, found, err := store.Get(key)
|
|
if err != nil || !found {
|
|
return Question{}, false, err
|
|
}
|
|
var q Question
|
|
if json.Unmarshal(raw, &q) != nil || q.ID != key {
|
|
return Question{}, false, nil
|
|
}
|
|
return q, true, nil
|
|
}
|
|
|
|
// 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 refused question
|
|
// keeps none of its words (tombstone). A choice is never taken from this word: only from the router's record, held to
|
|
// the question (ReadAnswer).
|
|
func HeardQuestionEnd(store QuestionStore, 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 == "" {
|
|
ended := string(w.Outcome)
|
|
if w.Words != "" {
|
|
ended += ": " + w.Words
|
|
}
|
|
if w.Outcome == asks.OutcomeRefused {
|
|
q = tombstone(q, ended)
|
|
} else {
|
|
q.Ended = ended
|
|
}
|
|
_ = 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 QuestionStore, 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.Before(q.Expires):
|
|
out["state"], out["words"] = AnswerEnded, "expired without the router recording it: refused (its refusal was not "+
|
|
"heard here) or never taken; the router refuses it now if it is taken late"
|
|
out["now"] = "do what you said: " + q.OnExpiry
|
|
case entry == nil:
|
|
out["state"] = AnswerWaiting
|
|
out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " +
|
|
"refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " +
|
|
q.Expires.Format(time.RFC3339)
|
|
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 QuestionStore, 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)
|
|
}
|
|
}
|