From df3203a820f59c372bd8066f01eb1818b90d9c25 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 16:45:04 -0400 Subject: [PATCH 1/7] =?UTF-8?q?questions:=20an=20answer=20is=20a=20message?= =?UTF-8?q?=20=E2=80=94=20keep=20working=20while=20asking,=20a=20countdown?= =?UTF-8?q?=20you=20can=20stop,=20and=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../unreleased/914-answers-are-messages.md | 25 + docs/design/questions/DESIGN.md | 125 +++- internal/e2e/tuiwords_test.go | 2 +- internal/manual/chat/permissions.md | 20 + internal/manual/chat/questions.md | 114 +++- internal/manual/chat_test.go | 11 + internal/remote/callclass.go | 2 +- internal/remote/questionlane.go | 13 + internal/remote/server.go | 15 + internal/remote/wire.go | 40 +- internal/session/answers.go | 12 + internal/session/answers_test.go | 5 +- internal/session/asklane.go | 445 ++++++++++++++ internal/session/asklane_test.go | 565 ++++++++++++++++++ internal/session/askwait.go | 125 +++- internal/session/autonomy.go | 40 +- internal/session/connectcaps.go | 17 +- internal/session/connectcaps_test.go | 4 +- internal/session/consent.go | 60 +- internal/session/principal_structure_test.go | 5 + internal/session/question.go | 54 +- internal/session/questionlaw_test.go | 11 +- internal/session/questionlifecycle_test.go | 4 +- internal/session/session.go | 4 + internal/session/steerquestion.go | 2 +- internal/session/taskpresence.go | 38 +- internal/session/tools_ask.go | 190 +++--- internal/session/tools_ask_test.go | 10 +- internal/tui3/app.go | 15 + internal/tui3/autonomysheet.go | 24 +- internal/tui3/question.go | 94 ++- internal/tui3/question_test.go | 9 + internal/tui3/questionchange.go | 245 ++++++++ internal/tui3/questionchange_test.go | 257 ++++++++ internal/tui3/questionhold.go | 97 +++ 35 files changed, 2544 insertions(+), 155 deletions(-) create mode 100644 docs/changes/unreleased/914-answers-are-messages.md create mode 100644 internal/session/asklane.go create mode 100644 internal/session/asklane_test.go create mode 100644 internal/tui3/questionchange.go create mode 100644 internal/tui3/questionchange_test.go create mode 100644 internal/tui3/questionhold.go diff --git a/docs/changes/unreleased/914-answers-are-messages.md b/docs/changes/unreleased/914-answers-are-messages.md new file mode 100644 index 0000000000..4371b8a8e1 --- /dev/null +++ b/docs/changes/unreleased/914-answers-are-messages.md @@ -0,0 +1,25 @@ +--- +kind: added +title: an answer is a message to the conversation, so the work carries on while you decide +pr: 914 +surface: [engine, chat, remote] +invalidates: + - "An answer to `ask` was the return value of the call that asked, and existed for the model only while that call was parked. It is now delivered: to the call parked on it where there is one, and otherwise onto the conversation's own queue through `Agent.accept` — the same road a background job's ending takes, landing at the next step boundary or waking an idle conversation. What the model reads is one rendering either way: a sentence saying what happened (`answered · question 3 · …`) and the whole `Answer` under it. A model or a test that expected bare JSON from `ask` reads a sentence first now." + - "`ask` always stopped the turn. It still does by default, and `blocking: {turn: false}` now returns at once with the question standing; the answer arrives later as a message opening `answered`. An absent `blocking` still waits, so nothing a model wrote before this behaves differently." + - "The book of parked asks (`askedOfThePerson`, `internal/session/askwait.go`, #870) and the lane's own account of a question were two maps holding one question. They are ONE — `Agent.asks` of `*modelAsk`, in `internal/session/asklane.go` — and that file carries #870's three endings word for word plus the fourth this adds: a call that never waited. `THE ENTRY IS THE OWNERSHIP` is now `THE CLAIM IS THE OWNERSHIP`, because a settled entry is kept so its answer can be changed." + - "The recommend-then-auto clock lived inside `executeAsk`'s select, so a question nobody was parked on had no clock at all. It belongs to the lane now and runs regardless; the length is one derivation (`autonomyClock`) — the person's own `/autonomy` figure, or `awayAfter` where they named none — and `/autonomy recommend` no longer requires a duration. A clock decides nothing once the session has closed, and an answer a closed conversation cannot read is refused instead of recorded." + - "A key on a question stopped only the SURFACE's reading clock; the engine's clock went on and could take the pick under somebody's hand. `Agent.HoldQuestion(kind, token)` (wire `Question.Hold`) stops it for good, clears `Question.Deadline` and says the question again, the row reads `paused`, and while the clock runs the question carries a dim line saying `any key stops the clock · you can still change the answer afterwards`." + - "`c change` on a receipt named a key nothing handled. It asks the same question again while the receipt is on screen and the box is empty; the answer carries `Answer.Revises`, the new record says `changed from `, and the model is told what changed. `u undo` is beside it on a permission answered `always` and hands that grant back whole — the session memo, the saved settings line, and a connected account's capability, which returns to asking. What may be changed is one reading, `Question.Revisable`, asked by the receipt that offers the key and by the door that takes it; an irreversible decision refuses it in words." + - "A standing yes to a connected service's tool was written as an account capability INSTEAD of the session memo, and a revision that deleted the memo therefore said `taken back` while the account went on being allowed. What a yes buys is recorded where it is bought (`Agent.rememberGrant`) and `Agent.undoGrant` is the one door that takes back all of it." + - "An answer to a question this engine is not holding — a row left by an interrupted turn, by a question the person talked past, or by a session host that has restarted — was applied to nothing in silence. It is refused in words now. A second answer to a question already settled is still silent to the person, and it now writes no decision record either: the first answer wins, and the record can no longer disagree with what the model was told." + - "A `ratify` — `something reversible was DONE … nothing waits on the answer, which is what makes it the cheapest question on the ladder` — parked the turn like every other ask, because an answer with nobody parked went nowhere. It returns at once now and the row waits on nobody; an asker that really wants to wait writes `blocking: {turn: true}`. Every other kind still waits by default." + - "A question the model asked was `waiting on you` on the presence file and the home page whether or not anything waited on it. The presence predicate reads `Blocking` now, so a conversation working around a question it asked without waiting says it is working. The `QuestionCap` still counts every open question against one subject: a row on a screen costs attention whether or not a turn is parked behind it." + - "The wire is version 15. A version-14 engine drops `Answer.Revises` as a late answer and answers `Question.Hold` with no such method while its clock runs on, and both fail as silence." +--- + +Three things the owner picked — carrying on while a question stands, a countdown that +goes with the pick, and changing an answer afterwards — are one mechanism: an answer is a +message that also releases a waiting call when there is one. `internal/session/asklane.go` +holds the lane's book and the one rendering every answer is read in, so a model that asked +without waiting reads exactly what a model that waited reads. A pick the clock takes says +in so many words that it is provisional and can still be changed. diff --git a/docs/design/questions/DESIGN.md b/docs/design/questions/DESIGN.md index 58e5ce3e2a..d4b8880820 100644 --- a/docs/design/questions/DESIGN.md +++ b/docs/design/questions/DESIGN.md @@ -21,7 +21,7 @@ Before the engine puts a question to the person, the asker has climbed: 1. **the record** — never ask what a decision record or a preference answers 2. **assume, and say so** — an assumptions card; the person strikes the wrong ones 3. **act, then ratify** — reversible: do it, show a ratify card (D1 = A) -4. *(owed, D8 = B this wave)* start on the pick while asking — designed as a seam, not built +4. **keep working while asking** (D8, built 2026-09-11) — an `ask` that says `blocking: {turn: false}` comes back at once and the answer arrives later as a message; see *An answer is a message* below 5. **show outcomes** — attach what each answer produces (a diff, a rendered row, a layout) 6. **structured input** — blanks · checklist · this-or-this · dial, never free text where a key would do 7. **the question** — head, options, pick with reason and confidence, what would change its mind @@ -214,11 +214,132 @@ conversation with the same answers row. One tool, `ask`, whose schema is the object minus what the engine fills (ID, Asker, Asked, Policy): head, kind, options with bodies and blocks, dimensions, input shape, pick with reason/confidence/wouldChange, stakes, scope, attach. -The result is the `Answer`, whole — including `Reframe` and `AskedBack`. The +The result is the `Answer`, whole — including `Reframe` and `AskedBack` — behind +one sentence saying who gave it; with `blocking: {turn: false}` the result is +`asked · question N` and that same text arrives later as a message. The system prompt carries the ladder in the model's own instructions and names `ask` as the last rung. `internal/session/prompts/system.md` and the manual state exactly when asking is allowed; the question gate enforces it. +## An answer is a message (lane A, 2026-09-11) + +Until this wave an answer to the model's own `ask` was only the RETURN VALUE of +the call that asked: `executeAsk` parked the turn on a channel, and the answer +existed for the model only if that call was still waiting for it. Three things +the owner picked could not be built on that — keeping working while a question +stands, a countdown that goes with the pick, and changing an answer after it is +given — because each of them is an answer arriving when NO call is waiting. + +**The road already existed.** A background job's ending, a watch's firing and a +task landing reach the model through ONE door, `Agent.accept(delivery)` +(`internal/session/mailbox.go`): the note goes on the steering queue, the loop +drains that queue at the next STEP boundary (`drainSteering`, before the next +request), and on an idle conversation the note's wake mark starts a turn +(`wakeLocked`). It coalesces, it respects the spend rail, a stopped session and a +task node's runner, and it journals the note in the session's lane. An answer is +now one more thing that rides it. There is no second delivery path. + +**The one door is still `ResolveQuestion`.** Its `ask` arm hands the answer to +the lane's book, `Agent.asks` (one `modelAsk` per question the model raised in +this conversation), which renders it ONCE — `askAnswerText`: one sentence saying +what happened, then the `Answer` as JSON in the model's own keys — and then: + +- a call parked on the question is handed that text as its result; +- with nobody parked, the text goes through `accept` as an owed note + (`answerNote`: wake, not batched into "while you worked", the same delivery + marks `relayNote` carries so a task node's runner is released by it and does + not close on top of it). + +The bytes are the same either way, and a law test holds them the same. + +**Deleted:** `askWaits` (a map of channels that could only release a parked +call), the policy clock inside `executeAsk`'s `select` (a clock that existed only +while a call waited), and the ask arm that dropped an answer with nobody parked. + +### The three features are three cases of it + +1. **Keep working while asking.** `blocking: {turn: false}` — an explicit false — + registers the question, puts it on every screen, and returns at once: + `asked · question 3 · · …the answer arrives as a message that starts + "answered · question 3"`. The question outlives the call and the turn; the + turn ending does not withdraw it. Its answer lands at the next step boundary + of whatever turn is running, or starts one. `blocking` omitted, or `turn: + true`, waits exactly as before — THE ZERO VALUE WAITS, as `Policy`'s does, so + a model that says nothing gets today's behaviour. A stop (`workStopped`) + declines the wake and the answer waits on the queue for the person's next + line. Headless runs are unchanged: the policy decides, or `your call`. +2. **The countdown.** A question with a pick under `PolicyRecommendThenAuto` + gets its clock from the lane's book, not from the call, so it runs whether or + not a call is parked. When it runs out the pick goes through the one door as + `DecidedBy: dial`, and the text says it is PROVISIONAL — the person has not + said this and may still change it. The length is the person's own number: + the `After` they set for this kind with `/autonomy`, and where they set none + (the assumption kind's default) `awayAfter` — the boundary this program + already uses to conclude nobody is at the keyboard. Irreversible stakes never + get a clock (the gate). **One source of truth for the deadline: + `Question.Deadline`.** A key on the question calls + `Agent.HoldQuestion(kind, token)` (wire `Question.Hold`), which stops the + clock for good, zeroes the deadline and re-sends the question, so every + window stops counting on the same beat. A surface reads a recommend-then-auto + question with a zero deadline as `paused`. +3. **Change an answer.** `Answer.Revises` — one bit on the answer that already + travels the wire whole — says "this is a new answer to a question already + settled", which the door would otherwise drop as a late second click. The + door refuses a revision of an irreversible decision (`that decision cannot + change — it was marked irreversible`), writes a new record (the gate and the + record read the newest), emits `EventQuestionAnswered` so other windows + update their receipts, and delivers a correction through the same road: + `changed · question 3 · · was SQLite, now JSONL…`. On the consent lane + a revision away from a widening answer drops the conversation's memo for that + tool, so the next call asks again; the saved settings rule is the surface's + to remove, since the surface wrote it. + +### The offer to stop asking (2026-09-11, owner) + +A setting that changes what the program does on its own is never reachable only by a +slash command. `/autonomy`, the settings row and this offer are three doors onto ONE +store — `.aforge/autonomy.json`, written only through `Agent.SetAutonomy`. + +WHEN the offer appears is derived from the record this session already keeps, and from +nothing else: the newest decision is the third of its shape answered the same way, by +the person, reversibly, and that shape has no explicit row yet. It is therefore offered +**once per kind per project** with nothing new to store — an explicit row silences it +for good, and both taking the offer (`recommend then go`) and declining it (`ask me`) +write one. Never for a shape the door itself refuses (a confirmation always asks; a +clarification never runs on a clock), and never on irreversible stakes. + +**This is not built.** It was written and then taken out of #914 rather than shipped +with nothing calling it: the dim line on the receipt and its keys are the surface's, and +that surface reads no decision record yet — so the whole of it would have been a +function nobody could reach. Two things it needs when somebody builds it: the record +list on the surface's side of the door, and ONE counter for the `r make it a rule` offer +(tui3's `questionYeses` counts yeses per shape today, which is a second count of the +same thing). + +### Limits, stated + +- The book is in memory. A session host that restarts loses its open asks + exactly as it loses a parked turn today; a revision of a question from an + earlier life is refused with `that question is from an earlier run`. +- Only the model's `ask` and consent take a revision. Every other lane's + answer has already moved work, and saying otherwise would be a key that lies. +- A consent keeps its own call parked — it is a gate on that call and nothing + else. The other calls in the same batch already run beside it (`runToolsWarm` + runs a batch concurrently); the NEXT step waits for the whole batch because a + request cannot be assembled with a tool result missing. +- Over the engine-host road a turn the conversation starts on its own reaches the + screen: #690 was the defect there and #856 fixed it. Driven over the default + host road on 2026-09-11 — a non-blocking ask answered with the turn idle woke + it, and the answer and the work it caused were drawn. + +### Wire + +Version 15. An older engine would read `Answer.Revises` as a late answer and +drop it silently, and would answer `Question.Hold` with no such method while its +clock went on and took the pick under somebody's hand — both are silences, and +the door refuses a mismatch rather than let a person believe they changed +something. + ## Lanes | lane | model | owns | lands | diff --git a/internal/e2e/tuiwords_test.go b/internal/e2e/tuiwords_test.go index 4292023a68..81ce261ede 100644 --- a/internal/e2e/tuiwords_test.go +++ b/internal/e2e/tuiwords_test.go @@ -934,7 +934,7 @@ var tuiWords = map[string]tuiWord{ why: "the head of the `/autonomy` sheet: this project's rules, in a person's own words", }, "autonomyUsageWord": { - screen: "/autonomy ask · recommend · decide", + screen: "/autonomy ask · recommend [duration] · decide", why: "the sheet's foot and the only place it names a door. An earlier draft put `· change` on " + "every row, which is a word with no key behind it", }, diff --git a/internal/manual/chat/permissions.md b/internal/manual/chat/permissions.md index df77dbc6c0..2ec0fbe21f 100644 --- a/internal/manual/chat/permissions.md +++ b/internal/manual/chat/permissions.md @@ -151,6 +151,26 @@ what it was told cannot become two accounts of one decision. It stays for about half a minute and then it is history — the transcript row keeps its own annotation for good. +**`u undo` on that line takes the permission back**, and it appears there as soon +as you answer one `always`: + +``` + decided needs your ok to run bash → always · you · 14:02 · u undo · c change +``` + +While the receipt is on the screen and your box is empty, `u` hands the standing +yes back whole — this conversation stops remembering it, the line it saved in +your settings goes, and a connected account's capability that the yes switched on +goes back to asking. It asks you nothing, and the row it leaves says `taken back · +this tool asks again`. **A shell `always` is the one exception**: the rule it saved +is about a command shape rather than a tool name, and the receipt does not know +which line it wrote, so `u` takes back this conversation's own memory and says +`taken back for this conversation · the saved command rule is in /permissions` — +which is where that line is removed for good. + +`c change` on the same line asks you the same thing again instead, for when the +answer you want is `allow once` rather than nothing at all. + A question the asker took back says so once instead, and the chip's count drops: ``` diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 705d1c6363..f42da11bc4 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -72,7 +72,7 @@ card opens with. - **ratify** — something reversible was already done, and this is your chance to unwind it. Nothing is waiting on the answer. -## Which questions wait, and which one carries a clock +## Which questions wait, which one carries a clock, and when it keeps working while you decide Most of them wait. A wait that ended is not a no: an approval question, a standing card and a page waiting to be approved carry no clock at all, and they @@ -95,6 +95,22 @@ to strike a line and carries on with what it said it was assuming. **Nothing that cannot be taken back ever runs on a clock**, and only you ever answer one. aforge refuses to raise a question that says otherwise. +**A question the model raises does not always stop the work.** It says when it +asks whether its turn waits for you. Where it does, the turn stands still until +you answer, and the row says `waiting`. Where it does not — because it has work +in hand that does not depend on what you decide — it carries on, and your answer +reaches it as a message the moment you give it: at the end of whatever step it is +in the middle of, or as a new turn if it has finished and gone quiet. Either way +nothing is lost by taking your time, and the question stays on the block until +you answer it. + +**A ratify never stops the work**, and that is the one shape where the model does +not get to choose: something reversible was already done, the row is your chance +to unwind it, and a turn standing still over finished work would make the +cheapest rung on the ladder the most expensive. It goes up, the work carries on, +and an answer — `put it back` — reaches the model as a message whenever you give +it. No window says this conversation is waiting on you because of one. + ## The record — where your answers are saved Every answer you give is written down, in the conversation's own folder, in a @@ -301,7 +317,7 @@ clock**. Trying to change either says so rather than failing quietly. The rules live in the project, not in your profile — the same kind of question can deserve a different answer in two pieces of work. -## Why is there a countdown on this question, and what is `your rule`? +## Why is there a countdown on this question, why did it go with the recommended answer, and what is `your rule`? A question that is going to be taken by a rule says so on its own row, with the answer that is about to be taken and how long is left: @@ -315,6 +331,28 @@ told to do**, not because the question came with one. `D` is the door that changes it, and `/autonomy` shows every row at once. **There are no hidden rules**: a clock you did not ask for never runs without that word beside it. +**A clock is something you turn on.** No question the model raises carries one +until you set a rule for that kind — `/autonomy choice recommend`, `D` on a row, +or the settings. Where you name no length it is **ten minutes**, which is the +same stretch of silence this program reads everywhere else as nobody being at the +keyboard; `/autonomy choice recommend 30s` names a different one. The one shape +that carries a clock without being asked to is an assumptions card, and nothing +is decided at the end of that one: the asker stops waiting for you to strike a +line and goes on with what it said it was assuming. + +**Any key on the question stops the clock, for good.** The question says so +itself while the clock runs, on a dim line under it: `any key stops the clock · +you can still change the answer afterwards`. Press one and the tail changes to +`paused`, that line goes, the work goes on waiting for you, and nothing decides +it but you. There is no way back to a running clock — a countdown that started +again would fire exactly when you had looked away mid-decision. + +**When a clock does run out, it goes with the recommended answer and says so.** +The decision is written down as this program's own rather than yours — the +receipt reads `aforge, on your settings` — and what the model is told says in +so many words that the answer is provisional and you may still change it. That is +the next section. + ## Several questions at once — the sheet, and the same answer for all of them Quiet questions raised while one step is running do not land on top of each @@ -596,8 +634,14 @@ Answering leaves one dim line where the question was: ``` What was asked, what you picked, anything you said beside it, who decided, and -when. Where a decision can still be taken back it says `c change`; where it -cannot it says `cannot change` instead. +when. A decision you changed says what it replaced — `changed from Hello` — +so the two lines about one question never read as two different questions. +Where a decision can still be taken back it says `c change`, and the key works +from there: it asks you the same question again and your new answer replaces the +old one. A permission you answered `always` says `u undo` beside it as well — +one key that hands the standing yes back without asking you anything. Where a +decision cannot be taken back it says `cannot change` instead, and nothing on it +is a key. The name in the middle is who gave the answer: `you`, or `an earlier decision` where something you already settled covered it, or `done and not objected to` @@ -623,7 +667,7 @@ off the right-hand edge, and the order is fixed: 2. the time. **What is never given up**: the question and what you picked, **who decided**, -`cannot change`, and `c change`. Who decided is the one thing on that line you +`cannot change`, `u undo` and `c change`. Who decided is the one thing on that line you cannot work out for yourself — `another window` and `aforge, on your settings` are the whole reason it is written — and the last two are not details about the decision, they are what is still possible about it. A line that dropped @@ -663,27 +707,45 @@ which of your decisions is still deciding things. Nothing is ever made into a rule on your behalf, and a `no` never becomes one: a standing refusal is something you write in your settings on purpose. -## Undoing what you just approved - -Two keys, and which one you get depends on whether the thing has happened yet. - -`u` is on a ratify line — something reversible was already done, and pressing it -puts it back. It is only offered while there is really something to undo, and -that is two things at once: the work has to be **reversible**, and whatever did -it has to have written down the answer that puts it back. A ratify line about -something costly or irreversible does not draw the key, and neither does one that -named no way back — pressing `u` there would send an answer nobody described. - -`c` is on the receipt after you answer, spelled `c change` on the line. It shows -what unwinding would cost before it reopens the question. - -Today `u` is never drawn: nothing yet tells a ratify row that the work behind it -can still be put back, so the row reads `✓ · [esc] later · -[c] change` and `c` is the way back. - -A decision that cannot be taken back says `cannot change` on its receipt and -offers neither. Those are the ones that were never on a clock and that nobody -but you was ever allowed to answer. +## Undo what you just approved, or change an answer you already gave + +**Yes — you can change an answer you already gave**, and you can undo a +permission you just approved. Two keys, and which one you get depends on whether +the thing has happened yet. + +`u undo` is on the receipt of a permission you answered **always**, and it hands +that standing yes straight back: this conversation stops remembering it, the +saved line in your settings goes with it, and an account capability the yes left +switched on goes back to asking. It asks you nothing, because there is nothing +left to decide — the next time that tool runs you get the question again. The +line it leaves says `taken back · this tool asks again`. A shell `always` is the +one that cannot be finished from here: the rule it saved is about a command +shape, and the receipt does not know which line it wrote, so the row says `taken +back for this conversation · the saved command rule is in /permissions`. + +`u` is also on a ratify line — something reversible was already done, and +pressing it puts it back. There it is only offered while there is really +something to undo, and that is two things at once: the work has to be +**reversible**, and whatever did it has to have written down the answer that puts +it back. A ratify line about something costly or irreversible does not draw the +key, and neither does one that named no way back. + +`c` is on the receipt after you answer, spelled `c change`. It asks you the same +question again, with no clock on it; your new answer replaces the old one in the +record, and the model is told what changed — `was sqlite, now jsonl` — so that +anything it did on the old answer is looked at again. It is the receipt's key +only while the receipt is on screen and your box is empty. A permission changed +this way stops applying: the next call of that tool asks again. + +On a ratify line `u` is still never drawn: nothing yet tells that row the work +behind it can be put back, so it reads `✓ · [esc] later · +[c] change` and `c` is the way back there. + +**Nothing irreversible is changed this way.** A decision that cannot be taken +back says `cannot change` on its receipt and offers neither key; answering it +again is refused in words, because what it allowed has already happened. Those +are the ones that were never on a clock and that nobody but you was ever allowed +to answer. ## Open a question up and read it properly — the page a question opens into diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index d00af49b9c..26c865f0d8 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -52,6 +52,17 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"it went ahead by itself", "questions"}, {"make it ask me every time", "questions"}, {"it assumed something wrong", "questions"}, + // An answer is a message now (docs/design/questions/DESIGN.md), and the + // three things that fall out of it are asked in the words somebody + // reaches for: the work going on around a question, changing their mind + // afterwards, and a clock that went with the recommendation. + {"can you keep working while I decide", "questions"}, + {"can I change my answer", "questions"}, + {"why did it go with the recommended answer", "questions"}, + // And the two a person asks the moment they regret a yes: one about the + // permission itself, one about the key that hands it back. + {"how do I take back a permission I allowed", "permissions"}, + {"how do I stop the countdown on a question", "questions"}, // The two marks a question can open with that are NOT the amber `?`, and // the countdown that goes with one of them. Somebody meeting either has // no name for it and describes the shape, so both are asked that way. diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index cf1afd15d7..9b25a102b7 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -146,7 +146,7 @@ func classify(method string) callClass { MethodTaskRoom, MethodTaskPending, MethodTaskJudge, MethodTaskEffort, MethodListDir, MethodStatPaths, MethodFetchFile: return classGetter - case MethodQuestionResolve, + case MethodQuestionResolve, MethodQuestionHold, MethodConsent, MethodConsentRemember, MethodStandingResolve, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, diff --git a/internal/remote/questionlane.go b/internal/remote/questionlane.go index 70f6ce073e..306d8b171b 100644 --- a/internal/remote/questionlane.go +++ b/internal/remote/questionlane.go @@ -42,6 +42,7 @@ import ( "encoding/json" "sync" + "github.com/Agent-Field/aforge-v2/internal/guard" "github.com/Agent-Field/aforge-v2/internal/session" ) @@ -154,3 +155,15 @@ func (a *Agent) WatchQuestions() (<-chan session.Event, func()) { // questions, because a window that does not draw questions has not been sent // any and has nothing to report about them. func (a *Agent) OpenQuestions() []session.Question { return a.c.asked.list() } + +// HoldQuestion stops one question's clock on the far machine without making a +// key wait for the connection — [Agent.HoldTask]'s shape, for every lane. There +// is no answer to carry back: what a person sees is the question said again with +// its countdown gone, on the questions lane. +func (a *Agent) HoldQuestion(kind session.QuestionKind, token string) { + c := a.c + go func() { + defer guard.Recover("remote/question hold") + _, _ = c.call(nil, MethodQuestionHold, QuestionHoldArgs{Kind: kind, Token: token}) + }() +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 857c0f3c25..23c763f14f 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2108,6 +2108,21 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, errors.New("engine: this session cannot answer questions from here") } return nil, door.ResolveQuestion(args.Answer) + case MethodQuestionHold: + args, err := arg[QuestionHoldArgs](call) + if err != nil { + return nil, err + } + // THE OPTIONAL-DOOR PATTERN AGAIN, and here a missing door costs nothing + // but the clock: an engine that cannot hold a question still answers it. + door, ok := agent.(interface { + HoldQuestion(session.QuestionKind, string) + }) + if !ok { + return nil, nil + } + door.HoldQuestion(args.Kind, args.Token) + return nil, nil case MethodSetAutonomy: args, err := arg[AutonomyArgs](call) if err != nil { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 793ae35bc7..691c1147e5 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -289,7 +289,26 @@ import ( // permanently dark, with nothing on the screen saying why. Refused at the door, // a person is told their engine is an older aforge; accepted, they would be told // nothing at all and their turn would simply stop. NEVER TO SILENCE. -const Version = 14 +// VERSION 15 IS AN ANSWER THAT IS A MESSAGE (docs/design/questions/DESIGN.md). +// A question the model asks no longer exists only for as long as the call that +// asked is parked on it: the answer is delivered to the conversation, which is +// what lets the model carry on while somebody decides, lets a clock take the +// pick on a question nobody is waiting on, and lets a decision be CHANGED +// afterwards. Two of those cross this wire: +// +// - [session.Answer.Revises] says an answer is a person changing their mind +// about a settled question rather than a second click on one somebody else +// has already answered. It rides [MethodQuestionResolve], which has always +// carried the answer whole. +// - [MethodQuestionHold] stops a question's clock without answering it. +// +// THE NUMBER MOVES BECAUSE BOTH FAIL AS SILENCE ON AN OLDER ENGINE. A version-14 +// engine reads `revises` as a field it does not know, applies answers.go's own +// law — a late answer is ignored and nothing says so — and the person watches +// their change do nothing; and it answers `Question.Hold` with no such method +// while its clock goes on counting, so the pick is taken under the hand of +// somebody who pressed a key to stop exactly that. NEVER TO SILENCE. +const Version = 15 // AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods // rode version 5's: neither half can be surprised by them. "phase" and "lane" @@ -422,6 +441,18 @@ const ( // are: they are what an older window on the other end of this wire sends, and // this one is what a window that has the whole object sends. MethodQuestionResolve = "ResolveQuestion" // QuestionArgs → nothing (or a refusal) + // MethodQuestionHold is the OTHER thing a key on a question can mean: stop + // the clock, do not answer. A question with a deadline takes the asker's own + // pick when it runs out ([session.PolicyRecommendThenAuto]), and a person + // reading it has to be able to stop that without deciding anything — + // [MethodTaskHold] is the same act for the one lane that had it first, and + // this is the door for every lane, named the way an answer is named: the lane + // and the lane's own token. + // + // IT NEVER MAKES A KEY WAIT. The surface sends it and carries on; the engine + // says the question again with its deadline gone, so every window stops + // counting from the same frame rather than from its own guess. + MethodQuestionHold = "Question.Hold" // QuestionHoldArgs → nothing // MethodQuestionWatch is the surface saying it draws questions, and it buys // exactly what [MethodTaskWatch] and [MethodDesignWatch] buy: "question" // frames from here on, including everything already open replayed the @@ -1542,6 +1573,13 @@ type QuestionArgs struct { Answer session.Answer `json:"answer"` } +// QuestionHoldArgs names one question whose clock a person has stopped, the way +// an answer names it: the lane, and the lane's own token ([session.Question.Token]). +type QuestionHoldArgs struct { + Kind session.QuestionKind `json:"kind"` + Token string `json:"token"` +} + type HarnessArgs struct { ID uint64 `json:"id"` Run bool `json:"run"` diff --git a/internal/session/answers.go b/internal/session/answers.go index 444ab95c84..120a7f6636 100644 --- a/internal/session/answers.go +++ b/internal/session/answers.go @@ -622,6 +622,18 @@ type Answer struct { // the person taking the work over rather than by an answer to it. It is // meaningful on [QuestionSubharnessAsk] alone. TakingOver bool `json:"takingOver,omitempty"` + // Revises says this is a NEW ANSWER TO A QUESTION ALREADY SETTLED — the + // person changing their mind from the receipt — rather than a second click + // on a question somebody else has already answered. + // + // IT IS THE ONE BIT THAT TELLS THOSE TWO APART, and without it the second is + // what every late answer looks like: answers.go's own law is that a late + // answer is ignored and nothing says so, which is exactly right for a key + // pressed in another window a moment too slowly and exactly wrong for + // somebody who has just read the receipt and decided otherwise. + // [Agent.ResolveQuestion] takes it to the lane's own revise, which refuses + // where a decision cannot be walked back. + Revises bool `json:"revises,omitempty"` } // Keys is what was picked, however the answer spelled it: [Answer.Picked] where diff --git a/internal/session/answers_test.go b/internal/session/answers_test.go index 724b65f514..bd8ef9b87e 100644 --- a/internal/session/answers_test.go +++ b/internal/session/answers_test.go @@ -543,10 +543,7 @@ func TestAModelsOwnQuestionTravelsInPresenceAndAnAnswerComesBack(t *testing.T) { } agent.drainAnswers() - var answer Answer - if err := json.Unmarshal([]byte(<-results), &answer); err != nil { - t.Fatalf("the ask handed back %v", err) - } + answer := askAnswerRead(t, <-results) if answer.FirstKey() != "1" { t.Fatalf("the model was handed %+v", answer) } diff --git a/internal/session/asklane.go b/internal/session/asklane.go new file mode 100644 index 0000000000..73a891f4cf --- /dev/null +++ b/internal/session/asklane.go @@ -0,0 +1,445 @@ +package session + +// The model's question lane: the book of what it has asked, and the one road an +// answer takes back to it. +// +// AN ANSWER IS A MESSAGE TO THE CONVERSATION THAT ALSO RELEASES A WAITING CALL +// WHEN THERE IS ONE (docs/design/questions/DESIGN.md, "An answer is a message"). +// Until this lane existed an answer was only the RETURN VALUE of the `ask` call +// that raised the question — the turn parked on a channel, and an answer that +// arrived with nobody parked went nowhere at all. Three things a person asked +// for are the same shape and none of them could be built on that: carrying on +// while a question stands, a clock that takes the pick when it runs out, and +// changing an answer after it was given. Each is an answer arriving when no call +// is waiting. +// +// So the answer is rendered ONCE ([askAnswerText]) and delivered one of two +// ways: handed to the call parked on it, or put on the conversation's own queue +// through [Agent.accept] — the road a background job's ending, a watch's firing +// and a landed task already take, which lands at the next step boundary and +// starts a turn on an idle conversation. There is no second delivery path, and +// the bytes are the same either way (asklane_test.go holds it). +// +// ── THE FOUR WAYS AN ASK ENDS ── +// +// This book absorbed the one that held only the wait (`askwait.go`, #870), whose +// three endings are kept here word for word because the rule in them is the same +// rule and two books for one question was the defect that file's own doc warned +// about: +// +// ANSWERED somebody chose ([Agent.answerAsk], from the one resolver every +// surface calls). The call parked on it reads the answer. +// TALKED PAST they said something else instead of answering +// ([Agent.asksTalkedPastLocked], from the splice — steerquestion.go +// says what was measured). The call reads a CLOSED channel, the one +// shape that cannot be mistaken for a choice they made. +// LET GO the call itself stopped waiting ([Agent.askLetGo]): the turn was +// stopped, or the question was never drawn at all. +// CARRIED ON the call never waited ([askBlocking]), so there is no channel and +// the answer is a message whenever it comes. This is the ending the +// three above had no room for, and the one this wave is about. +// +// ── THE CLAIM IS THE OWNERSHIP ── +// +// Every road takes the channel out of the entry ([modelAsk.claimParkedLocked]) +// under a.mu BEFORE it touches it, so at most one road can ever hold it. That is +// what makes closing it safe rather than a send-on-closed waiting to happen. It +// is `askwait.go`'s own law, moved from the map entry to the field because the +// entry now OUTLIVES the wait: a settled question is kept so its answer can be +// changed, so the map entry can no longer be the token. + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// deliverAskAnswer puts one answer in front of the model when no call is waiting +// for it, through the door every other piece of owed news goes through +// ([Agent.accept]): it lands at the next step boundary of a turn that is +// running, and starts one where the conversation is idle. It reports whether +// anything took it. +func (a *Agent) deliverAskAnswer(answer Answer, text string) bool { + origin := fromPerson + if answer.DecidedBy != DecidedByPerson && answer.DecidedBy != DecidedByWindow { + // The clock taking the pick is the program's own account of something + // that happened, which is what [fromRuntime] says. + origin = fromRuntime + } + receipt := a.accept(delivery{origin: origin, kind: msgResult, note: answerNote(text)}) + return receipt.state != deliveryClosed +} + +// answerNote is an answer on the conversation's queue. +// +// IT CARRIES [relayNote]'S DELIVERY MARKS, and for that function's reasons: the +// wake, because a question answered on an idle conversation is owed a sentence +// and a queue alone was the whole of #690's defect (fixed by #856); `steered`, so a task node's +// runner is released by it and does not close the agent on top of an answer no +// request has carried; `authored`, because the sentence is the engine's account +// of what happened rather than a line the person typed. It is NOT batched under +// "while you worked": an answer somebody is waiting to act on keeps its own +// shape. +func answerNote(text string) userMessage { + note := wakeNote(text) + note.authored = true + note.steered = true + note.batch = false + return note +} + +// ── what the model reads ──────────────────────────────────────────────────── + +// askAnswerText is the ONE rendering of an answer for the model: a sentence +// saying what happened, and the whole [Answer] under it in the asker's own keys. +// +// THE BYTES ARE THE SAME WHETHER A CALL WAS PARKED OR NOT. A model that asked +// without waiting must read exactly what a model that waited reads, or the two +// forms are two contracts and the second one is the one nobody tested +// (asklane_test.go holds it). +// +// THE SENTENCE IS THERE BECAUSE THE JSON CANNOT SAY IT. `"decidedBy":"dial"` is +// a field name; "nobody answered before the clock ran out, so your pick stands +// for now" is what it MEANS, and a provisional decision the model reads as a +// settled one is a model that never mentions it again. +func askAnswerText(q Question, answer Answer, theirs map[string]string, prior *Answer) string { + return askAnswerLead(q, answer, prior) + "\n" + answerJSON(askInTheirKeys(answer, q, theirs)) +} + +// askAnswerLead is that sentence. Its first word is the fact — answered, +// changed, or taken — and the question's own id and head follow it, because an +// answer that arrives as a message may arrive several steps after the call that +// asked for it and the model has to know which question it is about. +func askAnswerLead(q Question, answer Answer, prior *Answer) string { + head := strings.TrimSpace(q.Head) + switch { + case answer.Revises: + was := answerWords(q, prior) + now := answerWords(q, &answer) + lead := fmt.Sprintf("%s · %s · %s", askChangedWord, askQuestionWord(q), head) + if was != "" && now != "" { + lead += fmt.Sprintf(" · was %s, now %s", was, now) + } + return lead + " · " + askChangedNote + case answer.DecidedBy == DecidedByDial: + return fmt.Sprintf("%s · %s · %s · %s", askAnsweredWord, askQuestionWord(q), head, askProvisionalNote) + default: + return fmt.Sprintf("%s · %s · %s", askAnsweredWord, askQuestionWord(q), head) + } +} + +// askedText is what the asker is told when it said the turn does not wait: the +// question is up, and the answer will find it. +func askedText(q Question) string { + return fmt.Sprintf("%s · %s · %s · %s", + askAskedWord, askQuestionWord(q), strings.TrimSpace(q.Head), askCarryOnNote) +} + +// The words this lane says to the model, spelled once because the manual quotes +// them and because the sentence the model is told to expect +// ([askCarryOnNote]) is the sentence an answer actually opens with. +const ( + askAnsweredWord = "answered" + askChangedWord = "changed" + askAskedWord = "asked" + // askProvisionalNote is the whole of what `DecidedBy: dial` means, said in + // the words a person would use. + askProvisionalNote = "provisional: nobody answered before the clock ran out, so your pick stands for now and they can still change it" + // askChangedNote is what a correction obliges: whatever was done on the old + // answer is now the thing to look at. + askChangedNote = "they changed their answer; anything you did on the old one needs revisiting" + // askCarryOnNote is the non-blocking contract, in one sentence. + askCarryOnNote = "it is on their screen; carry on with anything that does not depend on it — their answer arrives as a message opening `" + askAnsweredWord + "`" +) + +// askQuestionWord names the question the way both sentences name it, so a model +// reading an answer several steps later can match it to the call it made. +func askQuestionWord(q Question) string { + if ref := strings.TrimSpace(q.Ref); ref != "" { + return "question " + ref + } + return "question " + strconv.FormatUint(q.ID, 10) +} + +// answerWords is what an answer picked, as a person read it: the labels the +// question gave those keys, and the keys themselves where it gave none. It is +// [DecisionRecord.Words] for an answer that has not been written down yet. +func answerWords(q Question, answer *Answer) string { + if answer == nil { + return "" + } + words := make([]string, 0, 2) + for _, key := range answer.Keys() { + if option, ok := q.Option(key); ok && strings.TrimSpace(option.Label) != "" { + words = append(words, strings.TrimSpace(option.Label)) + continue + } + words = append(words, key) + } + if len(words) == 0 { + return strings.TrimSpace(answer.Words()) + } + return strings.Join(words, ", ") +} + +// answerJSON is the answer itself. An answer that will not encode is a +// programming error rather than anything a person can act on, so what goes out +// is the sentence above it and the one field the model needs. +func answerJSON(answer Answer) string { + data, err := json.Marshal(answer) + if err != nil { + return `{"key":"` + answer.FirstKey() + `"}` + } + return string(data) +} + +// ── the clock ─────────────────────────────────────────────────────────────── + +// startAskClock starts the policy's clock on a question that has one. +// +// THE LENGTH IS THE PERSON'S OWN NUMBER AND NOT A NEW ONE. It is the `After` +// they set for this shape of question with `/autonomy` ([Agent.SetAutonomy]), +// and where they set none it is the one default this program already has for the +// shape that gets one — the assumption kind's [awayAfter], which is the boundary +// this program uses everywhere else to conclude that nobody is at the keyboard. +// So a pick is taken for somebody only after exactly as long as it would take to +// decide they are not coming. +// +// THE CLOCK BELONGS TO THE LANE AND NOT TO THE CALL. It used to live in +// `executeAsk`'s select, which meant a question nobody was parked on had no +// clock at all — and a question nobody is parked on is precisely the one this +// wave added. +func (a *Agent) startAskClock(open *askOpen) { + if open.q.Policy.Kind != PolicyRecommendThenAuto || open.q.Policy.After <= 0 { + return + } + id := open.q.ID + a.mu.Lock() + if a.closed { + a.mu.Unlock() + return + } + open.clock = time.AfterFunc(open.q.Policy.After, func() { a.askClockRanOut(id) }) + a.mu.Unlock() +} + +// askClockRanOut takes the asker's own pick for the person, through the one door +// every other answer goes through — so it is recorded, announced and delivered +// exactly as a key press would be, and marked as what it is. +func (a *Agent) askClockRanOut(id uint64) { + a.mu.Lock() + open := a.asked.atLocked(id) + if open == nil || open.settled != nil || open.held || a.closed { + // NOTHING THIS CLOCK COULD SAY WOULD REACH ANYBODY once the session has + // closed, and a decision nobody read is worse than no decision: the next + // run of this conversation reads the record as something the person + // settled. [Agent.stopAskClocksLocked] is the other half of that law. + a.mu.Unlock() + return + } + q := open.q + a.mu.Unlock() + _ = a.ResolveQuestion(defaultAnswer(q, "")) +} + +// HoldQuestion stops the clock on one question a person is looking at, without +// answering it, and tells every window so. +// +// IT IS [Agent.ResolveQuestion]'S SHAPE FOR THE OTHER THING A KEY CAN MEAN. A +// key pressed on a question is evidence that somebody is there, and a clock that +// answered over their hands would be the one answer this program must never give +// on their behalf (tui3's [app.tickQuestion] states it for the surface's own +// reading clock). The lane's token is the token an answer names, so a surface +// holds a question with what it already has. +// +// THERE IS NO WAY BACK, for that same reading: a clock that resumed after a few +// idle seconds would fire exactly when somebody had looked away mid-decision. +func (a *Agent) HoldQuestion(kind QuestionKind, token string) { + switch kind { + case QuestionTask: + id, err := strconv.ParseUint(strings.TrimSpace(token), 10, 64) + if err != nil { + return + } + a.HoldTask(id) + case QuestionAsk: + id, err := strconv.ParseUint(strings.TrimSpace(token), 10, 64) + if err != nil { + return + } + a.holdAsk(id) + } +} + +// holdAsk is the ask lane's own hold: the clock stops, the deadline goes, and +// the question is said again with the countdown off it. +func (a *Agent) holdAsk(id uint64) { + a.mu.Lock() + open := a.asked.atLocked(id) + if open == nil || open.settled != nil || open.held || open.q.Deadline.IsZero() { + a.mu.Unlock() + return + } + open.held = true + if open.clock != nil { + open.clock.Stop() + } + open.q.Deadline = time.Time{} + q := open.q + a.mu.Unlock() + a.restateQuestion(q) +} + +// restateQuestion says one question again because a fact ON it changed — its +// clock stopped — rather than because it is new. +// +// A QUESTION IS RESTATED AND NEVER RE-RAISED. Withdrawing it and asking it again +// would put `no longer needed` on somebody's screen about the question they are +// reading, and would hand it a fresh settle guard and a fresh reading clock +// under their hand (tui3's own replace keeps both). So the word book and the +// desk row are overwritten in place and the lane says it once more; every +// surface's upsert takes the newer object and keeps what it knows. +func (a *Agent) restateQuestion(q Question) { + a.putBackQuestion(q) + a.presenceRestateQuestion(q) + a.emitQuestion(EventQuestion, q, nil) +} + +// ── changing an answer ────────────────────────────────────────────────────── + +// The refusals a revision can meet, and the two an answer can. Each says what a +// person can do next, on [Question.Check]'s terms. +var ( + errRevisionIrreversible = errors.New( + "session: that decision cannot be changed — it was answered as irreversible, and what it allowed has already happened") + errRevisionGone = errors.New( + "session: that decision is from an earlier run of this conversation and cannot be changed from here — ask again instead") + errRevisionLane = errors.New( + "session: only a question this conversation asked, or a permission, can be changed after it is answered") + errAnswerGone = errors.New( + "session: nothing is waiting on that question here any more — the turn moved on, or this conversation's engine restarted since it was asked") + // errAnswerSettled is the loser of a race, and it is never shown to + // anybody: a second answer to a settled question is not a fault, it simply + // changes nothing ([Agent.answerAsk] says why it cannot be silent either). + errAnswerSettled = errors.New("session: that question was already answered") + // errAnswerClosed is an answer nothing can read. + errAnswerClosed = errors.New("session: this conversation has closed; the answer could not be given to it") +) + +// Revisable reports whether an answer to this question can still be changed +// after it was given — the one reading, used by the surface that offers the key +// and by the door that takes the revision, so a receipt can never offer what the +// engine will refuse. +// +// IT IS KEYED ON WHAT THE ANSWER DID, not on which lane asked. Two properties +// decide it. An irreversible decision is never revisable: what it allowed has +// already happened, and "changing" it would be a second decision wearing the +// first one's receipt. Everything else is revisable exactly where the answer can +// be GIVEN AGAIN — the model's own question, whose answer is a message and can +// therefore be sent a second time saying what changed, and a permission, whose +// widening yes is a grant and can be taken back ([Agent.undoGrant]). Every other +// lane's answer moved work: a task started, a landing landed, a service +// connected. +func (q Question) Revisable() bool { + if q.Stakes == StakesIrreversible { + return false + } + return q.Kind == QuestionAsk || q.Kind == QuestionConsent +} + +// reviseLane applies one revision and answers the question it was about, so the +// caller can record it and announce it exactly as it does a first answer. +// +// It answers what was asked and what was decided BEFORE, so the caller can +// record a line that says what it replaced. +// +// IT IS THE LANE SWITCH [Agent.applyToLane] IS, for the one verb that lane +// switch cannot carry: `applyToLane` hands an answer to a resolver that is +// waiting for it, and a revision is by definition an answer nothing is waiting +// for. [Question.Revisable] is what says which lanes take one, and it is the +// same function the receipt asks before it offers the key. +func (a *Agent) reviseLane(answer Answer) (Question, *Answer, error) { + switch answer.Kind { + case QuestionAsk: + a.mu.Lock() + open, settled := a.asked.settledLocked(answer.ID) + var q Question + var prior *Answer + if settled { + q, prior = open.q, open.settled + } + a.mu.Unlock() + if !settled { + return Question{}, nil, errRevisionGone + } + if !q.Revisable() { + return Question{}, nil, errRevisionIrreversible + } + return q, prior, a.answerAsk(answer) + case QuestionConsent: + q, found := a.decidedConsent(answer.ID) + if !found { + return Question{}, nil, errRevisionGone + } + if !q.Revisable() { + return Question{}, nil, errRevisionIrreversible + } + prior := a.decidedConsentAnswer(q, answer.ID) + a.reviseConsent(q, answer) + return q, prior, nil + } + return Question{}, nil, errRevisionLane +} + +// decidedConsentAnswer is what the record says was answered last time, as an +// answer, so a changed permission's line says what it replaced. +func (a *Agent) decidedConsentAnswer(q Question, id uint64) *Answer { + records := a.Decisions() + for at := len(records) - 1; at >= 0; at-- { + record := records[at] + if record.Kind != QuestionConsent || record.ID != id { + continue + } + return &Answer{Kind: QuestionConsent, ID: id, Picked: record.Picked, DecidedBy: record.By} + } + return nil +} + +// decidedConsent is the permission this id names, rebuilt from the record this +// conversation kept of it. The gate's own question is long gone — the call ran +// or did not — and what a revision needs is what was asked, what it was about +// and what it cost, all of which the record holds. +func (a *Agent) decidedConsent(id uint64) (Question, bool) { + records := a.Decisions() + for at := len(records) - 1; at >= 0; at-- { + record := records[at] + if record.Kind != QuestionConsent || record.ID != id { + continue + } + return Question{ + ID: record.ID, Kind: QuestionConsent, Ask: AskPermission, Form: FormLine, + Asker: Asker{Kind: AskerEngine}, Head: record.Head, Subject: record.Subject, + Options: AnswerOptions(QuestionConsent), Stakes: record.Stakes, + }, true + } + return Question{}, false +} + +// reviseConsent takes back what a widening yes bought, through the one door that +// knows every store a yes was written into ([Agent.undoGrant]). +func (a *Agent) reviseConsent(q Question, answer Answer) { + tool := strings.TrimSpace(q.Subject.Name) + if tool == "" { + return + } + action, ok := AnswerFromKey(QuestionConsent, answer.FirstKey()) + if ok && action.Allow && action.Scope == ConsentToolSession { + // A REVISION THAT WIDENS AGAIN IS NOT A REVOCATION. The person moved + // from one always to another; what they granted stands as it is. + return + } + a.undoGrant(tool) +} diff --git a/internal/session/asklane_test.go b/internal/session/asklane_test.go new file mode 100644 index 0000000000..6fb7e1ac42 --- /dev/null +++ b/internal/session/asklane_test.go @@ -0,0 +1,565 @@ +package session + +// The laws of the ask lane: an answer is a message, and the three things that +// fall out of that (docs/design/questions/DESIGN.md, "An answer is a message"). + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +// askAnswerRead is the [Answer] out of what the model reads. The rendering is a +// sentence and then the answer ([askAnswerText]), so a test reads it the way the +// model does: the fact first, the object under it. +func askAnswerRead(t *testing.T, text string) Answer { + t.Helper() + at := strings.IndexByte(text, '\n') + if at < 0 { + t.Fatalf("the answer carries no object under its sentence: %q", text) + } + var answer Answer + if err := json.Unmarshal([]byte(text[at+1:]), &answer); err != nil { + t.Fatalf("the answer's object does not read: %v · %q", err, text) + } + return answer +} + +// askDelivered is every answer this conversation has been handed with nobody +// parked on it: what is still on the queue, and what a turn has already taken +// off it — a wake starts a turn the instant the note lands, so a test that only +// read the queue would be reading a race. +func askDelivered(a *Agent) []string { + a.mu.Lock() + defer a.mu.Unlock() + notes := make([]string, 0, len(a.steering)+len(a.messages)) + for _, message := range a.messages { + if message.Role != "user" { + continue + } + if text := partsText(message); strings.HasPrefix(text, askAnsweredWord) || strings.HasPrefix(text, askChangedWord) { + notes = append(notes, text) + } + } + for _, message := range a.steering { + notes = append(notes, message.text()) + } + return notes +} + +// waitForOneQuestion is the question this conversation has just put, without a +// sleep of its own worth of guessing. +func waitForOneQuestion(t *testing.T, a *Agent) Question { + t.Helper() + deadline := time.After(2 * time.Second) + for { + if open := a.OpenQuestions(); len(open) > 0 { + return open[0] + } + select { + case <-deadline: + t.Fatal("no question was ever put") + default: + time.Sleep(time.Millisecond) + } + } +} + +const askNotWaiting = `{"head":"Which storage shape should this use?","kind":"choice","reason":"two shapes fit and the record does not choose","stakes":"reversible","blocking":{"turn":false},"options":[{"key":"1","label":"sqlite"},{"key":"2","label":"jsonl"}],"pick":{"key":"1","reason":"the readers we have are sqlite"}}` + +const askWaiting = `{"head":"Which storage shape should this use?","kind":"choice","reason":"two shapes fit and the record does not choose","stakes":"reversible","options":[{"key":"1","label":"sqlite"},{"key":"2","label":"jsonl"}],"pick":{"key":"1","reason":"the readers we have are sqlite"}}` + +// AN ASK THAT SAYS THE TURN DOES NOT WAIT COMES BACK AT ONCE, and the question +// it raised is still standing when it does. It is the whole of D8: the model +// carries on with what does not depend on the answer. +func TestAnAskThatDoesNotWaitComesBackAtOnceAndTheQuestionStands(t *testing.T) { + a := askTestAgent(t, true) + done := make(chan string, 1) + go func() { + text, _, _ := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)) + done <- text + }() + var text string + select { + case text = <-done: + case <-time.After(2 * time.Second): + t.Fatal("an ask that does not wait waited") + } + if !strings.HasPrefix(text, askAskedWord+" · question 1 · ") { + t.Fatalf("the asker was not told what happened: %q", text) + } + if !strings.Contains(text, askAnsweredWord) { + t.Fatalf("the asker was not told what its answer will look like: %q", text) + } + open := a.OpenQuestions() + if len(open) != 1 || open[0].ID != 1 { + t.Fatalf("the question did not stay open: %+v", open) + } + if open[0].Blocking.Turn { + t.Fatal("the question says the turn is waiting on it, and it is not") + } +} + +// THE ANSWER READS THE SAME WHETHER A CALL WAS PARKED OR NOT. A model that +// asked without waiting must be handed exactly what a model that waited is +// handed, or there are two contracts and only one of them was ever tested. +func TestTheAnswerReadsTheSameWhetherTheTurnWaitedOrNot(t *testing.T) { + answered := func(raw string, parked bool) string { + t.Helper() + a := askTestAgent(t, true) + done := make(chan string, 1) + go func() { + text, _, _ := a.executeAsk(context.Background(), json.RawMessage(raw)) + done <- text + }() + q := waitForOneQuestion(t, a) + if !parked { + <-done + } + answer := Answer{Kind: QuestionAsk, ID: q.ID, Key: "2", Change: "but keep the sqlite file"} + if err := a.ResolveQuestion(answer); err != nil { + t.Fatal(err) + } + if parked { + select { + case text := <-done: + return text + case <-time.After(2 * time.Second): + t.Fatal("the parked call was never handed its answer") + } + } + deadline := time.After(2 * time.Second) + for { + if notes := askDelivered(a); len(notes) > 0 { + return notes[len(notes)-1] + } + select { + case <-deadline: + t.Fatal("the answer never reached the conversation") + default: + time.Sleep(time.Millisecond) + } + } + } + waited := answered(askWaiting, true) + carried := answered(askNotWaiting, false) + // The answers were given a moment apart, so the one field that cannot be + // equal is the instant; everything else is the same bytes. + strip := func(text string) string { + answer := askAnswerRead(t, text) + answer.At = time.Time{} + at := strings.IndexByte(text, '\n') + return text[:at+1] + answerJSON(answer) + } + if strip(waited) != strip(carried) { + t.Fatalf("the two forms read differently:\n parked: %s\n message: %s", strip(waited), strip(carried)) + } + if !strings.Contains(carried, "but keep the sqlite file") { + t.Fatalf("the person's own words did not travel: %q", carried) + } +} + +// A PICK THE CLOCK TAKES IS PROVISIONAL AND SAYS SO. The person has not said +// this; a model that read it as settled would never mention it again. +func TestAPickTakenByTheClockReadsAsProvisionalAndRunsWithNobodyParked(t *testing.T) { + a := askTestAgent(t, true) + if err := a.SetAutonomy(AskChoice, Policy{Kind: PolicyRecommendThenAuto, After: time.Hour}); err != nil { + t.Fatal(err) + } + text, _, _ := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)) + if !strings.HasPrefix(text, askAskedWord) { + t.Fatalf("the ask did not come back at once: %q", text) + } + q := waitForOneQuestion(t, a) + // THE CLOCK IS ARMED ON A QUESTION NOBODY IS PARKED ON, which is the half + // that used to be impossible: it lived inside the call's own select. + a.mu.Lock() + open := a.asked.atLocked(q.ID) + armed := open != nil && open.clock != nil + a.mu.Unlock() + if !armed { + t.Fatal("no clock was armed on a question the call did not wait for") + } + if q.Deadline.IsZero() { + t.Fatal("the question carries no deadline for a surface to draw") + } + // AND IT IS RUN RATHER THAN WAITED FOR. An hour is on the dial above, so + // what this asserts is the clock's own road and not the speed of this box; + // a test that slept would be a wall-clock gate, which PERF.md bans. + a.askClockRanOut(q.ID) + notes := askDelivered(a) + if len(notes) == 0 { + t.Fatal("the clock never took the pick on a question nobody was parked on") + } + note := notes[len(notes)-1] + if !strings.Contains(note, "provisional") { + t.Fatalf("the pick was not marked provisional: %q", note) + } + answer := askAnswerRead(t, note) + if answer.DecidedBy != DecidedByDial || answer.FirstKey() != "1" { + t.Fatalf("the clock did not take the asker's own pick: %+v", answer) + } +} + +// A KEY ON THE QUESTION STOPS THE CLOCK IN THE ENGINE, not only in the drawing. +// The deadline is the one source of truth for the countdown, so it goes, and the +// question is said again with it gone. +func TestAHoldStopsTheClockAndClearsTheDeadline(t *testing.T) { + a := askTestAgent(t, true) + if err := a.SetAutonomy(AskChoice, Policy{Kind: PolicyRecommendThenAuto, After: time.Hour}); err != nil { + t.Fatal(err) + } + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if q.Deadline.IsZero() { + t.Fatal("a recommend-then-auto question carries no deadline for a surface to draw") + } + a.HoldQuestion(QuestionAsk, q.Token()) + held := waitForOneQuestion(t, a) + if !held.Deadline.IsZero() { + t.Fatalf("the deadline survived the hold: %v", held.Deadline) + } + // AND A CLOCK THAT RAN OUT ANYWAY DECIDES NOTHING. Running it is what + // proves the hold reached the engine rather than only the drawing — a sleep + // would prove only that this box was quick enough. + a.askClockRanOut(q.ID) + if notes := askDelivered(a); len(notes) > 0 { + t.Fatalf("the clock answered a question somebody was reading: %v", notes) + } + if open := a.OpenQuestions(); len(open) != 1 { + t.Fatalf("the held question is not still open: %+v", open) + } +} + +// A SETTLED REVERSIBLE DECISION CAN BE CHANGED, and the correction says what it +// was changed from — because whatever was done on the old answer is now the +// thing to look at. +func TestChangingAnAnswerReachesTheModelAsACorrection(t *testing.T) { + a := askTestAgent(t, true) + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "1"}); err != nil { + t.Fatal(err) + } + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "2", Revises: true}); err != nil { + t.Fatal(err) + } + notes := askDelivered(a) + if len(notes) != 2 { + t.Fatalf("the answer and the change did not both reach the conversation: %v", notes) + } + change := notes[1] + if !strings.HasPrefix(change, askChangedWord) || !strings.Contains(change, "was sqlite, now jsonl") { + t.Fatalf("the correction does not say what changed: %q", change) + } + // AND THE RECORD KEEPS THE NEWER DECISION, which is what the gate reads — + // with the clause that keeps two lines about one head from reading as a + // contradiction. + records := a.Decisions() + if len(records) != 2 || records[1].Words() != "jsonl" { + t.Fatalf("the record does not end on the changed answer: %+v", records) + } + if line := records[1].Line(); !strings.Contains(line, "changed from sqlite") { + t.Fatalf("the changed line does not say what it replaced: %q", line) + } + decided, ok := decidedAlready(records, q) + if !ok || decided.Words() != "jsonl" { + t.Fatalf("the gate would answer the next ask with the old decision: %+v", decided) + } +} + +// AN IRREVERSIBLE DECISION IS NOT CHANGED FROM A RECEIPT. What it allowed has +// already happened, and a key that pretended otherwise would be a lie on the +// screen. +func TestAnIrreversibleDecisionCannotBeChanged(t *testing.T) { + a := askTestAgent(t, true) + raw := `{"head":"Delete the release branch?","kind":"confirmation","reason":"it cannot be recovered","stakes":"irreversible","blocking":{"turn":false},"options":[{"key":"1","label":"delete it"},{"key":"2","label":"keep it","safe":true}]}` + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "1"}); err != nil { + t.Fatal(err) + } + err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "2", Revises: true}) + if err == nil { + t.Fatal("an irreversible decision was changed") + } + if !strings.Contains(err.Error(), "cannot be changed") { + t.Fatalf("the refusal does not say why: %v", err) + } + if notes := askDelivered(a); len(notes) != 1 { + t.Fatalf("the refused change still reached the model: %v", notes) + } +} + +// A REVISION OF A QUESTION THIS CONVERSATION NEVER ASKED IS REFUSED rather than +// silently dropped: a person who pressed a key is owed the sentence. +func TestAChangeToAQuestionTheLaneNeverHeldIsRefused(t *testing.T) { + a := askTestAgent(t, true) + err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: 9, Key: "1", Revises: true}) + if err == nil || !strings.Contains(err.Error(), "earlier run") { + t.Fatalf("a change to nothing was taken: %v", err) + } + if err := a.ResolveQuestion(Answer{Kind: QuestionStanding, ID: 1, Key: "1", Revises: true}); err == nil { + t.Fatal("a lane that cannot take a change took one") + } +} + +// A WIDENING YES TAKEN BACK IS A PERMISSION REVOKED: the gate asks again. +func TestChangingAnAlwaysRevokesThePermission(t *testing.T) { + a := askTestAgent(t, true) + a.rememberConsent("bash", true) + a.recordDecision(DecisionRecord{ + ID: 4, Kind: QuestionConsent, Ask: AskPermission, Head: "needs your ok to run bash", + Subject: SubjectRef{Kind: SubjectCall, Name: "bash"}, + Picked: []string{"2"}, By: DecidedByPerson, Stakes: StakesCostly, At: time.Now(), + }) + if err := a.ResolveQuestion(Answer{Kind: QuestionConsent, ID: 4, Key: "3", Revises: true}); err != nil { + t.Fatal(err) + } + if _, known := a.rememberedConsent("bash"); known { + t.Fatal("the permission still stands after it was taken back") + } +} + +// AN ANSWER TO A QUESTION THIS LANE IS NOT HOLDING IS REFUSED IN WORDS. It is +// the row left on a screen by a turn that was interrupted, or by a session host +// that has restarted since — and the person pressing the key is owed the +// sentence rather than a key that does nothing. +func TestAnAnswerToAQuestionTheLaneNoLongerHoldsIsRefusedInWords(t *testing.T) { + a := askTestAgent(t, true) + err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: 4, Key: "1"}) + if err == nil { + t.Fatal("an answer to a question nothing is asking was taken in silence") + } + if !strings.Contains(err.Error(), "restarted") { + t.Fatalf("the refusal does not say what happened: %v", err) + } + // AND A SECOND CLICK ON AN ANSWERED QUESTION IS STILL SILENT, which is the + // law this one must not have broken (answers.go). + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "1"}); err != nil { + t.Fatal(err) + } + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "2"}); err != nil { + t.Fatalf("a second click was refused: %v", err) + } +} + +// AND A CHANGE AFTER A TURN WAITED FOR ITS ANSWER IS STILL A MESSAGE. The call +// that was parked has taken its answer and gone; the correction cannot be handed +// to it, and a channel nobody reads is where this was lost on the Spark before +// the wait was claimed on the handover (askwait.go). +func TestChangingAnAnswerTheTurnWaitedForReachesTheModel(t *testing.T) { + a := askTestAgent(t, true) + done := make(chan string, 1) + go func() { + text, _, _ := a.executeAsk(context.Background(), json.RawMessage(askWaiting)) + done <- text + }() + q := waitForOneQuestion(t, a) + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "1"}); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("the parked call was never handed its answer") + } + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "2", Revises: true}); err != nil { + t.Fatal(err) + } + deadline := time.After(2 * time.Second) + for { + notes := askDelivered(a) + if len(notes) > 0 { + if !strings.HasPrefix(notes[len(notes)-1], askChangedWord) { + t.Fatalf("what reached the model is not the correction: %q", notes[len(notes)-1]) + } + return + } + select { + case <-deadline: + t.Fatal("the change never reached the model") + default: + time.Sleep(time.Millisecond) + } + } +} + +// deskRows is how many question rows this session is publishing for another +// window to read (taskpresence.go's desk). +func deskRows(a *Agent) int { + if a.presence == nil { + return 0 + } + a.presence.mu.Lock() + defer a.presence.mu.Unlock() + return len(a.presence.asks) +} + +// AN ANSWERED QUESTION LEAVES THE DESK, and it is the answer that takes it off +// rather than the call returning. +// +// Measured on the Spark, 2026-09-11: with the let-go still deferred in the call, +// a question the call did not wait for kept its presence row for the rest of the +// session — so home drew that answered row as the thing this conversation was +// waiting for, every time any other question came up, and a key pressed on it +// did nothing at all. +func TestAnAnswerTakesTheQuestionOffTheDeskWhenNobodyWasParked(t *testing.T) { + a := askTestAgent(t, true) + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if rows := deskRows(a); rows != 1 { + t.Fatalf("the question never reached the desk another window reads: %d rows", rows) + } + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "2"}); err != nil { + t.Fatal(err) + } + if rows := deskRows(a); rows != 0 { + t.Fatalf("an answered question is still on the desk: %d rows", rows) + } + if open := a.OpenQuestions(); len(open) != 0 { + t.Fatalf("an answered question is still open: %+v", open) + } +} + +// A QUESTION NOTHING WAITS ON DOES NOT SAY THIS CONVERSATION IS WAITING ON YOU. +// It is working, with a question standing beside the work — which is the whole +// of what asking without waiting means, and what every other window and the home +// page read off the presence file. +func TestAQuestionTheTurnDoesNotWaitForIsNotWaitingOnYou(t *testing.T) { + a := askTestAgent(t, true) + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + waitForOneQuestion(t, a) + if waiting := a.waitingOnPerson(); waiting.waiting { + t.Fatalf("a question the turn does not wait for made the conversation say it was waiting: %+v", waiting) + } +} + +// NOTHING ARMED OUTLIVES THE SESSION THAT ARMED IT. A clock that fired after the +// door shut would write a decision into the record with nobody left to read the +// answer — and the next run of this conversation reads that record as something +// the person settled. +func TestAClockThatRunsOutAfterTheSessionClosedDecidesNothing(t *testing.T) { + a := askTestAgent(t, true) + if err := a.SetAutonomy(AskChoice, Policy{Kind: PolicyRecommendThenAuto, After: time.Hour}); err != nil { + t.Fatal(err) + } + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + before := len(a.Decisions()) + if err := a.Close(); err != nil { + t.Fatal(err) + } + a.askClockRanOut(q.ID) + if after := len(a.Decisions()); after != before { + t.Fatalf("the clock wrote a decision after the session closed: %d → %d", before, after) + } +} + +// AND AN ANSWER NOBODY CAN READ IS NOT A DECISION EITHER. A person's key press +// that lands on a conversation which has since closed is refused rather than +// recorded, for the same reason: what makes a decision real is that the model +// was told. +func TestAnAnswerToAClosedConversationIsRefusedAndRecordsNothing(t *testing.T) { + a := askTestAgent(t, true) + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + before := len(a.Decisions()) + if err := a.Close(); err != nil { + t.Fatal(err) + } + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "2"}); err == nil { + t.Fatal("an answer to a closed conversation was taken as though it had been read") + } + if after := len(a.Decisions()); after != before { + t.Fatalf("a decision was recorded for an answer nothing read: %d → %d", before, after) + } +} + +// THE FIRST ANSWER WINS AND THE SECOND CHANGES NOTHING — including the record. +// The clock and a person can reach this door in the same instant, and a loser +// that wrote its own record left the windows and the transcript showing one +// answer while the model had been told the other. +func TestTheSecondAnswerToOneQuestionRecordsNothing(t *testing.T) { + a := askTestAgent(t, true) + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "2"}); err != nil { + t.Fatal(err) + } + records := a.Decisions() + if len(records) != 1 { + t.Fatalf("one answer wrote %d records", len(records)) + } + // The loser says nothing to anybody: a second key press on a question that + // is already answered is not a fault. + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "1"}); err != nil { + t.Fatalf("a second answer was reported as a failure: %v", err) + } + after := a.Decisions() + if len(after) != 1 { + t.Fatalf("the loser of the race wrote a record: %+v", after) + } + if after[0].Words() != records[0].Words() { + t.Fatalf("the record changed under the winner: %q → %q", records[0].Words(), after[0].Words()) + } + notes := askDelivered(a) + if len(notes) != 1 { + t.Fatalf("the model was told about a question twice: %v", notes) + } +} + +// A RATIFY WAITS ON NOBODY, which is its own definition ("something reversible +// was DONE … nothing waits on the answer") and could not be true until an answer +// could arrive with nobody parked. So the call comes back at once, the row stands +// for as long as somebody wants to unwind the thing, and no window says this +// conversation is waiting on them. +func TestARatifyDoesNotStopTheTurnAndIsNotWaitingOnYou(t *testing.T) { + a := askTestAgent(t, true) + const ratify = `{"head":"I renamed the two fixtures to match the test names","kind":"ratify","reason":"it was reversible and the names were already wrong","stakes":"reversible","options":[{"key":"1","label":"fine"},{"key":"2","label":"put it back","safe":true}]}` + done := make(chan string, 1) + go func() { + text, _, _ := a.executeAsk(context.Background(), json.RawMessage(ratify)) + done <- text + }() + select { + case text := <-done: + if !strings.HasPrefix(text, askShownLead) { + t.Fatalf("a ratify did not come back as a standing question: %q", text) + } + case <-time.After(2 * time.Second): + t.Fatal("the turn parked on a question its own rung says nobody waits for") + } + q := waitForOneQuestion(t, a) + if q.Blocking.Blocks() { + t.Fatalf("a ratify says something is waiting on it: %+v", q.Blocking) + } + if waiting := a.waitingOnPerson(); waiting.waiting { + t.Fatal("a ratify put `waiting on you` on the presence file") + } + // AND IT IS THE RUNG'S OWN ANSWER, NOT THE ASKER'S ([AskKind.Waits]): a + // ratify that writes `blocking: {turn: true}` still stops nothing, which is + // pinned from the other end by TestARatifyThatAskedToStopTheTurnStopsNothing. +} diff --git a/internal/session/askwait.go b/internal/session/askwait.go index f92b549c82..d71ff323c1 100644 --- a/internal/session/askwait.go +++ b/internal/session/askwait.go @@ -25,6 +25,22 @@ package session // // There is no fourth, and nothing outside this file may reach the channels. // +// ── AND AN ANSWERED QUESTION IS KEPT, BECAUSE IT CAN BE CHANGED ── +// +// A decision the person made is still theirs afterwards: while its receipt is on +// screen they may answer it again (`c change`, tui3's questionchange.go), and a +// permission they widened may be handed back whole (`u undo`). So the entry does +// not leave this book when it is answered — [askOpen.settled] is filled in and +// the entry stays, which is the only way the revision can be rendered against +// what was actually asked and say what it replaced. It leaves when the turn +// retires it, when the call that asked is abandoned, or with the session. +// +// THE OWNERSHIP MOVES DOWN ONE FIELD WITH IT. `THE ENTRY IS THE OWNERSHIP` holds +// for the three endings that take the entry out of the map; the answer claims +// the WAIT instead ([askedOfThePerson.answerLocked] takes `open.wait` and leaves +// nil behind), which is the same primitive one field lower and is what +// [askedOfThePerson.askedBackLocked] already did. +// // ── AND A QUESTION CAN OUTLIVE THE CALL THAT ASKED IT ── // // Two shapes do it: a RATIFY, which nothing waits on ([AskKind.Waits]), and a @@ -55,6 +71,8 @@ package session // a.mu held, like everything else here, without the unlock-and-hope the raw map // needed around it. +import "time" + // askedOfThePerson is every question the model has put that this session is // still parked on, keyed by the ask's own id. The zero value is usable and holds // nothing. @@ -77,6 +95,19 @@ type askOpen struct { theirs map[string]string wait chan Answer letGo func() + // clock is the policy's own timer where the question has one. It belongs to + // the LANE and not to the call, so it runs on a question nobody is parked on + // — which is every ratify and every ask whose turn did not wait + // (asklane.go's [Agent.startAskClock]). + clock *time.Timer + // held says a person pressed a key on this question and the clock will never + // answer it. There is no way back: a clock that resumed would fire exactly + // when somebody looked away mid-decision. + held bool + // settled is the answer that ended it, and nil while it is still open. It is + // kept because a settled reversible decision can still be CHANGED, and the + // correction says what it was changed from. + settled *Answer } // parkLocked registers one ask and answers the channel its lane waits on, which @@ -86,7 +117,11 @@ func (asked *askedOfThePerson) parkLocked(open *askOpen) <-chan Answer { if asked.parked == nil { asked.parked = make(map[uint64]*askOpen) } - if open.q.Ask.Waits() { + // THE FIELD IS THE DERIVATION. `Blocking.Turn` is built in [Agent.executeAsk] + // from whether this call really parks — the kind's own answer AND the asker's + // own word — so reading it here is reading that one decision rather than + // making a second one that could disagree with the row on the screen. + if open.q.Blocking.Turn { // Buffered to one and read at most once, so the road that applies the // answer never blocks on the lane having got to its select. open.wait = make(chan Answer, 1) @@ -107,6 +142,20 @@ func (asked *askedOfThePerson) claimLocked(id uint64) (*askOpen, bool) { return open, true } +// stopClocksLocked stops every clock this book has armed, because NOTHING ARMED +// OUTLIVES THE SESSION THAT ARMED IT (steer_grace.go states the same law for a +// steer's grace). A clock that fired after the door shut would answer a question +// with nobody to read the answer: the note would be refused by a closed queue +// and the decision would still be in the record, where the next run of this +// conversation reads it as something the person settled. +func (asked *askedOfThePerson) stopClocksLocked() { + for _, open := range asked.parked { + if open != nil && open.clock != nil { + open.clock.Stop() + } + } +} + // atLocked is what is standing under one id, without taking it. It is for the // readings — the presence file's, the sweep's — and never for an ending. func (asked *askedOfThePerson) atLocked(id uint64) *askOpen { return asked.parked[id] } @@ -116,17 +165,50 @@ func (asked *askedOfThePerson) atLocked(id uint64) *askOpen { return asked.parke // whose call is long gone; false is an answer that arrived after the question // stopped being one, which every caller treats as nothing to do rather than as // a fault. -func (asked *askedOfThePerson) answerLocked(id uint64, answer Answer) (*askOpen, bool) { - open, parked := asked.claimLocked(id) - if !parked { - return nil, false +func (asked *askedOfThePerson) answerLocked(id uint64, answer Answer) (*askOpen, chan Answer, error) { + open, parked := asked.parked[id] + if !parked || open == nil { + // A QUESTION THIS BOOK HAS NO ENTRY FOR IS NOT A LATE ANSWER, AND THE + // DIFFERENCE IS WORTH A SENTENCE. What reaches here is a row on somebody + // screen that this engine is not asking any more — the turn was + // interrupted and took the question with it, they talked past it, or the + // session host has restarted since it was drawn and the book went with + // the process. + return nil, nil, errAnswerGone + } + if open.settled != nil && !answer.Revises { + // THE FIRST ANSWER WINS, AND THE SECOND CHANGES NOTHING AT ALL — + // including the record. The clock and a person can reach the door in the + // same instant, and a loser that said nothing was recorded and announced + // as the decision while the model had already been told the other one. + return nil, nil, errAnswerSettled } - if open.wait != nil { - // The claim above is the ownership: the entry is out of the map, so no - // other road can reach this channel and the field is left standing as - // the caller's own reading of WHO GOT THE ANSWER — a lane still parked, - // or nobody, which is a question that outlived its call. - open.wait <- answer + settled := answer + open.settled = &settled + if open.clock != nil { + open.clock.Stop() + } + // THE WAIT IS CLAIMED AND THE ENTRY STAYS. Nothing else can reach this + // channel afterwards, so a correction that arrives once the call has taken + // its answer and gone is a MESSAGE rather than a send into a channel nobody + // reads — which is measurable: without it, a change to a question the turn + // had waited for was recorded, drawn, and never heard by the model + // (the Spark, 2026-09-11). + wait := open.wait + open.wait = nil + if wait != nil { + wait <- answer + } + return open, wait, nil +} + +// settledLocked is one question this book holds and somebody has already +// answered, for the revision that wants to know what was asked and what was +// decided last time. +func (asked *askedOfThePerson) settledLocked(id uint64) (*askOpen, bool) { + open, parked := asked.parked[id] + if !parked || open == nil || open.settled == nil { + return nil, false } return open, true } @@ -211,6 +293,18 @@ func (asked *askedOfThePerson) retireLocked() []*askOpen { if open == nil || open.wait != nil || questionOutlivesTurn(open.q) { continue } + if open.settled != nil { + // AN ANSWERED QUESTION IS NOT RETIRED, IT IS CLEARED. Nothing is + // drawn for it any more — the answer took its row down — and what is + // left is the entry a revision reads. It goes without a withdrawal, + // because withdrawing a question somebody has answered would put + // `no longer needed` on the screen about their own decision. + if open.clock != nil { + open.clock.Stop() + } + delete(asked.parked, id) + continue + } if taken, parked := asked.claimLocked(id); parked { gone = append(gone, taken) } @@ -233,7 +327,14 @@ func (asked *askedOfThePerson) openLocked() []uint64 { return nil } ids := make([]uint64, 0, len(asked.parked)) - for id := range asked.parked { + for id, open := range asked.parked { + if open == nil || open.settled != nil { + // AN ANSWERED QUESTION IS NOT AN OPEN ONE. The entry is kept so the + // decision can be changed while its receipt is on screen (the header + // says why); what is drawn, counted and replayed is what is still a + // question. + continue + } ids = append(ids, id) } return ids diff --git a/internal/session/autonomy.go b/internal/session/autonomy.go index 1c712c052a..1751a90217 100644 --- a/internal/session/autonomy.go +++ b/internal/session/autonomy.go @@ -11,6 +11,29 @@ import ( const awayAfter = 10 * time.Minute +// autonomyClock is THE ONE DERIVATION of how long a question's clock runs before +// the asker's own pick is taken, and it is deliberately the only arithmetic in +// this program about that length. +// +// THE NUMBER IS THE PERSON'S OWN. `set` is what they chose for this shape of +// question ([Agent.SetAutonomy]); where they chose a shape and no length, it is +// [awayAfter] — the boundary this program already uses everywhere else to +// conclude that nobody is at the keyboard. So a pick is taken for somebody only +// after exactly as long as it would take to decide they are not coming, and +// there is no second figure anywhere that could drift from it. +// +// A CLOCK IS OPT-IN AND STAYS OPT-IN (owner ruling, 2026-09-11): nothing here +// gives a question a clock. [Agent.autonomyFor] decides WHETHER there is one — +// only where the person set a rule, plus the assumption kind, whose whole shape +// is that its options stand unless somebody strikes them — and this decides only +// how long. +func autonomyClock(set time.Duration) time.Duration { + if set > 0 { + return set + } + return awayAfter +} + // autonomyFile is deliberately inside the project: the same kind of question // may deserve a different answer in two projects, and neither should leak. func (a *Agent) autonomyFile() string { @@ -40,8 +63,12 @@ func (a *Agent) SetAutonomy(kind AskKind, policy Policy) error { if policy.Kind == "" { policy.Kind = PolicyAsk } - if policy.Kind == PolicyRecommendThenAuto && policy.After <= 0 { - return errors.New("recommend-then-auto needs a positive wait") + if policy.Kind == PolicyRecommendThenAuto { + // A LENGTH IS OPTIONAL AND NEVER MISSING. A row set from the settings + // sheet says "recommend then go" and names no number, and refusing it + // would make the one door harder to reach than the slash command it + // exists to replace ([autonomyClock] is where the figure comes from). + policy.After = autonomyClock(policy.After) } path := a.autonomyFile() if path == "" { @@ -92,10 +119,17 @@ func (a *Agent) autonomyFor(kind AskKind) Policy { return Policy{Kind: PolicyAsk} } if policy, ok := a.readAutonomy()[kind]; ok { + if policy.Kind == PolicyRecommendThenAuto { + policy.After = autonomyClock(policy.After) + } return policy } if kind == AskAssumption { - return Policy{Kind: PolicyRecommendThenAuto, After: awayAfter} + // THE ONE KIND WITH A CLOCK NOBODY ASKED FOR, and it is the kind whose + // own shape is that nothing is decided at the end of it: an assumptions + // card's options STAND unless they are struck, so the clock ends a + // reading and not a decision (docs/design/questions/DESIGN.md's table). + return Policy{Kind: PolicyRecommendThenAuto, After: autonomyClock(0)} } return Policy{Kind: PolicyAsk} } diff --git a/internal/session/connectcaps.go b/internal/session/connectcaps.go index b678643dbe..7a7be6ca1b 100644 --- a/internal/session/connectcaps.go +++ b/internal/session/connectcaps.go @@ -361,23 +361,28 @@ func (a *Agent) serviceName(service string) string { // message is refusing one message; taking a capability away for good is a // decision with its own control, on a page they can see, and it should not be a // side effect of a keystroke in a prompt. -func (a *Agent) rememberCapability(tool string, args json.RawMessage, allow bool) bool { +func (a *Agent) rememberCapability(tool string, args json.RawMessage, allow bool) (grantMade, bool) { if !allow || a.connect == nil { - return false + return grantMade{}, false } service := a.serviceOf(tool) if service == "" { - return false + return grantMade{}, false } capability := a.capabilityOf(service, tool, args) if capability == "" { - return false + return grantMade{}, false } if err := a.connect.SetCapabilityState(service, capability, connect.StateYes); err != nil { // The answer could not be written down. The call it was given for still // runs — that is what the person just said — and the memo takes the // standing half of it for this session rather than losing it entirely. - return false + return grantMade{}, false } - return true + // IT ANSWERS WHAT IT WROTE, not merely that it wrote. Taking a permission + // back needs the same two names this used to leave the account on, and + // working them out a second time from the arguments would be a second + // reading of [Agent.capabilityOf] that could disagree with this one — the + // revision has no arguments to read. + return grantMade{service: service, capability: capability}, true } diff --git a/internal/session/connectcaps_test.go b/internal/session/connectcaps_test.go index 6280881d58..053481c031 100644 --- a/internal/session/connectcaps_test.go +++ b/internal/session/connectcaps_test.go @@ -463,13 +463,13 @@ func TestAlwaysOnAnOrdinaryToolStillUsesTheMemo(t *testing.T) { agent := connectAgent(t, &scriptedCompleter{}, hub, true) for _, tool := range []string{"bash", "read", "services", ""} { - if agent.rememberCapability(tool, nil, true) { + if _, wrote := agent.rememberCapability(tool, nil, true); wrote { t.Errorf("%q was treated as a call against an account", tool) } } // And a no, even on a connector tool, is not an off: taking a capability // away for good is a decision with its own control. - if agent.rememberCapability("gmail_send", nil, false) { + if _, wrote := agent.rememberCapability("gmail_send", nil, false); wrote { t.Error("a refusal was written to the settings as an answer that lasts") } if got := hub.CapabilityState("google", "mail-send"); got != connect.StateAsk { diff --git a/internal/session/consent.go b/internal/session/consent.go index 98391e6c4e..47423b69ea 100644 --- a/internal/session/consent.go +++ b/internal/session/consent.go @@ -402,10 +402,15 @@ func (a *Agent) askAnswer(ctx context.Context, hub *eventHub, call ai.ToolCall, // one answer would drift the moment somebody set the row back to ask // in the panel and went on not being asked here for the rest of the // session. Everything else keeps the memo it always had. - if a.rememberCapability(call.Function.Name, json.RawMessage(call.Function.Arguments), answer.allow) { + // WHAT IT BOUGHT IS WRITTEN DOWN WHERE IT IS BOUGHT, so changing + // your mind on the receipt can take back exactly this and nothing + // else ([Agent.undoGrant]). + if made, wrote := a.rememberCapability(call.Function.Name, json.RawMessage(call.Function.Arguments), answer.allow); wrote { + a.rememberGrant(call.Function.Name, made) return answer, nil } a.rememberConsent(call.Function.Name, answer.allow) + a.rememberGrant(call.Function.Name, grantMade{}) } return answer, nil case <-ctx.Done(): @@ -423,6 +428,59 @@ func (a *Agent) rememberConsent(tool string, allow bool) { a.consentMemo[tool] = allow } +// grantMade is the standing half of a yes, remembered so it can be TAKEN BACK. +// +// A widening yes is written into more than one place, and which one depends on +// what the tool is: an ordinary tool gets the session memo above, and a +// connected service's tool gets a capability left on in the person's own account +// settings ([Agent.rememberCapability]) INSTEAD of the memo. A revision that +// only deleted the memo therefore said "the permission is taken back" on the +// receipt while the account went on being allowed — so what was granted is +// written down here at the moment it is granted, and [Agent.undoGrant] is the +// one door that takes back all of it. +type grantMade struct { + service string + capability string +} + +// rememberGrant banks what a standing yes bought, keyed by the tool it was about +// — the same key the memo uses, because a second yes for one tool replaces the +// first and undoing the newest is what changing your mind on a receipt means. +func (a *Agent) rememberGrant(tool string, made grantMade) { + a.mu.Lock() + defer a.mu.Unlock() + if a.grants == nil { + a.grants = make(map[string]grantMade, 1) + } + a.grants[tool] = made +} + +// undoGrant takes back everything a standing yes bought for one tool: the memo +// that stops this session asking again, and the capability left on in the +// person's account settings where the yes was written there instead. +// +// THE THIRD STORE IS THE SURFACE'S AND CANNOT BE REACHED FROM HERE. A yes given +// as a RULE is written to the person's settings by the window that asked +// (tui3's `app.rememberAlways`), where `/permissions` shows it; the surface +// removes its own with `app.forgetAlways` on the same key press that sends the +// revision. That is the whole of what this engine cannot do, and it is named +// here so the next reader does not conclude there are two doors. +func (a *Agent) undoGrant(tool string) { + a.mu.Lock() + delete(a.consentMemo, tool) + made, granted := a.grants[tool] + delete(a.grants, tool) + connected := a.connect + a.mu.Unlock() + if !granted || connected == nil || made.service == "" || made.capability == "" { + return + } + // BACK TO ASKING, NOT TO NO. The person is taking back a standing yes, and + // what they had before they gave it was a question — turning it into a + // refusal would deny the next call something they never said no to. + _ = connected.SetCapabilityState(made.service, made.capability, connect.StateAsk) +} + func (a *Agent) rememberedConsent(tool string) (bool, bool) { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/session/principal_structure_test.go b/internal/session/principal_structure_test.go index 031a7ed8a1..b4e5654a5b 100644 --- a/internal/session/principal_structure_test.go +++ b/internal/session/principal_structure_test.go @@ -56,6 +56,11 @@ var wakeRoads = map[string]string{ "and nobody else — a worker's turns are its runner's to start ([Agent.wakeLocked] " + "declines inside a task) — so it never reaches the wake a person would have to be " + "there for, and it is marked as the session's own words rather than as theirs", + "answerNote": "THE ANSWER TO A QUESTION THIS SESSION PUT, delivered when no call is parked " + + "on it (asklane.go). It is the one wake that cannot be addressed to nobody: it exists " + + "because somebody answered — a key on a screen, or the dial they set for this shape of " + + "question — so the person the sentence is for has just been there by construction, and " + + "what it says is their own decision read back", "postTaskMessage": "THE ONE THAT MATTERED, and the seam both roads into a landed node's " + "news take ([Agent.deliverTaskNote] announces the ending, " + "[Agent.bubbleUnverifiedChildren] re-addresses what is still owed). The note is built " + diff --git a/internal/session/question.go b/internal/session/question.go index 827d096e29..070e1d0220 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -1051,6 +1051,17 @@ type DecisionRecord struct { // preference is later written from. Empty is the ordinary case and nothing // is drawn for it. Why string `json:"why,omitempty"` + // Was is what this decision replaced, in the words it was read under, and it + // is filled on a CHANGED decision alone ([Answer.Revises]). + // + // THE LINE HAS TO SAY IT OR THE RECORD READS AS A CONTRADICTION. Two lines + // with the same head and different answers is exactly what a person changing + // their mind leaves behind, and a reader — the model, the mark reader, the + // person three days later — cannot tell that from the program having asked + // the same thing twice and got two answers. Measured on the Spark, + // 2026-09-11: a side-call read the record, found "Hello" and a file saying + // "Hola", and set about "fixing" the file. + Was []string `json:"was,omitempty"` // At is when. At time.Time `json:"at"` } @@ -1136,6 +1147,11 @@ func (r DecisionRecord) LineClauses() []DecisionClause { if change := strings.TrimSpace(r.Change); change != "" { clauses = append(clauses, DecisionClause{Text: "with: " + change, GiveUp: 2}) } + if len(r.Was) > 0 { + // IT IS NEVER GIVEN UP. A changed decision that dropped the clause + // saying so is the contradiction this field exists to prevent. + clauses = append(clauses, DecisionClause{Text: "changed from " + strings.Join(r.Was, ", ")}) + } if by := strings.TrimSpace(string(r.By)); by != "" { clauses = append(clauses, DecisionClause{Text: decidedByWord(r.By)}) } @@ -1826,6 +1842,26 @@ func (a *Agent) ResolveQuestion(answer Answer) error { if strings.TrimSpace(answer.Key) == "" { answer.Key = answer.FirstKey() } + // A REVISION IS THE OTHER THING AN ANSWER CAN BE, and it takes its own road + // for one reason: every lane below is a resolver handing an answer to + // somebody WAITING for it, and a question that has been settled has nobody + // waiting. [Agent.reviseLane] applies it and answers what was asked, so the + // record and the announcement below are made exactly as they are for a first + // answer — a changed decision is a decision, and it is kept and said the + // same way. + if answer.Revises { + q, prior, err := a.reviseLane(answer) + if err != nil { + return err + } + record := decisionRecordOf(q, answer) + if prior != nil { + record.Was = decisionRecordOf(q, *prior).Labels + } + a.recordDecision(record) + a.emitQuestion(EventQuestionAnswered, q, &answer) + return nil + } // THE WORDS ARE CLAIMED BEFORE THE LANE IS TOUCHED. The lane is about to // return and let go of this question, and letting go of one nobody answered // is withdrawing it ([Agent.rememberQuestion]) — so an answer that had not @@ -1843,6 +1879,16 @@ func (a *Agent) ResolveQuestion(answer Answer) error { q = a.questionForLandingAnswer(answer) } if err := a.applyToLane(answer); err != nil { + if errors.Is(err, errAnswerSettled) { + // THE LOSER OF A RACE RECORDS NOTHING AND SAYS NOTHING. Somebody + // else's answer to this question got there first — the clock and a + // person can both reach this door in the same instant — and it has + // already been recorded, announced and given to the model. Writing a + // second record here is how the record and the windows came to show + // one answer while the model had been told the other. The question is + // NOT put back: it really is answered. + return nil + } // NOTHING WAS DECIDED, SO NOTHING IS FORGOTTEN. The question is still a // question and still has to be drawn. if said && resolvesQuestion(answer) { @@ -2009,9 +2055,11 @@ func (a *Agent) applyToLane(answer Answer) error { words := answer.Words() switch answer.Kind { case QuestionAsk: - // The model's own lane keeps its answers in one place (tools_ask.go): - // the call parked on the question takes it where there is one, and the - // model is told as a message where the question outlived its call. + // THE MODEL'S OWN LANE DELIVERS RATHER THAN RELEASES (asklane.go): the + // answer reaches the call parked on it where there is one, and the + // conversation's own queue where there is not. The delivery cannot block + // and the claim is the ownership, so the handover is one locked step + // rather than the read-unlock-send it was. return a.answerAsk(answer) case QuestionConsent, QuestionTask, QuestionStanding: if answer.Kind == QuestionStanding && key == "" && words != "" { diff --git a/internal/session/questionlaw_test.go b/internal/session/questionlaw_test.go index c6f899427c..77e18d5d15 100644 --- a/internal/session/questionlaw_test.go +++ b/internal/session/questionlaw_test.go @@ -45,10 +45,19 @@ const questionDoor = "raiseQuestion" // ([Agent.putBackQuestion]). const questionPutBack = "putBackQuestion" +// questionRestate is the other road that is not a raise: a question ALREADY +// standing, said again because a fact ON it changed — its clock stopped under +// somebody's hand ([Agent.restateQuestion], asklane.go). It may not go through +// the door: raising it again would put `no longer needed` on the screen of the +// person reading it and hand it a fresh settle guard and a fresh reading clock +// mid-decision. It banks the same question under the same token and says it +// once more, which every surface upserts. +const questionRestate = "restateQuestion" + func TestEveryQuestionIsRaisedThroughTheOneDoor(t *testing.T) { for _, call := range []string{"emitQuestion", "rememberQuestion"} { for _, site := range callSitesIn(t, ".", call) { - if site.fn == questionDoor || site.fn == questionPutBack { + if site.fn == questionDoor || site.fn == questionPutBack || site.fn == questionRestate { continue } if call == "emitQuestion" && !site.raises { diff --git a/internal/session/questionlifecycle_test.go b/internal/session/questionlifecycle_test.go index 837c0c5aab..ce4950f4c4 100644 --- a/internal/session/questionlifecycle_test.go +++ b/internal/session/questionlifecycle_test.go @@ -121,7 +121,7 @@ func TestARatifyIsShownAndWaitsOnNobody(t *testing.T) { t.Fatalf("ResolveQuestion: %v", err) } waitForAsk(t, asks, EventQuestionAnswered) - if note := waitForNote(t, agent, askAnsweredLead); !strings.Contains(note, "put it back") { + if note := waitForNote(t, agent, askAnsweredWord); !strings.Contains(note, "put it back") { t.Fatalf("the answer did not reach the model: %q", note) } if records, _ := ReadDecisions(dir); len(records) != 1 { @@ -170,7 +170,7 @@ func TestAskingBackLeavesTheQuestionOpenAndTheAnswerArrivesLater(t *testing.T) { t.Fatalf("ResolveQuestion: %v", err) } waitForAsk(t, asks, EventQuestionAnswered) - if note := waitForNote(t, agent, askAnsweredLead); !strings.Contains(note, "jsonl") { + if note := waitForNote(t, agent, askAnsweredWord); !strings.Contains(note, "jsonl") { t.Fatalf("the answer never reached the model: %q", note) } if len(agent.OpenQuestions()) != 0 { diff --git a/internal/session/session.go b/internal/session/session.go index ae2bc64b66..7b69830935 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2768,6 +2768,10 @@ type Agent struct { // agent's life only. It is never persisted — a session-scoped answer that // outlived the session would be a settings change nobody made. consentMemo map[string]bool + // grants is what each standing yes BOUGHT, keyed by tool, so that changing + // your mind on the receipt can take back all of it rather than the half this + // session happens to hold ([grantMade], consent.go). + grants map[string]grantMade // connectAsks is the connect questions a person owes an answer to, keyed by // the id the EventConnectAsk carried, and connectSeq is what names them diff --git a/internal/session/steerquestion.go b/internal/session/steerquestion.go index ad42e56f08..91f036b020 100644 --- a/internal/session/steerquestion.go +++ b/internal/session/steerquestion.go @@ -93,7 +93,7 @@ const steerTookTheQuestion = "took this instead of the question" // for the caller to say out loud once the lock is down. // // The ending itself belongs to [askedOfThePerson] and not to this file — it is -// one of the three a parked ask has, beside the answer and the let-go, and it is +// one of the endings an ask has, beside the answer and the let-go, and it is // written there with them (askwait.go). // // ── IT CLAIMS THE WORDS IN THE SAME BREATH AS THE WAIT ── diff --git a/internal/session/taskpresence.go b/internal/session/taskpresence.go index ef24daa984..24cdf2d17d 100644 --- a/internal/session/taskpresence.go +++ b/internal/session/taskpresence.go @@ -716,6 +716,39 @@ func (a *Agent) presenceAskingQuestion(q Question) func() { } } +// presenceRestateQuestion writes one question at the desk again because a fact +// ON it changed — today, its clock stopping ([Agent.holdAsk]). +// +// IT REPLACES THE ROW RATHER THAN ADDING ONE, and it is a replacement rather +// than a withdrawal and a fresh raise because the question is the same question: +// another window reading this file must see the countdown go, not see the +// question disappear and come back. A row nobody banked is not created here — +// there is nothing to restate about a question this desk was never told about. +func (a *Agent) presenceRestateQuestion(q Question) { + desk := a.presence + if desk == nil { + return + } + found := false + desk.mu.Lock() + for at, ask := range desk.asks { + if ask.question.Kind != q.Kind || ask.question.ID != q.ID { + continue + } + restated := ask.question + restated.Options = q.Options + restated.Text = strings.TrimSpace(q.Head) + restated.Full = &q + desk.asks[at].question = restated + found = true + break + } + desk.mu.Unlock() + if found { + a.nudgePresence() + } +} + // beat is the heartbeat: one write now, one on every nudge, one on every tick, // and a removal on the way out. // @@ -933,7 +966,10 @@ func (a *Agent) waitingOnPerson() personAsk { a.mu.Lock() // THE MODEL'S OWN DOOR IS ONE OF THESE LANES, and leaving it out was a // session stopped on a question telling every other window it was `working`. - // [Agent.asked] is what the `ask` tool blocks its turn on (askwait.go); + // [Agent.asked] is the lane's book of what the model has asked (askwait.go), + // and the half of it that BLOCKS is what belongs here + // ([askedOfThePerson.anyLocked]): a question the asker said its turn would + // not wait for is a conversation that is working, not one waiting on you; // the desk already carries the whole question beside it // ([Agent.presenceAskingQuestion]), so the words below are there — it was // only this predicate that did not know to look. Measured in two terminals diff --git a/internal/session/tools_ask.go b/internal/session/tools_ask.go index a99ad791ed..886f1c123f 100644 --- a/internal/session/tools_ask.go +++ b/internal/session/tools_ask.go @@ -44,7 +44,7 @@ const askBlockSchemaJSON = `{"type":"object","properties":{"kind":{"type":"strin // `"Turn waits"`, `"Certainty"` on a low/medium/high enum — plus the tail of the // two long ones. A field whose name and enum already say what it is gets no // description at all, which is the honest amount rather than a missing one. -const askSchemaTemplate = `{"type":"object","properties":{"head":{"type":"string","description":"Decision in one sentence"},"kind":{"type":"string","enum":ASKKINDS,"description":"assumption options stand unless struck; ratify waits on nothing"},"form":{"type":"string","enum":FORMS,"description":"Smallest form evidence needs"},"reason":{"type":"string","description":"Why ask now after cheaper rungs"},"subject":{"type":"object","description":"Existing subject","properties":{"kind":{"type":"string","enum":SUBJECTS},"id":{"type":"integer","description":"Task id"},"callId":{"type":"string"},"ref":{"type":"string","description":"String id"},"name":{"type":"string"}}},"options":{"type":"array","description":"Answers, or assumptions that stand","items":{"type":"object","properties":{"key":{"type":"string","description":"A single digit, 1 upward. Anything else is renumbered and given back as you wrote it"},"label":{"type":"string","description":"Short label"},"body":{"type":"string","description":"Meaning"},"consequence":{"type":"string","description":"What happens"},"safe":{"type":"boolean","description":"Changes nothing"},"widening":{"type":"boolean","description":"Grants more"},"blocks":{"type":"array","description":"Evidence","items":BLOCK},"dimensions":{"type":"object","description":"Value per axis","additionalProperties":{"type":"string"}}},"required":["key","label"]}},"input":{"type":"object","description":"checklist and pairs use the options; blanks and dial carry their shape here","properties":{"kind":{"type":"string","enum":INPUTS},"blanks":{"type":"array","description":"Named fields, kind blanks","items":{"type":"object","properties":{"label":{"type":"string"},"kind":{"type":"string","enum":BLANKS},"default":{"type":"string","description":"Prefilled"},"choices":{"type":"array","description":"kind choice","items":{"type":"string"}}},"required":["label"]}},"dial":{"type":"object","description":"Number on a range, kind dial","properties":{"min":{"type":"number"},"max":{"type":"number"},"default":{"type":"number"},"labels":{"type":"array","description":"Words for the ends","items":{"type":"string"}}},"required":["min","max"]},"prompt":{"type":"string","description":"One line over the box"},"secret":{"type":"boolean","description":"A credential, never drawn back"}}},"pick":{"type":"object","description":"Recommended answer","properties":{"key":{"type":"string","description":"Option key"},"reason":{"type":"string"},"confidence":{"type":"string","enum":CONFIDENCE},"wouldChange":{"type":"string","description":"What would change it"}},"required":["key"]},"stakes":{"type":"string","enum":STAKES,"description":"Cost if wrong"},"blocking":{"type":"object","description":"What waits","properties":{"turn":{"type":"boolean"},"tasks":{"type":"array","description":"Task names","items":{"type":"string"}}}},"scope":{"type":"array","description":"Allowed lifetimes","items":{"type":"string","enum":SCOPES}},"attach":{"type":"array","description":"Evidence before answers","items":BLOCK}},"required":["head","kind","reason","stakes"],"additionalProperties":false}` +const askSchemaTemplate = `{"type":"object","properties":{"head":{"type":"string","description":"Decision in one sentence"},"kind":{"type":"string","enum":ASKKINDS,"description":"assumption options stand unless struck; ratify waits on nothing"},"form":{"type":"string","enum":FORMS,"description":"Smallest form evidence needs"},"reason":{"type":"string","description":"Why ask now after cheaper rungs"},"subject":{"type":"object","description":"Existing subject","properties":{"kind":{"type":"string","enum":SUBJECTS},"id":{"type":"integer","description":"Task id"},"callId":{"type":"string"},"ref":{"type":"string","description":"String id"},"name":{"type":"string"}}},"options":{"type":"array","description":"Answers, or assumptions that stand","items":{"type":"object","properties":{"key":{"type":"string","description":"A single digit, 1 upward. Anything else is renumbered and given back as you wrote it"},"label":{"type":"string","description":"Short label"},"body":{"type":"string","description":"Meaning"},"consequence":{"type":"string","description":"What happens"},"safe":{"type":"boolean","description":"Changes nothing"},"widening":{"type":"boolean","description":"Grants more"},"blocks":{"type":"array","description":"Evidence","items":BLOCK},"dimensions":{"type":"object","description":"Value per axis","additionalProperties":{"type":"string"}}},"required":["key","label"]}},"input":{"type":"object","description":"checklist and pairs use the options; blanks and dial carry their shape here","properties":{"kind":{"type":"string","enum":INPUTS},"blanks":{"type":"array","description":"Named fields, kind blanks","items":{"type":"object","properties":{"label":{"type":"string"},"kind":{"type":"string","enum":BLANKS},"default":{"type":"string","description":"Prefilled"},"choices":{"type":"array","description":"kind choice","items":{"type":"string"}}},"required":["label"]}},"dial":{"type":"object","description":"Number on a range, kind dial","properties":{"min":{"type":"number"},"max":{"type":"number"},"default":{"type":"number"},"labels":{"type":"array","description":"Words for the ends","items":{"type":"string"}}},"required":["min","max"]},"prompt":{"type":"string","description":"One line over the box"},"secret":{"type":"boolean","description":"A credential, never drawn back"}}},"pick":{"type":"object","description":"Recommended answer","properties":{"key":{"type":"string","description":"Option key"},"reason":{"type":"string"},"confidence":{"type":"string","enum":CONFIDENCE},"wouldChange":{"type":"string","description":"What would change it"}},"required":["key"]},"stakes":{"type":"string","enum":STAKES,"description":"Cost if wrong"},"blocking":{"type":"object","description":"What waits","properties":{"turn":{"type":"boolean","description":"Default true stops your turn until they answer. false returns at once, keep working, their answer arrives as a message"},"tasks":{"type":"array","description":"Task names","items":{"type":"string"}}}},"scope":{"type":"array","description":"Allowed lifetimes","items":{"type":"string","enum":SCOPES}},"attach":{"type":"array","description":"Evidence before answers","items":BLOCK}},"required":["head","kind","reason","stakes"],"additionalProperties":false}` // askSchemaJSON is the wire schema: the template with the block written out in // full at both places it stands, and EVERY ENUM WRITTEN FROM THE CONSTANTS THE @@ -89,11 +89,32 @@ type askArguments struct { Input InputShape `json:"input"` Pick *Pick `json:"pick"` Stakes Stakes `json:"stakes"` - Blocking Blocking `json:"blocking"` + Blocking askBlocking `json:"blocking"` Scope []AnswerScope `json:"scope"` Attach []Block `json:"attach"` } +// askBlocking is [Blocking] as the ASKER may write it, and the one difference is +// the pointer: it is how this decoder tells `turn: false` from a `blocking` the +// model did not write at all. +// +// THE ZERO VALUE WAITS, exactly as [Policy]'s does and for the same reason: a +// model that says nothing gets the behaviour every `ask` has always had, and +// carrying on while somebody decides is a thing the asker has to CLAIM it can +// do. +type askBlocking struct { + Turn *bool `json:"turn"` + Tasks []string `json:"tasks"` +} + +// waits reads that field: true where the asker asked to wait or said nothing. +// +// IT IS THE ASKER'S WORD AND NOTHING ELSE. Whether the KIND waits is +// [AskKind.Waits]'s answer and is asked beside this one, so neither reading has +// to know the other's business: a ratify waits on nobody whatever the model +// writes here, and everything else waits unless the model says otherwise. +func (b askBlocking) waits() bool { return b.Turn == nil || *b.Turn } + // askRefusedLead opens every refusal `ask` hands back. IT SAYS THAT NOTHING WAS // SHOWN, because a model reading a bare refusal has told the person "the form // is presented above — check whichever boxes you like" over a screen with @@ -121,30 +142,32 @@ func (a *Agent) executeAsk(ctx context.Context, raw json.RawMessage) (string, bo } id := a.askSeq.Add(1) theirs := askDigitKeys(in.Options, in.Pick) - q := Question{ID: id, Kind: QuestionAsk, Ask: in.Kind, Form: in.Form, Asker: Asker{Kind: AskerModel}, Head: in.Head, Reason: in.Reason, Subject: in.Subject, Options: in.Options, Input: in.Input, Pick: in.Pick, Stakes: in.Stakes, Blocking: in.Blocking, Scope: in.Scope, Attach: in.Attach, + // AND WHETHER THE ASKER IS WAITING FOR IT AT ALL. Two things decide it, and + // both are the asker's: the KIND, because a ratify names something this turn + // already did and nothing waits on one ([AskKind.Waits]), and the asker's own + // `blocking.turn`, which is how a model says it has work in hand that does + // not depend on the answer. Either of them is enough to let the call carry on. + waits := in.Kind.Waits() && in.Blocking.waits() + q := Question{ID: id, Kind: QuestionAsk, Ask: in.Kind, Form: in.Form, Asker: Asker{Kind: AskerModel}, Head: in.Head, Reason: in.Reason, Subject: in.Subject, Options: in.Options, Input: in.Input, Pick: in.Pick, Stakes: in.Stakes, Scope: in.Scope, Attach: in.Attach, // AND WHICH STEP ASKED IT. A model may call `ask` three times in one // batch, and those three questions are one moment to a person // (question.go's [Question.Batch]). Batch: a.stepToken()} - // AND WHETHER THE ASKER IS WAITING FOR IT AT ALL. A model that says nothing - // is blocked on the turn has said it will read the answer whenever it comes, - // which is the one thing that lets a question stand after the turn that - // raised it ends ([questionOutlivesTurn]). The [AskKind.Waits] term keeps a - // ratify out of it: a ratify names something this turn already did, and one - // still on screen after the turn is a question about work nobody is doing. - q.Later = q.Ask.Waits() && !in.Blocking.Turn + // WHAT MAY OUTLIVE THE TURN IS THE ASKER'S OWN WORD, kept once + // ([questionOutlivesTurn] reads it). The [AskKind.Waits] term keeps a ratify + // out of it: a ratify names something this turn already did, and one still on + // screen after the turn is a question about work nobody is doing. + q.Later = q.Ask.Waits() && !in.Blocking.waits() // AND `blocking.turn` IS DERIVED AND NEVER THE MODEL'S CLAIM. What stops the - // turn is whether this call actually parks, which is the kind and nothing - // else ([AskKind.Waits]) — so a model that writes `blocking: {turn: true}` - // on a ratify cannot put a row on the waiting desk about a turn that is - // carrying on, and a model that writes nothing at all cannot make home draw - // `idle` over work that has stopped. The model's own word about waiting is - // kept, once, in [Question.Later] above; nothing downstream may read this - // field as an argument the asker supplied. + // turn is whether this call actually parks — the two terms above — so a model + // that writes `blocking: {turn: true}` on a ratify cannot put a row on the + // waiting desk about a turn that is carrying on, and a model that writes + // nothing at all cannot make home draw `idle` over work that has stopped. + // Nothing downstream may read this field as an argument the asker supplied. // // The tasks half is the asker's, because only the asker knows what work of // its own is waiting and this engine cannot derive that from anything. - q.Blocking = Blocking{Turn: q.Ask.Waits(), Tasks: in.Blocking.Tasks} + q.Blocking = Blocking{Turn: waits, Tasks: in.Blocking.Tasks} q.Policy = a.autonomyFor(q.Ask) if q.Stakes == StakesIrreversible { q.Policy = Policy{Kind: PolicyAsk} @@ -165,6 +188,9 @@ func (a *Agent) executeAsk(ctx context.Context, raw json.RawMessage) (string, bo answer := defaultAnswer(q, "") answer.From = headlessAnswerLine(q, answer) a.recordDecision(decisionRecordOf(q, answer)) + // Nothing was asked, so there is nothing in the book and nothing to + // deliver: the answer is this call's own result, rendered the one way + // every answer is rendered ([askAnswerText]). // AND THE LINE LEAVES THE ENGINE. docs/design/questions/DESIGN.md's // HEADLESS law is that the policy applies AND IS PRINTED — a run that // took a default silently is a run whose decision nobody can find @@ -174,7 +200,7 @@ func (a *Agent) executeAsk(ctx context.Context, raw json.RawMessage) (string, bo // a surface hearing this about a question it never drew does nothing // with it, which is what [app.foldOthersAnswer] already does. a.emitQuestion(EventQuestionAnswered, q, &answer) - return marshalAnswer(askInTheirKeys(answer, q, theirs)) + return askAnswerText(q, answer, theirs, nil), false, nil } open := &askOpen{q: q, theirs: theirs} a.mu.Lock() @@ -190,19 +216,24 @@ func (a *Agent) executeAsk(ctx context.Context, raw json.RawMessage) (string, bo a.mu.Lock() open.letGo = letGo a.mu.Unlock() + // THE CLOCK STARTS ONCE THE QUESTION IS ON SCREEN, and it belongs to the lane + // rather than to this call ([Agent.startAskClock]): it has to go on running + // for a question this call is not waiting for, which is the shape that had no + // clock at all while the timer lived in the select below. + a.startAskClock(open) if wait == nil { + if !in.Blocking.waits() { + // THE ASKER SAID ITS TURN DOES NOT WAIT. The question stays up, the + // turn carries on around it, and the answer arrives as a message + // ([Agent.deliverAskAnswer]). + return askedText(q), false, nil + } // NOTHING WAITS ON A RATIFY ([AskKind.Waits]), so the call comes straight // back and the question stands on its own. The turn goes on; if the // person strikes what was done, the answer reaches this conversation as a // message ([Agent.answerAsk]). return askShownLead + strings.TrimSpace(q.Head), false, nil } - var clock <-chan time.Time - if q.Policy.Kind == PolicyRecommendThenAuto { - timer := time.NewTimer(q.Policy.After) - defer timer.Stop() - clock = timer.C - } select { case answer, answered := <-wait: // A CLOSED WAIT IS THE PERSON TALKING PAST THE QUESTION, and it is the @@ -229,21 +260,46 @@ func (a *Agent) executeAsk(ctx context.Context, raw json.RawMessage) (string, bo return askedBackResult(q, answer) } letGo() - return marshalAnswer(askInTheirKeys(answer, q, theirs)) - case <-clock: - answer := defaultAnswer(q, "") - _ = a.ResolveQuestion(answer) - letGo() - return marshalAnswer(askInTheirKeys(answer, q, theirs)) + // THE ONE RENDERING, so a model that waited reads exactly what a model + // that carried on reads ([askAnswerText], asklane.go). There is no clock + // case here any more: the clock belongs to the lane, because a question + // nobody is parked on needs one too and this select is only reached when + // somebody is. + return askAnswerText(q, answer, theirs, nil), false, nil case <-ctx.Done(): - a.mu.Lock() - a.asked.letGoLocked(id) - a.mu.Unlock() + a.askLetGo(open, wait) letGo() return "", false, ctx.Err() } } +// askLetGo is this call leaving a question it was parked on — the turn was +// interrupted, or it moved on without it. +// +// AN ANSWER THAT ARRIVED IN THE SAME INSTANT IS STILL AN ANSWER. It was recorded +// and announced by the door that took it, so throwing it away here would leave a +// decision in the record that the model was never told about; it becomes a +// message instead, which is what an answer with nobody parked already is. The +// answering road claimed the wait before it sent (askwait.go), so a settled +// entry here means the value is in the buffer and this is the only road left +// that can read it. +func (a *Agent) askLetGo(open *askOpen, wait <-chan Answer) { + a.mu.Lock() + settled := open.settled + if settled == nil { + a.asked.letGoLocked(open.q.ID) + } + a.mu.Unlock() + if settled == nil { + return + } + select { + case answer := <-wait: + a.deliverAskAnswer(answer, askAnswerText(open.q, answer, open.theirs, nil)) + default: + } +} + // retireTurnQuestions withdraws every question of the model's own that the turn // which raised it is not allowed to leave behind. It is the trigger a question // that outlived its call never had, and the rule is one predicate rather than a @@ -290,54 +346,55 @@ func (a *Agent) answerAsk(answer Answer) error { a.enqueueSteering(askBackNote(open.q, answer)) return nil } - // The delivery cannot block and the entry is the ownership, so this is one - // call under one lock rather than the read-unlock-send it was (askwait.go). - // An answer to an ask that has already ended finds nothing parked and is - // nothing to do (answers.go's second law: a late answer is ignored and - // nothing says so). + // THE SETTLE AND THE HANDOVER ARE ONE LOCKED STEP (askwait.go's + // [askedOfThePerson.answerLocked]): the wait is claimed there before anything + // is put on it, so a call letting go in the same instant and this road cannot + // interleave into an answer that was recorded, announced, and handed to a + // channel nobody will ever read. a.mu.Lock() - open, said := a.asked.answerLocked(answer.ID, answer) + prior := priorAnswerLocked(a.asked.atLocked(answer.ID), answer) + open, wait, err := a.asked.answerLocked(answer.ID, answer) a.mu.Unlock() - if !said { - return nil + if err != nil { + return err } - if open.wait != nil { + if wait != nil { // The lane read it, and the lane owns the rest: it lets go of the - // question and returns the answer to the model itself. + // question and renders the answer to the model itself. return nil } + text := askAnswerText(open.q, answer, open.theirs, prior) + if !a.deliverAskAnswer(answer, text) { + // THE CONVERSATION IS CLOSED AND NOBODY WILL EVER READ THIS. Undoing the + // settle is what makes the refusal true: the question is still a + // question, and the error stops [Agent.ResolveQuestion] recording a + // decision whose answer the model was never told. + a.mu.Lock() + open.settled = prior + a.mu.Unlock() + return errAnswerClosed + } if open.letGo != nil { // THE QUESTION OUTLIVED ITS CALL. The words were claimed by the answer // before this ran, so what this takes down is the presence row and // nothing else — the withdrawal it would otherwise send finds the // question already answered. open.letGo() + a.mu.Lock() + open.letGo = nil + a.mu.Unlock() } - a.tellAskAnswer(open, answer) return nil } -// tellAskAnswer puts the answer to a question that outlived its call in front of -// the model, in the same JSON the tool call would have returned. -// -// IT IS OWED A TURN WHEN IT ASKS FOR SOMETHING. A person who struck an -// assumption, picked against what stands or typed a sentence has said something -// the model has to act on, so the note wakes the conversation the way a landed -// task's report does. An answer that changes nothing — the safe answer on a -// ratify, or a bare acknowledgement — is context for the next request and -// nothing more, so it rides the ambient lane and starts no turn: waking a model -// to be told that what it already did is fine is the session talking to itself. -func (a *Agent) tellAskAnswer(open *askOpen, answer Answer) { - body, _, err := marshalAnswer(askInTheirKeys(answer, open.q, open.theirs)) - if err != nil { - return - } - note := askAnsweredLead + strings.TrimSpace(open.q.Head) + "\n" + body - if answerChangesNothing(open.q, answer) { - a.enqueueAmbientNote(note) - return +// priorAnswerLocked is what this question was answered with BEFORE, for a +// revision that has to say what it replaced. It is nil for a first answer and +// for a change to something this book is not holding. +func priorAnswerLocked(open *askOpen, answer Answer) *Answer { + if open == nil || !answer.Revises { + return nil } - a.enqueueSteering(note) + return open.settled } // answerChangesNothing reports whether an answer asks for nothing to be done: @@ -369,9 +426,6 @@ const ( askShownLead = "shown, and nothing waits on it — carry on. If they strike it you will be told: " // askedBackLead opens the result of a question the person asked back on. askedBackLead = "they asked you this before answering, and the question is still open on their screen — answer them in your reply and do not ask it again; their answer will arrive as a message: " - // askAnsweredLead opens the message carrying an answer to a question that - // outlived the call that asked it. - askAnsweredLead = "The person answered the question you left open — " // askBackNoteLead opens the message carrying words said about a question that // is still standing. askBackNoteLead = "The person said this about the question still open — " diff --git a/internal/session/tools_ask_test.go b/internal/session/tools_ask_test.go index 80b5b8f8c8..768934c6bd 100644 --- a/internal/session/tools_ask_test.go +++ b/internal/session/tools_ask_test.go @@ -50,10 +50,7 @@ func TestAskToolRoundTripsTheWholeAnswer(t *testing.T) { if err := a.ResolveQuestion(want); err != nil { t.Fatal(err) } - var got Answer - if err := json.Unmarshal([]byte(<-done), &got); err != nil { - t.Fatal(err) - } + got := askAnswerRead(t, <-done) if got.Change != want.Change || got.Reframe != want.Reframe || len(got.AskedBack) != 1 || got.Comments["2"] != "portable" { t.Fatalf("answer did not round trip: %+v", got) } @@ -76,10 +73,7 @@ func TestAssumptionsStandAfterTheClock(t *testing.T) { if err != nil || failed { t.Fatalf("ask failed: %q %v %v", text, failed, err) } - var answer Answer - if err := json.Unmarshal([]byte(text), &answer); err != nil { - t.Fatal(err) - } + answer := askAnswerRead(t, text) if strings.Join(answer.Picked, ",") != "1,2" || answer.DecidedBy != DecidedByDial { t.Fatalf("assumptions did not stand: %+v", answer) } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 33df94f1c3..165eea871e 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1493,6 +1493,15 @@ type app struct { // [app.questionRecordsShown] — a receipt is news, and news that never goes // is furniture. questionRecords []questionRecord + // priorAnswers is what a question being CHANGED was answered with last time, + // kept by token from the receipt that offered the change until the new answer + // is given (questionchange.go). + priorAnswers map[string]session.DecisionRecord + // questionHolds are the clocks a key has stopped this frame, waiting to be + // handed to bubbletea as commands (questionhold.go). They are never sent + // from inside the key routine: telling the engine is a call over a + // connection, and Update is the one place that must not wait on one. + questionHolds []tea.Cmd // questionYeses counts the same-shaped yeses per shape, which is the whole // of the rule offer: the third one puts `r make it a rule` on the row // ([app.questionRule]). It is this window's own count and is deliberately @@ -3080,6 +3089,12 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.levelsWaiting() || a.waiting() || a.formingCardLive() { cmd = tea.Batch(cmd, a.wake()) } + // AND A CLOCK SOMEBODY STOPPED IS TOLD TO THE ENGINE HERE, from a command + // rather than from inside the key routine that took the key + // (questionhold.go's [app.takeQuestionHolds] says why it is this line). + if hold := a.takeQuestionHolds(); hold != nil { + cmd = tea.Batch(cmd, hold) + } // AND THE TERMINAL'S TITLE IS ASKED AFTER EVERY MESSAGE, because this is // the one place every change to where a person stands has already happened // by — a place entered, a name arriving, a question coming up — and it is diff --git a/internal/tui3/autonomysheet.go b/internal/tui3/autonomysheet.go index 0d315e2683..0c9508324c 100644 --- a/internal/tui3/autonomysheet.go +++ b/internal/tui3/autonomysheet.go @@ -13,7 +13,7 @@ package tui3 // landing decide yourself // assumptions recommend, auto in 10m // already done ask me -// /autonomy ask · recommend · decide +// /autonomy ask · recommend [duration] · decide // // ── WHY IT IS PER PROJECT AND NOT PER PROFILE ── // @@ -57,7 +57,7 @@ const ( // door. An earlier draft put `· change` on every row, which is a word with // no key behind it — furniture that tells somebody a thing is changeable // without telling them how. - autonomyUsageWord = "/autonomy ask · recommend · decide" + autonomyUsageWord = "/autonomy ask · recommend [duration] · decide" // autonomyNoProjectWord is the refusal for a conversation with no project to // store rules in. It says what is missing rather than that something failed. autonomyNoProjectWord = "this conversation has no project to keep question rules in" @@ -197,16 +197,22 @@ func autonomyLineOf(words string) (session.AskKind, session.Policy, string) { case "decide": rule.Kind = session.PolicyDecide case "recommend": - if len(parts) != 3 { + // A LENGTH IS OPTIONAL. `recommend` on its own takes the engine's own + // default, which is the one derivation of that figure (session's + // [autonomyClock]); naming a duration is how somebody who wants a + // different one says so. + rule.Kind = session.PolicyRecommendThenAuto + if len(parts) == 3 { + after, err := time.ParseDuration(parts[2]) + if err != nil || after <= 0 { + return "", session.Policy{}, "that duration is not understood: " + parts[2] + } + rule.After = after + } else if len(parts) != 2 { return "", session.Policy{}, autonomyUsageWord } - after, err := time.ParseDuration(parts[2]) - if err != nil || after <= 0 { - return "", session.Policy{}, "that duration is not understood: " + parts[2] - } - rule.Kind, rule.After = session.PolicyRecommendThenAuto, after default: - return "", session.Policy{}, "choose ask, recommend , or decide" + return "", session.Policy{}, "choose ask, recommend [duration], or decide" } return kind, rule, "" } diff --git a/internal/tui3/question.go b/internal/tui3/question.go index 54ba2f9429..473b582383 100644 --- a/internal/tui3/question.go +++ b/internal/tui3/question.go @@ -289,6 +289,11 @@ type questionShown struct { // stopping that clock is a call over a connection, which is a lane's // business and not a renderer's (task.go's [app.holdTask]). held func() + // revising says this question is on the block for the SECOND time, because + // somebody pressed `c change` on its receipt. The answer it takes carries + // [session.Answer.Revises], which is what tells the engine a decision is + // being changed rather than answered late (questionchange.go). + revising bool // clockAt is when the reading time on this question started and clockFor how // long it runs; clockHeld says it has stopped for good. // @@ -341,6 +346,12 @@ type questionRecord struct { // says `cannot change` instead, and the engine's own [session. // DecisionRecord.Line] already writes that half. reversible bool + // question is what was asked, kept so that `c change` can put it back on the + // block with its answers exactly as they were read the first time + // (questionchange.go). The record alone carries the head and the keys and + // not the words on the answers nobody picked, which is what somebody + // changing their mind is choosing between. + question session.Question } // ── what is open, and which one is drawn ──────────────────────────────────── @@ -449,6 +460,14 @@ func (a *app) raiseQuestion(q questionShown) { // in the key table, a paragraph in the manual, and a key no ratify line ever // drew ([questionUndoable] holds the reading). q.undoable = q.undoable || questionUndoable(q.question) + // AND A CLOCK THAT ANSWERS IS STOPPED WHERE IT RUNS, which is in the engine. + // The hook is attached here, once, off the question's own properties rather + // than by each lane remembering to set one: a question that says it will take + // its own pick at a deadline is a question whose clock a key must be able to + // stop ([app.holdQuestionClocks] calls this for every key the block reads). + if q.held == nil { + q.held = a.questionHold(q.question) + } q.holes = newQuestionInput(q.question) for i := range a.questions { if a.questions[i].token() != q.token() { @@ -889,6 +908,9 @@ func (a *app) questionRows(width int) []string { out = append(out, a.questionLineRows(head, width)...) } a.shiftQuestionMarks(base) + if aside := a.questionClockAside(head, width); aside != "" { + out = append(out, aside) + } if more := len(a.questionOpen()) - 1; more > 0 { out = append(out, a.pal.dim(fit(" "+itoa(more)+" more", width))) } @@ -913,6 +935,35 @@ func (a *app) shiftQuestionMarks(by int) { } } +// questionClockAside is the dim line under a question whose clock will ANSWER +// it, and it is there because the tail above says what is about to happen +// without saying what a person can do about it. +// +// BOTH HALVES ARE THE THING SOMEBODY WATCHING A COUNTDOWN WANTS TO KNOW. Any key +// stops it — literally any, because [app.holdQuestionClocks] runs on every key +// the block reads — and a pick the clock takes is provisional: the receipt it +// leaves offers `c change`, and the model is told in so many words that the +// answer may still change (session's `askProvisionalNote`). A countdown nobody +// can stop and nobody can walk back is the shape this program must never have +// (F41), so the row says it is neither. +// +// IT IS ONLY DRAWN WHILE THE CLOCK IS REALLY RUNNING. A held one says `paused` +// in the tail and has nothing left to stop; a question with no clock at all +// would be told about a key that does nothing. +func (a *app) questionClockAside(head questionShown, width int) string { + if !questionClockAnswers(head.question) { + return "" + } + if head.question.Deadline.Sub(a.now()) <= 0 { + return "" + } + return a.pal.dim(fit(" "+questionClockAsideWord, width)) +} + +// questionClockAsideWord is that line, spelled once because the manual quotes it +// and the tmux suite waits for it. +const questionClockAsideWord = "any key stops the clock · you can still change the answer afterwards" + // questionForm is which of the three forms this block draws one question in. // // FORMS PROMOTE AND NEVER DEMOTE (DESIGN.md). A lane that asked for a card gets @@ -2030,6 +2081,15 @@ const ( // questionClockWord is that tail without the separator that joins it to a line // of words. func (a *app) questionClockWord(q questionShown) string { + if q.question.Policy.Kind == session.PolicyRecommendThenAuto && q.question.Deadline.IsZero() { + // A CLOCK THAT WOULD HAVE ANSWERED AND HAS NO DEADLINE LEFT IS PAUSED, + // and it says so rather than falling back to `waiting`. The deadline is + // the one source of truth for the countdown (session's [Agent.holdAsk] + // clears it when a key stops the clock), so a question that still says it + // would take its own pick and no longer says when is a question somebody + // is reading. + return questionPausedWord + } if q.question.Policy.Kind != session.PolicyRecommendThenAuto || q.question.Deadline.IsZero() { return a.questionHeldWord(q) } @@ -2346,9 +2406,17 @@ func (a *app) questionRecordRow(record questionRecord, width int) string { // never given up and never counted against the record's own words. func (a *app) questionReceiptLine(record questionRecord, width int) string { const lead = " decided " + // WHAT IS OFFERED IS WHAT THE KEY WILL DO, asked of the same two readings the + // keys ask (questionchange.go): `c change` puts the question back, and + // `u undo` hands back a permission that granted something standing. A + // reversible decision this door would refuse says nothing at all, which is + // what `cannot change` was for. change := "" - if record.reversible { - change = session.DecisionSep + questionCommentKey + " change" + if questionCanUndo(record) { + change += session.DecisionSep + questionUndoKey + " undo" + } + if questionCanChange(record) { + change += session.DecisionSep + questionCommentKey + " change" } clauses := record.record.LineClauses() for { @@ -2574,6 +2642,11 @@ func (a *app) dressAnswer(q questionShown, answer session.Answer) session.Answer if answer.DecidedBy == "" { answer.DecidedBy = session.DecidedByPerson } + // AND A DECISION BEING MADE AGAIN SAYS SO. Without this bit the engine reads + // a second answer to a settled question as a late one and drops it in + // silence, which is right for another window's key a moment too slow and + // wrong for somebody who has just read the receipt (questionchange.go). + answer.Revises = q.revising // WHAT IS IN THE HOLES TRAVELS WITH THE ANSWER, and it travels whichever key // gave it: a proposal approved with `1`, with `enter`, or with a click on the // row all start the work on the model the card was showing. The filling is @@ -2722,8 +2795,17 @@ func (a *app) recordQuestion(q questionShown, answer session.Answer) { return } labels := questionLabels(q.question, answer.Keys()) + was := []string(nil) + if prior, changed := a.priorAnswers[q.token()]; changed && answer.Revises { + // A CHANGED DECISION SAYS WHAT IT REPLACED, here for the reason the + // engine's own record does it ([session.DecisionRecord.Was]): two lines + // about one question, with no clause between them, read as two questions. + was = prior.Labels + delete(a.priorAnswers, q.token()) + } record := session.DecisionRecord{ - ID: q.question.ID, Ref: q.question.Ref, + Was: was, + ID: q.question.ID, Ref: q.question.Ref, Kind: q.question.Kind, Ask: q.question.Ask, Head: strings.TrimSpace(q.question.Head), Subject: q.question.Subject, Picked: answer.Keys(), Labels: labels, Change: answer.Words(), @@ -2732,6 +2814,7 @@ func (a *app) recordQuestion(q questionShown, answer session.Answer) { } a.questionRecords = append(a.questionRecords, questionRecord{ record: record, head: record.Head, at: answer.At, reversible: record.Reversible(), + question: q.question, }) } @@ -2839,6 +2922,11 @@ func (a *app) raiseFolded() { func (a *app) questionKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { head, ok := a.questionHead() if !ok { + // AND WHERE THERE IS NO QUESTION THERE MAY STILL BE A RECEIPT, which + // offers `c change` and `u undo` in so many words (questionchange.go). + if cmd, taken := a.questionReceiptKey(msg); taken { + return cmd, true + } // WHAT IS DRAWN IS WHAT TAKES THE KEY. With nothing on the block the // sheet has the rows, so the sheet has the keyboard (questionsheet.go) // — and the same off-frame guard stands in front of it, because a sheet diff --git a/internal/tui3/question_test.go b/internal/tui3/question_test.go index cfb094bb97..46665e4351 100644 --- a/internal/tui3/question_test.go +++ b/internal/tui3/question_test.go @@ -32,6 +32,9 @@ type questionScript struct { open []session.Question lane chan session.Event answer func(session.Answer) error + // held is every clock this window asked the engine to stop + // (questionchange_test.go). + held []questionHeldCall } func (q *questionScript) OpenQuestions() []session.Question { return q.open } @@ -976,6 +979,12 @@ func TestTheReceiptGivesUpAClauseRatherThanLosingItsTail(t *testing.T) { Change: "2, but keep the sqlite file as the source of truth and write the migration first", By: session.DecidedByPerson, At: lab.at, }, + // The question is on the receipt because `c change` puts it back, and + // the row only offers the key where it would work (questionchange.go). + question: session.Question{ + ID: 1, Kind: session.QuestionAsk, Head: "which store should the ledger sit on?", + Options: []session.AnswerOption{{Key: "1", Label: "one file"}, {Key: "2", Label: "sqlite beside the project"}}, + }, at: lab.at, reversible: true, }) row := plain(lab.a.questionRecordRow(lab.a.questionRecords[0], lab.a.width)) diff --git a/internal/tui3/questionchange.go b/internal/tui3/questionchange.go new file mode 100644 index 0000000000..f1d0e63675 --- /dev/null +++ b/internal/tui3/questionchange.go @@ -0,0 +1,245 @@ +package tui3 + +// `c change` on a receipt: a decision made again. +// +// The receipt above the box has offered `c change` on every reversible decision +// since it was written (question.go's [app.questionReceiptLine]) and nothing has +// ever taken the key — a sentence on the screen naming a key that does nothing. +// What it takes now is the SAME question, put back on the block with its answers +// exactly as they were read the first time, and the answer it takes carries one +// bit saying the decision is being changed ([session.Answer.Revises]). +// +// IT IS NOT A SECOND ANSWERING PATH. The block draws it, the pointer walks it +// and [app.answerQuestion] sends it, all unchanged; the only difference between +// a question and a question being changed is that bit and the receipt that came +// off the screen to make room for it. +// +// THE KEY IS ONLY TAKEN WHERE THE SENTENCE IS ON THE SCREEN AND THE BOX IS +// EMPTY. A receipt is drawn above the box in the conversation for a short while +// after an answer ([app.questionRecordsShown]); outside that, `c` is a letter +// somebody is typing, and a surface that took it would be answering a question +// with the first word of a sentence. + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// questionReceiptKey is `c` or `u` on the newest receipt that still takes one. +func (a *app) questionReceiptKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + key := msg.String() + if key != questionCommentKey && key != questionUndoKey { + return nil, false + } + if !a.input.empty() { + return nil, false + } + if a.questionOffFrame() || a.pageShowing() { + // WHAT IS NOT ON THE SCREEN TAKES NO KEYS, which is [app.questionKey]'s + // own guard about the block said about the row under it. + return nil, false + } + if key == questionUndoKey { + record, ok := a.undoableReceipt() + if !ok { + return nil, false + } + return a.undoGrant(record), true + } + record, ok := a.changeableReceipt() + if !ok { + return nil, false + } + a.changeAnswer(record) + return nil, true +} + +// changeableReceipt is the newest receipt whose decision can still be walked +// back and whose question this surface kept. +// +// IT IS THE NEWEST AND NOT THE FIRST ONE THAT MATCHES, because `c` means the +// thing the person is looking at: two receipts are drawn at once and the one +// they have just made is the lower and the newer of them. +func (a *app) changeableReceipt() (questionRecord, bool) { + shown := a.questionRecordsShown() + for at := len(shown) - 1; at >= 0; at-- { + if record := shown[at]; questionCanChange(record) { + return record, true + } + } + return questionRecord{}, false +} + +// questionCanChange is THE ONE READING of whether a receipt may be answered +// again, and the receipt line and the key both ask it — so the sentence on the +// screen can never offer what the door would refuse. +// +// [session.Question.Revisable] is the engine's half of it, keyed on what the +// answer DID rather than on which lane asked. What this adds is what the SURFACE +// needs to put the question back: the answers it was read with. A decision with +// none was answered in words, or was learnt from another window and never drawn +// here. +func questionCanChange(record questionRecord) bool { + if record.withdrawn != "" || !record.reversible { + return false + } + return record.question.Revisable() && len(record.question.Options) > 0 +} + +// undoableReceipt is the newest receipt that GRANTED something — a permission +// answered `always`, whose standing half can be handed back whole. +// +// `u undo` AND `c change` ARE NOT THE SAME ACT, which is why they are two keys. +// Changing an answer puts the question back and asks it again; undoing one takes +// back what it gave and asks nothing, because there is nothing to decide — a +// person who says "undo that" about a permission has already said what they +// want. So `u` is offered where a yes bought something standing, and `c` +// wherever a question can be put again. +func (a *app) undoableReceipt() (questionRecord, bool) { + shown := a.questionRecordsShown() + for at := len(shown) - 1; at >= 0; at-- { + if record := shown[at]; questionCanUndo(record) { + return record, true + } + } + return questionRecord{}, false +} + +// questionCanUndo is that reading, asked by the key and by the receipt line. +func questionCanUndo(record questionRecord) bool { + if record.withdrawn != "" || !record.reversible { + return false + } + if record.question.Kind != session.QuestionConsent || !record.question.Revisable() { + return false + } + picked := "" + if len(record.record.Picked) > 0 { + picked = record.record.Picked[0] + } + action, ok := session.AnswerFromKey(session.QuestionConsent, picked) + // ONLY A YES IS UNDONE. A denial gave nothing away, and an `allow once` is + // already over — what is left to take back is the standing half. + return ok && action.Allow && action.Scope == session.ConsentToolSession +} + +// undoGrant hands back a permission the person has just given: the engine's memo +// and the account capability go through the one door that knows every store a +// yes was written into ([session.Agent.undoGrant], reached by a revision), and +// the saved rule in their own settings goes here, because the surface is what +// wrote it. +func (a *app) undoGrant(record questionRecord) tea.Cmd { + q := record.question + if a.priorAnswers == nil { + a.priorAnswers = make(map[string]session.DecisionRecord, 1) + } + a.priorAnswers[questionTokenOf(q)] = record.record + a.dropReceipt(record) + tool := strings.TrimSpace(q.Subject.Name) + if said := a.forgetAlways(tool); said != "" { + a.note(said) + } + // THE REVISION IS AN ANSWER LIKE ANY OTHER and goes through the one door + // (questionchange.go's own law): the same [session.Answer] a key press + // makes, with `Revises` on it and the refusal spelled by the engine. + return a.answerQuestion(questionShown{question: q, revising: true}, session.Answer{ + Key: questionDenyKey, + Revises: true, + }) +} + +// questionDenyKey is the consent lane's `deny`, which is what taking a +// permission back MEANS — spelled from the lane's own answers rather than as a +// literal, so a renumbered row cannot silently undo the wrong thing. +var questionDenyKey = questionConsentDenyKey() + +func questionConsentDenyKey() string { + for _, option := range session.AnswerOptions(session.QuestionConsent) { + if action, ok := session.AnswerFromKey(session.QuestionConsent, option.Key); ok && !action.Allow { + return option.Key + } + } + return "" +} + +// forgetAlways takes one tool's standing approval out of the person's own +// settings — the inverse of [app.rememberAlways], through the same door the +// permissions panel drops a line with ([app.dropPermission]) — and answers what +// to say about it. +// +// THE SHELL RULE IS NAMED AND NOT GUESSED. A bash `always` writes a rule about a +// COMMAND SHAPE, and the receipt carries the tool's name and not the shape it +// was banked under; dropping "the bash rule" would be dropping whichever one a +// list reached first. So the memo for this session is taken back either way (the +// engine's half) and the person is told where the standing line still is. +func (a *app) forgetAlways(tool string) string { + if tool == "" { + return "" + } + if tool == consentBash { + return undoneBashWord + } + if err := a.dropPermission(permRule{kind: permTool, name: tool}); err != nil { + // THE SESSION'S OWN MEMO IS STILL GONE, which is what the revision did + // before this ran; what could not be written is the standing row, and + // saying so is this surface's own honesty law ([app.approvalsReloaded]). + return undoneWord + " · " + err.Error() + } + return undoneWord +} + +// The two sentences an undo says, spelled once because the manual quotes them. +const ( + undoneWord = "taken back · this tool asks again" + undoneBashWord = "taken back for this conversation · the saved command rule is in /permissions" +) + +// changeAnswer puts one settled question back on the block. +// +// THE CLOCK DOES NOT COME BACK WITH IT. Whatever the policy was the first time, +// this question is now in front of somebody who has just pressed a key about it, +// and a countdown that started again would be this surface taking the decision +// away from the person who came back to make it. +// +// AND THE RECEIPT COMES OFF. One decision is one row: the question is open +// again, and a receipt under it saying what was decided is the old answer drawn +// as though it still stood. The new one is written when the new answer is given +// ([app.recordQuestion]). +func (a *app) changeAnswer(record questionRecord) { + q := record.question + q.Withdrawn = nil + q.Deadline = time.Time{} + q.Policy = session.Policy{Kind: session.PolicyAsk} + // AND NOTHING IS WAITING ON IT. The turn that asked has long since been + // answered and moved on — that is what the receipt was — so a row that still + // said `waiting` would be telling somebody their work had stopped for a + // decision they are only revisiting. Seen on the Spark, 2026-09-11. + q.Blocking = session.Blocking{} + q.Asked = a.now() + if a.priorAnswers == nil { + a.priorAnswers = make(map[string]session.DecisionRecord, 1) + } + a.priorAnswers[questionTokenOf(q)] = record.record + a.dropReceipt(record) + shown := questionShown{question: q, revising: true} + a.raiseQuestion(shown) + a.markQuestionShown(shown.token()) + a.touch() +} + +// dropReceipt takes one receipt off the screen, by the question it was about. +func (a *app) dropReceipt(record questionRecord) { + token := questionTokenOf(record.question) + kept := a.questionRecords[:0] + for _, held := range a.questionRecords { + if held.withdrawn == "" && questionTokenOf(held.question) == token { + continue + } + kept = append(kept, held) + } + a.questionRecords = kept +} diff --git a/internal/tui3/questionchange_test.go b/internal/tui3/questionchange_test.go new file mode 100644 index 0000000000..41e9f3acca --- /dev/null +++ b/internal/tui3/questionchange_test.go @@ -0,0 +1,257 @@ +package tui3 + +// The surface's half of changing an answer, and of stopping a clock that +// answers (docs/design/questions/DESIGN.md, "An answer is a message"). + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// questionHeldCall is one hold this window sent, as the fake engine heard it. +type questionHeldCall struct { + kind session.QuestionKind + token string +} + +func (q *questionScript) HoldQuestion(kind session.QuestionKind, token string) { + q.held = append(q.held, questionHeldCall{kind: kind, token: token}) +} + +// askedQuestion is the model's own question, which is the shape both of these +// are about: reversible, with a pick, and answerable with a digit. +func askedQuestion() session.Question { + return session.Question{ + ID: 3, Kind: session.QuestionAsk, Ask: session.AskChoice, + Asker: session.Asker{Kind: session.AskerModel}, + Head: "Which storage shape should this use?", + Reason: "two shapes fit and the record does not choose between them", + Options: []session.AnswerOption{ + {Key: "1", Label: "sqlite"}, + {Key: "2", Label: "jsonl"}, + }, + Pick: &session.Pick{Key: "1", Reason: "the readers we have are sqlite"}, + Stakes: session.StakesReversible, + Blocking: session.Blocking{Turn: true}, + } +} + +// `c change` ON A RECEIPT PUTS THE QUESTION BACK, and the answer it takes says +// it is a change — without that bit the engine reads it as a late answer and +// drops it in silence. +func TestChangeOnAReceiptAsksTheQuestionAgainAndTheAnswerSaysItIsAChange(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(askedQuestion()) + lab.tick(questionSettle) + lab.rows() + lab.press("1") + rows := questionPlainRows(lab.rows()) + if len(rows) != 1 || !strings.Contains(rows[0], "c change") { + t.Fatalf("the receipt does not offer the change:\n%s", lab.screen()) + } + if !lab.press("c") { + t.Fatal("`c` on the receipt was not taken") + } + if lab.a.questionCount() != 1 { + t.Fatalf("the question did not come back: %d open", lab.a.questionCount()) + } + if strings.Contains(lab.screen(), "decided") { + t.Fatalf("the old receipt is still drawn under the question it is about:\n%s", lab.screen()) + } + lab.tick(questionSettle) + lab.rows() + lab.press("2") + if len(lab.answer) != 2 { + t.Fatalf("the change never reached the door: %+v", lab.answer) + } + if rows := questionPlainRows(lab.rows()); len(rows) != 1 || !strings.Contains(rows[0], "changed from sqlite") { + t.Fatalf("the new receipt does not say what it replaced:\n%s", lab.screen()) + } + changed := lab.answer[1] + if !changed.Revises { + t.Fatalf("the second answer does not say it is a change: %+v", changed) + } + if changed.FirstKey() != "2" { + t.Fatalf("the change carries %q", changed.FirstKey()) + } + if lab.answer[0].Revises { + t.Fatal("the first answer claimed to be a change") + } +} + +// A DECISION THAT CANNOT BE WALKED BACK OFFERS NO KEY. The receipt says `cannot +// change`, and `c` is a letter somebody is typing. +func TestAnIrreversibleReceiptTakesNoChangeKey(t *testing.T) { + lab := newQuestionLab(t) + q := askedQuestion() + q.Stakes = session.StakesIrreversible + lab.raise(q) + lab.tick(questionSettle) + lab.rows() + lab.press("1") + if lab.press("c") { + t.Fatal("`c` was taken on an irreversible decision") + } + if lab.a.questionCount() != 0 { + t.Fatal("an irreversible decision was asked again") + } +} + +// AND A HALF-TYPED SENTENCE KEEPS ITS LETTERS. The receipt's key is only the +// receipt's while the box is empty. +func TestTheChangeKeyIsALetterWhileSomebodyIsTyping(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(askedQuestion()) + lab.tick(questionSettle) + lab.rows() + lab.press("1") + lab.a.input.setText("cannot be") + if lab.press("c") { + t.Fatal("`c` was taken out of a sentence somebody was typing") + } +} + +// A KEY ON A QUESTION WHOSE CLOCK ANSWERS STOPS THAT CLOCK IN THE ENGINE, and +// the row says `paused` on the same frame rather than counting down to a +// decision nobody is going to make. +func TestAKeyOnACountdownHoldsItInTheEngineAndTheRowSaysPaused(t *testing.T) { + lab := newQuestionLab(t) + q := askedQuestion() + q.Policy = session.Policy{Kind: session.PolicyRecommendThenAuto, After: time.Minute} + q.Deadline = lab.at.Add(30 * time.Second) + lab.raise(q) + lab.tick(questionSettle) + rows := strings.Join(lab.rows(), "\n") + if !strings.Contains(rows, "sqlite in 30s") { + t.Fatalf("the countdown does not say which answer it will take:\n%s", rows) + } + lab.press("down") + if lab.agent.held != nil { + t.Fatal("the engine was told from inside the key routine rather than from a command") + } + // THE DOOR IS ASKED FROM A COMMAND, which is what [app.Update] drains on the + // line every message passes through; a test that reached the engine without + // running one would be testing a call this surface may not make. + hold := lab.a.takeQuestionHolds() + if hold == nil { + t.Fatal("no hold was handed back as a command") + } + hold() + if held := lab.agent.held; len(held) != 1 || held[0].kind != session.QuestionAsk || held[0].token != "3" { + t.Fatalf("the engine was not told to stop counting: %+v", held) + } + rows = strings.Join(lab.rows(), "\n") + if !strings.Contains(rows, questionPausedWord) { + t.Fatalf("the row does not say it is paused:\n%s", rows) + } + if strings.Contains(rows, "in 30s") { + t.Fatalf("the countdown is still drawn after the hold:\n%s", rows) + } +} + +// THE COUNTDOWN SAYS HOW TO STOP IT AND THAT IT CAN BE WALKED BACK. A clock +// nobody knows how to stop, on a pick nobody knows they can change, is the shape +// this program must never have (F41). +func TestAQuestionWithAClockSaysHowToStopItAndOnlyWhileItRuns(t *testing.T) { + lab := newQuestionLab(t) + q := askedQuestion() + q.Policy = session.Policy{Kind: session.PolicyRecommendThenAuto, After: time.Minute} + q.Deadline = lab.at.Add(30 * time.Second) + lab.raise(q) + lab.tick(questionSettle) + rows := strings.Join(lab.rows(), "\n") + if !strings.Contains(rows, questionClockAsideWord) { + t.Fatalf("the countdown does not say what a person can do about it:\n%s", rows) + } + // AND IT GOES WITH THE CLOCK. A held question has nothing left to stop, and + // a line about a key that would do nothing is a line that teaches a lie. + lab.press("down") + if hold := lab.a.takeQuestionHolds(); hold != nil { + hold() + } + if held := strings.Join(lab.rows(), "\n"); strings.Contains(held, questionClockAsideWord) { + t.Fatalf("the paused question still says any key stops the clock:\n%s", held) + } +} + +// A QUESTION WITH NO CLOCK IS TOLD NOTHING ABOUT ONE. +func TestAQuestionThatSimplyWaitsSaysNothingAboutAClock(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(askedQuestion()) + lab.tick(questionSettle) + if rows := strings.Join(lab.rows(), "\n"); strings.Contains(rows, questionClockAsideWord) { + t.Fatalf("a question that simply waits was given a countdown's line:\n%s", rows) + } +} + +// `u undo` HANDS BACK A PERMISSION THE PERSON HAS JUST GIVEN, and it is an +// answer like any other: the deny goes through the one door with `Revises` on +// it, so the engine takes back the memo and the account capability and the +// record says what it replaced. +func TestUndoOnAPermissionReceiptTakesTheStandingYesBack(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(alwaysAnsweredQuestion()) + lab.tick(questionSettle) + lab.rows() + // `2` is `always` on the consent lane's own answers. + lab.press("2") + rows := questionPlainRows(lab.rows()) + if len(rows) != 1 || !strings.Contains(rows[0], "u undo") { + t.Fatalf("the receipt does not offer to hand the permission back:\n%s", lab.screen()) + } + if !lab.press("u") { + t.Fatal("`u` on the receipt was not taken") + } + if len(lab.answer) != 2 { + t.Fatalf("the undo never reached the door: %+v", lab.answer) + } + undo := lab.answer[1] + if !undo.Revises { + t.Fatalf("the undo does not say it is a change: %+v", undo) + } + if action, ok := session.AnswerFromKey(session.QuestionConsent, undo.FirstKey()); !ok || action.Allow { + t.Fatalf("the undo did not deny: %+v", undo) + } + // AND IT ASKS NOTHING. Undoing is not changing your mind about which answer + // to give; the person has already said what they want. + if lab.a.questionCount() != 0 { + t.Fatalf("the undo put a question back: %d open", lab.a.questionCount()) + } +} + +// AND A DECISION THAT GRANTED NOTHING OFFERS NO UNDO. `allow once` is already +// over, and a deny gave nothing away. +func TestOnlyAStandingYesIsOfferedAnUndo(t *testing.T) { + lab := newQuestionLab(t) + lab.raise(alwaysAnsweredQuestion()) + lab.tick(questionSettle) + lab.rows() + lab.press("1") + rows := questionPlainRows(lab.rows()) + if len(rows) != 1 { + t.Fatalf("one answer did not leave one receipt:\n%s", lab.screen()) + } + if strings.Contains(rows[0], "u undo") { + t.Fatalf("an `allow once` was offered an undo:\n%s", rows[0]) + } + if lab.press("u") { + t.Fatal("`u` was taken on a decision with nothing to hand back") + } +} + +// alwaysAnsweredQuestion is the approval gate's own question, which is the one +// shape `u undo` is about: a yes to it can buy something standing. +func alwaysAnsweredQuestion() session.Question { + return session.Question{ + ID: 4, Kind: session.QuestionConsent, Ask: session.AskPermission, Form: session.FormLine, + Asker: session.Asker{Kind: session.AskerEngine}, + Head: "run `git push` in this project?", + Subject: session.SubjectRef{Kind: session.SubjectCall, Name: "run_shell"}, + Options: session.AnswerOptions(session.QuestionConsent), + Stakes: session.StakesReversible, + Blocking: session.Blocking{Turn: true}, + } +} diff --git a/internal/tui3/questionhold.go b/internal/tui3/questionhold.go new file mode 100644 index 0000000000..feeb51924d --- /dev/null +++ b/internal/tui3/questionhold.go @@ -0,0 +1,97 @@ +package tui3 + +// Stopping a clock that ANSWERS. +// +// This surface has always held its own reading clock: a countdown that never +// answers and only ever stops (question.go's [app.tickQuestion]). The other kind +// belongs to the engine — a question under `recommend-then-auto` takes the +// asker's own pick when its deadline passes — and a key pressed on one of those +// has to reach the engine, because the drawing stopping while the engine went on +// counting is the pick taken under somebody's hand with `paused` on the screen +// above it. +// +// The task proposal's card had this door first ([app.holdTask]); this is the +// same act for every lane, hung off the question's own properties rather than +// off its name. + +import ( + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/aforge-v2/internal/session" +) + +// questionHolder is the door that stops a clock, when the agent under this +// surface has one. It is asked for separately from [questionAgent] on that +// interface's own terms: an engine can apply an answer without being able to +// hold a clock, and a capability that cannot work is absent rather than broken. +type questionHolder interface { + // HoldQuestion stops the clock on the question this lane and token name, + // without answering it. It never blocks: what a person sees is the question + // said again with its deadline gone. + HoldQuestion(session.QuestionKind, string) +} + +// questionClockAnswers reports whether this question's clock will DECIDE +// something when it runs out — the one kind a key has to stop in the engine. +func questionClockAnswers(q session.Question) bool { + return q.Policy.Kind == session.PolicyRecommendThenAuto && !q.Deadline.IsZero() +} + +// questionHold is the hook [app.holdQuestionClocks] calls for one question, or +// nil where this question has no clock that answers. +func (a *app) questionHold(q session.Question) func() { + if !questionClockAnswers(q) { + return nil + } + kind, token, drawn := q.Kind, q.Token(), questionTokenOf(q) + return func() { a.holdQuestionClock(kind, token, drawn) } +} + +// holdQuestionClock takes the countdown off this window at once and tells the +// engine to stop counting. +// +// THE DRAWING GOES FIRST FOR [app.holdTask]'S REASON: the person pressed a key +// and the row has to stop counting on that frame, not on the round trip. The +// engine says the question again with its deadline gone (session's +// [Agent.restateQuestion]), and the object that arrives says the same thing this +// window already drew. +func (a *app) holdQuestionClock(kind session.QuestionKind, token, drawn string) { + for i := range a.questions { + if a.questions[i].token() != drawn { + continue + } + a.questions[i].question.Deadline = time.Time{} + a.touch() + } + doors, ok := a.agent.(questionHolder) + if !ok || doors == nil { + return + } + // AND THE DOOR IS ASKED FROM A COMMAND AND NEVER FROM INSIDE Update. It is a + // call over a connection, and the one thing this window may not do while it + // holds the update loop is wait on the engine (remote's callclass.go says + // what that costs). [app.takeQuestionHolds] is where it is handed back to + // bubbletea, on the one line every message passes through. + a.questionHolds = append(a.questionHolds, func() tea.Msg { + doors.HoldQuestion(kind, token) + return nil + }) +} + +// takeQuestionHolds is every hold this frame asked for, as one command. +// +// IT IS DRAINED IN [app.Update] AND NOWHERE ELSE, because a hold is asked for +// from inside a key routine that answers `taken` or `not taken` — and a command +// returned beside `not taken` is a command the caller throws away. The one place +// that sees every message is the one place that can hand this to bubbletea +// whatever the key turned out to mean. +func (a *app) takeQuestionHolds() tea.Cmd { + if len(a.questionHolds) == 0 { + return nil + } + holds := a.questionHolds + a.questionHolds = nil + return tea.Batch(holds...) +} From 7129e54d68a554e33a01c3ff7e4eb6cf94a2cf1e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 16:57:35 -0400 Subject: [PATCH 2/7] questions: the book keeps only the receipts a person can still act on, and none past the door Co-Authored-By: Claude Opus 5 --- internal/session/asklane_test.go | 84 ++++++++++++++++++++++++++++ internal/session/askwait.go | 74 ++++++++++++++++++++---- internal/session/questionlaw_test.go | 13 +++++ 3 files changed, 159 insertions(+), 12 deletions(-) diff --git a/internal/session/asklane_test.go b/internal/session/asklane_test.go index 6fb7e1ac42..b819535d75 100644 --- a/internal/session/asklane_test.go +++ b/internal/session/asklane_test.go @@ -6,6 +6,7 @@ package session import ( "context" "encoding/json" + "strconv" "strings" "testing" "time" @@ -563,3 +564,86 @@ func TestARatifyDoesNotStopTheTurnAndIsNotWaitingOnYou(t *testing.T) { // ratify that writes `blocking: {turn: true}` still stops nothing, which is // pinned from the other end by TestARatifyThatAskedToStopTheTurnStopsNothing. } + +// THE BOOK DOES NOT GROW FOR THE LIFE OF A SESSION. A settled question is kept +// so its answer can be changed while the receipt offering that is on screen, and +// the oldest go once more are kept than can ever be drawn — otherwise every +// question ever answered in a long conversation stays in the one book. +func TestTheBookKeepsOnlyTheNewestAnsweredQuestions(t *testing.T) { + a := askTestAgent(t, true) + const rounds = askSettledKept + 3 + for round := range rounds { + // A DIFFERENT QUESTION EACH TIME, because the gate refuses one a record + // has already answered — which is the ladder's first rung and not this + // law's subject. + raw := strings.Replace(askNotWaiting, "Which storage shape should this use?", + "Which storage shape should round "+strconv.Itoa(round)+" use?", 1) + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "1"}); err != nil { + t.Fatal(err) + } + } + a.mu.Lock() + held := len(a.asked.parked) + newest := a.asked.atLocked(uint64(rounds)) + oldest := a.asked.atLocked(1) + a.mu.Unlock() + if held != askSettledKept { + t.Fatalf("the book holds %d answered questions, want %d", held, askSettledKept) + } + if newest == nil { + t.Fatal("the newest decision is not there to be changed") + } + if oldest != nil { + t.Fatal("the oldest decision is still in the book") + } + // AND THE ONE THE RECEIPT IS ABOUT CAN STILL BE CHANGED, which is the whole + // reason any of them are kept. + change := Answer{Kind: QuestionAsk, ID: uint64(rounds), Key: "2", Revises: true} + if err := a.ResolveQuestion(change); err != nil { + t.Fatalf("the newest decision could not be changed: %v", err) + } + // A decision that has left the book is refused in words rather than silently + // applied to nothing. + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: 1, Key: "2", Revises: true}); err == nil { + t.Fatal("a decision the book no longer holds was changed anyway") + } +} + +// AND NOTHING SETTLED OUTLIVES THE SESSION. A receipt kept so its decision could +// be changed is one nobody can change once the door has shut. +// +// IT CALLS THE BOOK'S OWN CLOSE BECAUSE [Agent.Close] DOES NOT YET, and that is +// the one-line seam named in this lane's report: `agent.go` is another lane's +// file, and `a.stopAskClocksLocked()` belongs beside `a.stopSteerGraceLocked()` +// there. What this pins is the half that is mine — that the book really does +// clear what it was keeping. +func TestClosingTheSessionClearsWhatWasKeptForARevision(t *testing.T) { + a := askTestAgent(t, true) + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if err := a.ResolveQuestion(Answer{Kind: QuestionAsk, ID: q.ID, Key: "1"}); err != nil { + t.Fatal(err) + } + a.mu.Lock() + kept := a.asked.atLocked(q.ID) != nil + a.mu.Unlock() + if !kept { + t.Fatal("the decision was not kept for a change while the session was open") + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + a.mu.Lock() + a.asked.stopClocksLocked() + left := a.asked.atLocked(q.ID) + a.mu.Unlock() + if left != nil { + t.Fatal("a settled question outlived the session that answered it") + } +} diff --git a/internal/session/askwait.go b/internal/session/askwait.go index d71ff323c1..a0e8077387 100644 --- a/internal/session/askwait.go +++ b/internal/session/askwait.go @@ -142,6 +142,42 @@ func (asked *askedOfThePerson) claimLocked(id uint64) (*askOpen, bool) { return open, true } +// askSettledKept is how many ANSWERED questions this book keeps for a revision. +// +// IT IS THE BOUND ON THE ONE THING HERE THAT OUTLIVES BEING ANSWERED. A settled +// entry is kept so `c change` can put the question back and say what it +// replaced; kept forever, the book would grow for the life of a session with +// every question ever answered in it. The number is the surface's own: at most +// [tui3's questionRecordsKept] receipts are drawn at once and the key is live +// only while the receipt is, so a handful more than can ever be on screen is +// exactly enough to make every offer on the screen work and nothing more. +const askSettledKept = 4 + +// forgetOldSettledLocked drops the oldest settled entries past that bound. Ids +// are minted in order ([Agent.askSeq]), so the smallest settled id is the oldest +// decision and the first to go. +func (asked *askedOfThePerson) forgetOldSettledLocked() { + for { + settled, oldest := 0, uint64(0) + for id, open := range asked.parked { + if open == nil || open.settled == nil { + continue + } + settled++ + if oldest == 0 || id < oldest { + oldest = id + } + } + if settled <= askSettledKept || oldest == 0 { + return + } + if open := asked.parked[oldest]; open != nil && open.clock != nil { + open.clock.Stop() + } + delete(asked.parked, oldest) + } +} + // stopClocksLocked stops every clock this book has armed, because NOTHING ARMED // OUTLIVES THE SESSION THAT ARMED IT (steer_grace.go states the same law for a // steer's grace). A clock that fired after the door shut would answer a question @@ -149,10 +185,21 @@ func (asked *askedOfThePerson) claimLocked(id uint64) (*askOpen, bool) { // and the decision would still be in the record, where the next run of this // conversation reads it as something the person settled. func (asked *askedOfThePerson) stopClocksLocked() { - for _, open := range asked.parked { - if open != nil && open.clock != nil { + for id, open := range asked.parked { + if open == nil { + continue + } + if open.clock != nil { open.clock.Stop() } + if open.settled != nil { + // AND NOTHING SETTLED OUTLIVES THE SESSION EITHER. A receipt kept so + // its decision could be changed is a receipt nobody can change once + // the door has shut; the questions still OPEN are left exactly where + // they are, because a session leaving does not answer them and the + // next one has its own book. + delete(asked.parked, id) + } } } @@ -188,6 +235,7 @@ func (asked *askedOfThePerson) answerLocked(id uint64, answer Answer) (*askOpen, if open.clock != nil { open.clock.Stop() } + asked.forgetOldSettledLocked() // THE WAIT IS CLAIMED AND THE ENTRY STAYS. Nothing else can reach this // channel afterwards, so a correction that arrives once the call has taken // its answer and gone is a MESSAGE rather than a send into a channel nobody @@ -290,19 +338,21 @@ func (asked *askedOfThePerson) talkedPastLocked() []uint64 { func (asked *askedOfThePerson) retireLocked() []*askOpen { var gone []*askOpen for id, open := range asked.parked { - if open == nil || open.wait != nil || questionOutlivesTurn(open.q) { + if open == nil || open.wait != nil { continue } if open.settled != nil { - // AN ANSWERED QUESTION IS NOT RETIRED, IT IS CLEARED. Nothing is - // drawn for it any more — the answer took its row down — and what is - // left is the entry a revision reads. It goes without a withdrawal, - // because withdrawing a question somebody has answered would put - // `no longer needed` on the screen about their own decision. - if open.clock != nil { - open.clock.Stop() - } - delete(asked.parked, id) + // AN ANSWERED QUESTION IS NOT RETIRED AT ALL. Nothing is drawn for it + // — the answer took its row down — and withdrawing it would put `no + // longer needed` on somebody's screen about their own decision. What + // is left is the entry a revision reads, and its bound is + // [askedOfThePerson.forgetOldSettledLocked] rather than this sweep: + // a turn very often ENDS on the answer that settled it, and clearing + // it here would take `c change` away one frame after the receipt + // offered it. + continue + } + if questionOutlivesTurn(open.q) { continue } if taken, parked := asked.claimLocked(id); parked { diff --git a/internal/session/questionlaw_test.go b/internal/session/questionlaw_test.go index 77e18d5d15..cbba87a871 100644 --- a/internal/session/questionlaw_test.go +++ b/internal/session/questionlaw_test.go @@ -52,11 +52,24 @@ const questionPutBack = "putBackQuestion" // person reading it and hand it a fresh settle guard and a fresh reading clock // mid-decision. It banks the same question under the same token and says it // once more, which every surface upserts. +// +// THE PROTECTION IS THE CALLER'S GUARD AND NOT THIS FUNCTION. What makes it a +// re-statement rather than a second raise door is that its one caller takes the +// entry out of the book first ([Agent.holdAsk]: `open := a.asked.atLocked(id)`, +// and nothing happens when that is nil), so only a question already raised can +// reach it. A lane that called it without that check would be using the +// exception as a shortcut onto the screen, which is the hole this law exists to +// close — arriving through the allowance instead of around it. const questionRestate = "restateQuestion" func TestEveryQuestionIsRaisedThroughTheOneDoor(t *testing.T) { for _, call := range []string{"emitQuestion", "rememberQuestion"} { for _, site := range callSitesIn(t, ".", call) { + // The two allowances are not raises: one puts back a question this + // engine refused, and one says a STANDING question again with its + // deadline gone — raising that one afresh would put `no longer + // needed` on the screen of the person reading it, and hand it a + // fresh settle guard and a fresh reading clock mid-decision. if site.fn == questionDoor || site.fn == questionPutBack || site.fn == questionRestate { continue } From 5fb6ee24e6173f75fd9c46b5263ecfeddefb8845 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 16:59:39 -0400 Subject: [PATCH 3/7] questions: the number of receipts the book keeps says which half is the rule and which is the guess Co-Authored-By: Claude Opus 5 --- internal/session/askwait.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/session/askwait.go b/internal/session/askwait.go index a0e8077387..4d8789e2a9 100644 --- a/internal/session/askwait.go +++ b/internal/session/askwait.go @@ -147,10 +147,22 @@ func (asked *askedOfThePerson) claimLocked(id uint64) (*askOpen, bool) { // IT IS THE BOUND ON THE ONE THING HERE THAT OUTLIVES BEING ANSWERED. A settled // entry is kept so `c change` can put the question back and say what it // replaced; kept forever, the book would grow for the life of a session with -// every question ever answered in it. The number is the surface's own: at most -// [tui3's questionRecordsKept] receipts are drawn at once and the key is live -// only while the receipt is, so a handful more than can ever be on screen is -// exactly enough to make every offer on the screen work and nothing more. +// every question ever answered in it. +// +// THE CONSTRAINT IS WHAT MATTERS AND THE NUMBER FOLLOWS IT: this must be at +// least as large as the most receipts any surface offers `c change` or `u undo` +// on at once, or a key drawn on the screen would be refused by this book. In +// tui3 that figure is `questionRecordsKept`, which is TWO — the two receipts +// above the box, each live for about half a minute. +// +// It is four rather than two because this engine cannot see how many surfaces +// are drawing it. A second window attached to the same conversation keeps +// receipts of its own, and the `--host` link's window is a third; each one is +// offering its own keys against this one book. Twice the figure of the only +// surface anybody can count is slack bought cheaply — four entries — against a +// refusal a person would read as the program forgetting what they just decided. +// The floor is the sentence above; the doubling is the guess, and it is written +// down as a guess so that raising the surface's figure is known to reach here. const askSettledKept = 4 // forgetOldSettledLocked drops the oldest settled entries past that bound. Ids From 2adcd769925171708e5f370b93131d3cd9d3dbfc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 17:01:34 -0400 Subject: [PATCH 4/7] questions: a law says the engine keeps at least as many receipts as the surface draws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two numbers cannot be interpolated — internal/session cannot import internal/tui3 — so the law reads the other one out of the tree and fails when the relationship breaks. Drafted by lane F; the reason it guards is lane A's. Co-Authored-By: Claude Opus 5 --- internal/tui3/settledreceiptlaw_test.go | 86 +++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 internal/tui3/settledreceiptlaw_test.go diff --git a/internal/tui3/settledreceiptlaw_test.go b/internal/tui3/settledreceiptlaw_test.go new file mode 100644 index 0000000000..e34cffe747 --- /dev/null +++ b/internal/tui3/settledreceiptlaw_test.go @@ -0,0 +1,86 @@ +package tui3 + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strconv" + "strings" + "testing" +) + +// ── ONE FACT, TWO PACKAGES ────────────────────────────────────────────────── +// +// THE CONSTRAINT. `internal/session` keeps the newest answered questions in its +// book so that `c change` and `u undo` have something to render a revision +// against ([session.askSettledKept]). The surface is what OFFERS those keys, and +// it offers them on the receipts it is drawing ([questionRecordsKept]). So the +// engine's cap may never be smaller than the surface's count: a receipt on +// screen wearing `c change` whose entry the engine had already dropped is a key +// drawn as pressable that refuses. +// +// AND IT IS A LAW RATHER THAN A COMMENT BECAUSE THE TWO NUMBERS CANNOT BE MADE +// ONE. `internal/session` cannot import `internal/tui3` — the arrow points the +// other way — so the figure cannot be interpolated the way ONE SOURCE OF TRUTH +// asks for (CLAUDE.md). What can be done is this: read the other number out of +// the tree and fail when the relationship breaks, so the day somebody raises the +// surface's count the build says which other file has to move. The reason is +// lane A's (#914): the constant's own doc states the constraint, names this +// figure, and says which half of its own value is the rule and which half is a +// guess. +func TestTheEngineKeepsAtLeastAsManyReceiptsAsTheSurfaceDraws(t *testing.T) { + kept := constInt(t, "../session", "askSettledKept") + if kept < questionRecordsKept { + t.Fatalf("session.askSettledKept is %d and this surface draws %d receipts, each offering [c] change and [u] undo — raise the engine's cap or lower questionRecordsKept, in the same commit", + kept, questionRecordsKept) + } +} + +// constInt reads one integer constant out of a package's source. +// +// IT FAILS WHEN IT CANNOT FIND ONE, which is the whole of its value: a reading +// that quietly answered zero would turn this law into a green that ran nothing, +// which is the shape of #576. +func constInt(t *testing.T, dir, name string) int { + t.Helper() + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, func(f os.FileInfo) bool { + return !strings.HasSuffix(f.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("reading %s: %v", dir, err) + } + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, id := range value.Names { + if id.Name != name || i >= len(value.Values) { + continue + } + lit, ok := value.Values[i].(*ast.BasicLit) + if !ok || lit.Kind != token.INT { + t.Fatalf("%s in %s is not a plain integer any more; this law reads the literal", name, dir) + } + n, err := strconv.Atoi(lit.Value) + if err != nil { + t.Fatalf("%s in %s reads %q: %v", name, dir, lit.Value, err) + } + return n + } + } + } + } + } + t.Fatalf("%s is not declared in %s any more — this law is guarding nothing; move it or delete it", name, dir) + return 0 +} From f0e238f8b868fa3f280829d8589c414d224650ec Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 17:46:55 -0400 Subject: [PATCH 5/7] questions: the change entry and the lane's doc say what is true on dev, now that the one book has landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #910 merged to dev, and three things this branch said about itself stopped being true the moment it did. asklane.go's header still described the merge going the other way — this file's own book absorbing askwait.go's, with an ownership rule keyed on a `modelAsk` that no longer exists. The merge went the way lane F and this lane agreed and the way the landed code is written: askwait.go's `askedOfThePerson` IS the one book, and asklane.go is what an answer needs after somebody gives one. The doc says that now, and points at the file that holds the four endings instead of repeating them. Two change-entry bullets claimed work that #910 landed. The ratify returning at once and the presence predicate reading what really waits are both on dev already; what this branch adds at those two seams is named instead, and the ratify bullet is replaced by the one thing there that is genuinely this branch's — `ask`'s `blocking.turn` is a `*bool` again, so a model that writes no `blocking` is read as waiting rather than as having written `false`. On dev a plain `bool` makes `q.Later` true for every ordinary ask, which keeps questions on the desk past the turn that raised them. Co-Authored-By: Claude Opus 5 --- .../unreleased/914-answers-are-messages.md | 6 +-- internal/session/asklane.go | 41 ++++++++----------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/docs/changes/unreleased/914-answers-are-messages.md b/docs/changes/unreleased/914-answers-are-messages.md index 4371b8a8e1..357c9c055d 100644 --- a/docs/changes/unreleased/914-answers-are-messages.md +++ b/docs/changes/unreleased/914-answers-are-messages.md @@ -6,14 +6,14 @@ surface: [engine, chat, remote] invalidates: - "An answer to `ask` was the return value of the call that asked, and existed for the model only while that call was parked. It is now delivered: to the call parked on it where there is one, and otherwise onto the conversation's own queue through `Agent.accept` — the same road a background job's ending takes, landing at the next step boundary or waking an idle conversation. What the model reads is one rendering either way: a sentence saying what happened (`answered · question 3 · …`) and the whole `Answer` under it. A model or a test that expected bare JSON from `ask` reads a sentence first now." - "`ask` always stopped the turn. It still does by default, and `blocking: {turn: false}` now returns at once with the question standing; the answer arrives later as a message opening `answered`. An absent `blocking` still waits, so nothing a model wrote before this behaves differently." - - "The book of parked asks (`askedOfThePerson`, `internal/session/askwait.go`, #870) and the lane's own account of a question were two maps holding one question. They are ONE — `Agent.asks` of `*modelAsk`, in `internal/session/asklane.go` — and that file carries #870's three endings word for word plus the fourth this adds: a call that never waited. `THE ENTRY IS THE OWNERSHIP` is now `THE CLAIM IS THE OWNERSHIP`, because a settled entry is kept so its answer can be changed." + - "The book of parked asks (`askedOfThePerson`, `internal/session/askwait.go`, #870) held a question only while a call was parked on it, so a question nobody waits on — a ratify, one asked without blocking the turn — had nowhere to live. There is still exactly ONE book and it is still that one: it now holds an entry with a nil wait for those, and a fourth ending beside #870's three, a call that CARRIED ON. `internal/session/asklane.go` is not a second book; it is what an answer needs after somebody gives one — the rendering, the delivery, the clock, the hold, the revision. `THE ENTRY IS THE OWNERSHIP` holds for the three endings that take the entry out of the map, and the answer claims `open.wait` one field lower instead, because a settled entry is KEPT so its answer can be changed." - "The recommend-then-auto clock lived inside `executeAsk`'s select, so a question nobody was parked on had no clock at all. It belongs to the lane now and runs regardless; the length is one derivation (`autonomyClock`) — the person's own `/autonomy` figure, or `awayAfter` where they named none — and `/autonomy recommend` no longer requires a duration. A clock decides nothing once the session has closed, and an answer a closed conversation cannot read is refused instead of recorded." - "A key on a question stopped only the SURFACE's reading clock; the engine's clock went on and could take the pick under somebody's hand. `Agent.HoldQuestion(kind, token)` (wire `Question.Hold`) stops it for good, clears `Question.Deadline` and says the question again, the row reads `paused`, and while the clock runs the question carries a dim line saying `any key stops the clock · you can still change the answer afterwards`." - "`c change` on a receipt named a key nothing handled. It asks the same question again while the receipt is on screen and the box is empty; the answer carries `Answer.Revises`, the new record says `changed from `, and the model is told what changed. `u undo` is beside it on a permission answered `always` and hands that grant back whole — the session memo, the saved settings line, and a connected account's capability, which returns to asking. What may be changed is one reading, `Question.Revisable`, asked by the receipt that offers the key and by the door that takes it; an irreversible decision refuses it in words." - "A standing yes to a connected service's tool was written as an account capability INSTEAD of the session memo, and a revision that deleted the memo therefore said `taken back` while the account went on being allowed. What a yes buys is recorded where it is bought (`Agent.rememberGrant`) and `Agent.undoGrant` is the one door that takes back all of it." - "An answer to a question this engine is not holding — a row left by an interrupted turn, by a question the person talked past, or by a session host that has restarted — was applied to nothing in silence. It is refused in words now. A second answer to a question already settled is still silent to the person, and it now writes no decision record either: the first answer wins, and the record can no longer disagree with what the model was told." - - "A `ratify` — `something reversible was DONE … nothing waits on the answer, which is what makes it the cheapest question on the ladder` — parked the turn like every other ask, because an answer with nobody parked went nowhere. It returns at once now and the row waits on nobody; an asker that really wants to wait writes `blocking: {turn: true}`. Every other kind still waits by default." - - "A question the model asked was `waiting on you` on the presence file and the home page whether or not anything waited on it. The presence predicate reads `Blocking` now, so a conversation working around a question it asked without waiting says it is working. The `QuestionCap` still counts every open question against one subject: a row on a screen costs attention whether or not a turn is parked behind it." + - "`ask`'s own `blocking.turn` was a plain `bool`, so a model that wrote no `blocking` at all was read as having written `turn: false` — and `q.Later`, the asker's word for a question that may outlive its turn, was therefore TRUE for every ordinary ask, which kept questions on the desk past the turn that raised them. The tool's input carries `*bool` now and absent means waits (`askBlocking.waits`), which is the same reading the rest of the ladder has: a model that wrote nothing behaves exactly as it did before anything in this wave. `Question.Blocking.Turn` stays derived from whether the call really parks, so the field still cannot lie." + - "The presence predicate already asked whether anything really waits (`askedOfThePerson.anyLocked`, #910), but until now nothing could make it false: every entry in the book had a call parked on it. `blocking: {turn: false}` is the shape that makes it answer honestly, so a conversation working around a question it asked without waiting says it is working rather than `waiting on you`. A question whose clock is stopped is REPLACED on the desk rather than withdrawn and raised again (`Agent.presenceRestateQuestion`), so another window sees the countdown go and not the question vanish. The `QuestionCap` still counts every open question against one subject: a row on a screen costs attention whether or not a turn is parked behind it." - "The wire is version 15. A version-14 engine drops `Answer.Revises` as a late answer and answers `Question.Hold` with no such method while its clock runs on, and both fail as silence." --- diff --git a/internal/session/asklane.go b/internal/session/asklane.go index 73a891f4cf..c269be74e8 100644 --- a/internal/session/asklane.go +++ b/internal/session/asklane.go @@ -20,33 +20,24 @@ package session // starts a turn on an idle conversation. There is no second delivery path, and // the bytes are the same either way (asklane_test.go holds it). // -// ── THE FOUR WAYS AN ASK ENDS ── +// ── THERE IS ONE BOOK, AND IT IS NOT THIS FILE ── // -// This book absorbed the one that held only the wait (`askwait.go`, #870), whose -// three endings are kept here word for word because the rule in them is the same -// rule and two books for one question was the defect that file's own doc warned -// about: +// The book of what the model has asked is [askedOfThePerson] in `askwait.go` +// (#870), and this wave did not write a second beside it: two books holding one +// question was the defect that file's own doc warned about, and the lane's +// account of a question was folded INTO it rather than the other way round. So +// `askwait.go` keeps the map, the channels, the four ways an ask ends and the +// ownership rule that makes them safe; what lives here is everything an answer +// needs AFTER somebody has given one — the rendering, the delivery, the lane's +// clock, the hold, and the revision. // -// ANSWERED somebody chose ([Agent.answerAsk], from the one resolver every -// surface calls). The call parked on it reads the answer. -// TALKED PAST they said something else instead of answering -// ([Agent.asksTalkedPastLocked], from the splice — steerquestion.go -// says what was measured). The call reads a CLOSED channel, the one -// shape that cannot be mistaken for a choice they made. -// LET GO the call itself stopped waiting ([Agent.askLetGo]): the turn was -// stopped, or the question was never drawn at all. -// CARRIED ON the call never waited ([askBlocking]), so there is no channel and -// the answer is a message whenever it comes. This is the ending the -// three above had no room for, and the one this wave is about. -// -// ── THE CLAIM IS THE OWNERSHIP ── -// -// Every road takes the channel out of the entry ([modelAsk.claimParkedLocked]) -// under a.mu BEFORE it touches it, so at most one road can ever hold it. That is -// what makes closing it safe rather than a send-on-closed waiting to happen. It -// is `askwait.go`'s own law, moved from the map entry to the field because the -// entry now OUTLIVES the wait: a settled question is kept so its answer can be -// changed, so the map entry can no longer be the token. +// Two of that file's rules are worth knowing at this end, because this file is +// where they are felt. The fourth ending is CARRIED ON: the call never waited +// ([askBlocking]), so there is no channel and the answer is a message whenever +// it comes — the ending the other three had no room for, and the one this wave +// is about. And the ownership moved down one field: a settled entry is KEPT so +// its answer can be changed, so the answer claims `open.wait` rather than the +// map entry ([askedOfThePerson.answerLocked]). import ( "encoding/json" From 85cff1e865301c4c84166ba34c4964824b3fd514 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 17:48:02 -0400 Subject: [PATCH 6/7] questions: the change entry carries the number of the pull request that is actually open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #914 was auto-closed by GitHub when #910 merged and `feat/qv-F-engine`, its base, was deleted with it; a closed pull request cannot be reopened, so the same branch is open as #954 against `dev`. The entry's filename and `pr:` row name that, and the two places in the tree that cite the number by hand — the design doc's note about the offer-to-stop-asking machinery this lane took OUT, and the settled receipts law's attribution — follow it. The entry itself is unchanged. Co-Authored-By: Claude Opus 5 --- ...{914-answers-are-messages.md => 954-answers-are-messages.md} | 2 +- docs/design/questions/DESIGN.md | 2 +- internal/tui3/settledreceiptlaw_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename docs/changes/unreleased/{914-answers-are-messages.md => 954-answers-are-messages.md} (99%) diff --git a/docs/changes/unreleased/914-answers-are-messages.md b/docs/changes/unreleased/954-answers-are-messages.md similarity index 99% rename from docs/changes/unreleased/914-answers-are-messages.md rename to docs/changes/unreleased/954-answers-are-messages.md index 357c9c055d..2c96d6ee24 100644 --- a/docs/changes/unreleased/914-answers-are-messages.md +++ b/docs/changes/unreleased/954-answers-are-messages.md @@ -1,7 +1,7 @@ --- kind: added title: an answer is a message to the conversation, so the work carries on while you decide -pr: 914 +pr: 954 surface: [engine, chat, remote] invalidates: - "An answer to `ask` was the return value of the call that asked, and existed for the model only while that call was parked. It is now delivered: to the call parked on it where there is one, and otherwise onto the conversation's own queue through `Agent.accept` — the same road a background job's ending takes, landing at the next step boundary or waking an idle conversation. What the model reads is one rendering either way: a sentence saying what happened (`answered · question 3 · …`) and the whole `Answer` under it. A model or a test that expected bare JSON from `ask` reads a sentence first now." diff --git a/docs/design/questions/DESIGN.md b/docs/design/questions/DESIGN.md index d4b8880820..7ab7c2d27c 100644 --- a/docs/design/questions/DESIGN.md +++ b/docs/design/questions/DESIGN.md @@ -308,7 +308,7 @@ for good, and both taking the offer (`recommend then go`) and declining it (`ask write one. Never for a shape the door itself refuses (a confirmation always asks; a clarification never runs on a clock), and never on irreversible stakes. -**This is not built.** It was written and then taken out of #914 rather than shipped +**This is not built.** It was written and then taken out of #954 (opened as #914, which GitHub closed with its base branch) rather than shipped with nothing calling it: the dim line on the receipt and its keys are the surface's, and that surface reads no decision record yet — so the whole of it would have been a function nobody could reach. Two things it needs when somebody builds it: the record diff --git a/internal/tui3/settledreceiptlaw_test.go b/internal/tui3/settledreceiptlaw_test.go index e34cffe747..04e537da06 100644 --- a/internal/tui3/settledreceiptlaw_test.go +++ b/internal/tui3/settledreceiptlaw_test.go @@ -26,7 +26,7 @@ import ( // asks for (CLAUDE.md). What can be done is this: read the other number out of // the tree and fail when the relationship breaks, so the day somebody raises the // surface's count the build says which other file has to move. The reason is -// lane A's (#914): the constant's own doc states the constraint, names this +// lane A's (#954): the constant's own doc states the constraint, names this // figure, and says which half of its own value is the rule and which half is a // guess. func TestTheEngineKeepsAtLeastAsManyReceiptsAsTheSurfaceDraws(t *testing.T) { From eb3ca5df277ac5cceaa824201f5d5c4619720961 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 11 Sep 2026 18:10:44 -0400 Subject: [PATCH 7/7] questions: `blocking.turn` is paid for out of `ask`'s own schema, not out of the lean budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lean prefix law measures a belt on a runner, and on one this branch was 96 bytes over its 31,500 ceiling — the 135 bytes `blocking.turn`'s description costs, minus what dev happened to have spare. That law's own words are the rule: this arm's number only ever comes down, so the description a model actually reads differently is paid for out of the schema it lives in. Four glosses and a tail leave, all of them the habit the file's doc comment already names — prose that says the field's own name back at the model. `"What waits"` on a field called `blocking` and `"Recommended answer"` on one called `pick`, both with their halves described beneath them; `"Prefilled"` on a blank's `default`. The tail of `key` goes for a worse reason: it told the model that anything but a digit would be "renumbered and given back as you wrote it", an instruction to comply followed by a promise that not complying is handled. The engine still renumbers; it no longer says so to the one reader who should not be told. And `askDescription` gives up its last sentence because it had stopped being true. "Returns the person's whole answer" is what `ask` did when every call parked; with `blocking.turn` false the call returns with the question standing and the answer arrives later as a message. The verb says what to ask and the field says what comes back, once each. Lean prefix 31,478 → 31,296 locally, which is 182 down and about 86 clear of the ceiling on the runner. Fixed prefix unchanged: `ask` is shelved on a full belt. Co-Authored-By: Claude Opus 5 --- internal/session/tools_ask.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/session/tools_ask.go b/internal/session/tools_ask.go index 886f1c123f..8cccbbbaa2 100644 --- a/internal/session/tools_ask.go +++ b/internal/session/tools_ask.go @@ -20,7 +20,7 @@ import ( "github.com/Agent-Field/aforge-v2/internal/exec/bare" ) -const askDescription = "Ask only after the decision ladder is exhausted. Give a reason, stakes, structured answers before free text, your pick and what would change it. Returns the person's whole answer." +const askDescription = "Ask only after the decision ladder is exhausted. Give a reason, stakes, structured answers before free text, your pick and what would change it." // askBlockSchemaJSON is the one evidence block, spelled once and spliced into // askSchemaJSON at the two places a block may stand: under an answer and @@ -44,7 +44,27 @@ const askBlockSchemaJSON = `{"type":"object","properties":{"kind":{"type":"strin // `"Turn waits"`, `"Certainty"` on a low/medium/high enum — plus the tail of the // two long ones. A field whose name and enum already say what it is gets no // description at all, which is the honest amount rather than a missing one. -const askSchemaTemplate = `{"type":"object","properties":{"head":{"type":"string","description":"Decision in one sentence"},"kind":{"type":"string","enum":ASKKINDS,"description":"assumption options stand unless struck; ratify waits on nothing"},"form":{"type":"string","enum":FORMS,"description":"Smallest form evidence needs"},"reason":{"type":"string","description":"Why ask now after cheaper rungs"},"subject":{"type":"object","description":"Existing subject","properties":{"kind":{"type":"string","enum":SUBJECTS},"id":{"type":"integer","description":"Task id"},"callId":{"type":"string"},"ref":{"type":"string","description":"String id"},"name":{"type":"string"}}},"options":{"type":"array","description":"Answers, or assumptions that stand","items":{"type":"object","properties":{"key":{"type":"string","description":"A single digit, 1 upward. Anything else is renumbered and given back as you wrote it"},"label":{"type":"string","description":"Short label"},"body":{"type":"string","description":"Meaning"},"consequence":{"type":"string","description":"What happens"},"safe":{"type":"boolean","description":"Changes nothing"},"widening":{"type":"boolean","description":"Grants more"},"blocks":{"type":"array","description":"Evidence","items":BLOCK},"dimensions":{"type":"object","description":"Value per axis","additionalProperties":{"type":"string"}}},"required":["key","label"]}},"input":{"type":"object","description":"checklist and pairs use the options; blanks and dial carry their shape here","properties":{"kind":{"type":"string","enum":INPUTS},"blanks":{"type":"array","description":"Named fields, kind blanks","items":{"type":"object","properties":{"label":{"type":"string"},"kind":{"type":"string","enum":BLANKS},"default":{"type":"string","description":"Prefilled"},"choices":{"type":"array","description":"kind choice","items":{"type":"string"}}},"required":["label"]}},"dial":{"type":"object","description":"Number on a range, kind dial","properties":{"min":{"type":"number"},"max":{"type":"number"},"default":{"type":"number"},"labels":{"type":"array","description":"Words for the ends","items":{"type":"string"}}},"required":["min","max"]},"prompt":{"type":"string","description":"One line over the box"},"secret":{"type":"boolean","description":"A credential, never drawn back"}}},"pick":{"type":"object","description":"Recommended answer","properties":{"key":{"type":"string","description":"Option key"},"reason":{"type":"string"},"confidence":{"type":"string","enum":CONFIDENCE},"wouldChange":{"type":"string","description":"What would change it"}},"required":["key"]},"stakes":{"type":"string","enum":STAKES,"description":"Cost if wrong"},"blocking":{"type":"object","description":"What waits","properties":{"turn":{"type":"boolean","description":"Default true stops your turn until they answer. false returns at once, keep working, their answer arrives as a message"},"tasks":{"type":"array","description":"Task names","items":{"type":"string"}}}},"scope":{"type":"array","description":"Allowed lifetimes","items":{"type":"string","enum":SCOPES}},"attach":{"type":"array","description":"Evidence before answers","items":BLOCK}},"required":["head","kind","reason","stakes"],"additionalProperties":false}` +// +// AND THE NUMBER ONLY EVER COMES DOWN, which is why this wave paid for +// `blocking.turn` — the one description on this belt that measurably changed +// what a model does (0 of 2 non-blocking asks with the contract on the verb, 2 +// of 2 with it on the field) — out of the same schema rather than out of the +// budget. `"What waits"` on a field called `blocking` and `"Recommended answer"` +// on one called `pick` are the same habit as `"Turn waits"` was, both with their +// halves described beneath them; `"Prefilled"` on a blank's `default` is a third. +// The tail of `key` went for a different reason and a worse one: it told the +// model that anything but a digit would be "renumbered and given back as you +// wrote it", which is an instruction to comply followed by a promise that not +// complying is handled. The engine still renumbers (askDigitKeys); it just no +// longer says so to the one reader who should not be told. +// +// AND `askDescription` GAVE UP ITS LAST SENTENCE BECAUSE IT HAD STOPPED BEING +// TRUE. "Returns the person's whole answer" is what `ask` did when every call +// parked; a call with `blocking.turn` false returns with the question still +// standing and the answer arrives later as a message. Rather than write the +// exception twice, the verb says what to ask and `blocking.turn` says what comes +// back — which is where a model reading one field at a time will find it. +const askSchemaTemplate = `{"type":"object","properties":{"head":{"type":"string","description":"Decision in one sentence"},"kind":{"type":"string","enum":ASKKINDS,"description":"assumption options stand unless struck; ratify waits on nothing"},"form":{"type":"string","enum":FORMS,"description":"Smallest form evidence needs"},"reason":{"type":"string","description":"Why ask now after cheaper rungs"},"subject":{"type":"object","description":"Existing subject","properties":{"kind":{"type":"string","enum":SUBJECTS},"id":{"type":"integer","description":"Task id"},"callId":{"type":"string"},"ref":{"type":"string","description":"String id"},"name":{"type":"string"}}},"options":{"type":"array","description":"Answers, or assumptions that stand","items":{"type":"object","properties":{"key":{"type":"string","description":"A single digit, 1 upward"},"label":{"type":"string","description":"Short label"},"body":{"type":"string","description":"Meaning"},"consequence":{"type":"string","description":"What happens"},"safe":{"type":"boolean","description":"Changes nothing"},"widening":{"type":"boolean","description":"Grants more"},"blocks":{"type":"array","description":"Evidence","items":BLOCK},"dimensions":{"type":"object","description":"Value per axis","additionalProperties":{"type":"string"}}},"required":["key","label"]}},"input":{"type":"object","description":"checklist and pairs use the options; blanks and dial carry their shape here","properties":{"kind":{"type":"string","enum":INPUTS},"blanks":{"type":"array","description":"Named fields, kind blanks","items":{"type":"object","properties":{"label":{"type":"string"},"kind":{"type":"string","enum":BLANKS},"default":{"type":"string"},"choices":{"type":"array","description":"kind choice","items":{"type":"string"}}},"required":["label"]}},"dial":{"type":"object","description":"Number on a range, kind dial","properties":{"min":{"type":"number"},"max":{"type":"number"},"default":{"type":"number"},"labels":{"type":"array","description":"Words for the ends","items":{"type":"string"}}},"required":["min","max"]},"prompt":{"type":"string","description":"One line over the box"},"secret":{"type":"boolean","description":"A credential, never drawn back"}}},"pick":{"type":"object","properties":{"key":{"type":"string","description":"Option key"},"reason":{"type":"string"},"confidence":{"type":"string","enum":CONFIDENCE},"wouldChange":{"type":"string","description":"What would change it"}},"required":["key"]},"stakes":{"type":"string","enum":STAKES,"description":"Cost if wrong"},"blocking":{"type":"object","properties":{"turn":{"type":"boolean","description":"Default true stops your turn until they answer. false returns at once, keep working, their answer arrives as a message"},"tasks":{"type":"array","description":"Task names","items":{"type":"string"}}}},"scope":{"type":"array","description":"Allowed lifetimes","items":{"type":"string","enum":SCOPES}},"attach":{"type":"array","description":"Evidence before answers","items":BLOCK}},"required":["head","kind","reason","stakes"],"additionalProperties":false}` // askSchemaJSON is the wire schema: the template with the block written out in // full at both places it stands, and EVERY ENUM WRITTEN FROM THE CONSTANTS THE