Skip to content

Commit 82b2437

Browse files
autonomy: the question rules are asked off the update loop, and a row without them says nothing rather than "ask me"
#919 brought the offLoop law to dev and my four call sites break it: reading the rules when the settings page opens, writing one on a keystroke, and the two reads behind /autonomy. Every one is a call to another process — bare `aforge` talks to its engine over a socket — made from Update, where the window cannot draw, take a key, or hear its own answer. THE READ IS NOW ONE PREFETCH. app.readAutonomy is in Init's standing list, so neither the page nor a raised question ever pays a round trip, and autonomyRuled reads the cache and never the door. Its fold RE-STAMPS THE QUESTIONS ALREADY OPEN, because WatchQuestions replays everything waiting the moment a surface attaches — the ordinary resume path raises questions before the rules land, and `ruled` is the tail of a running countdown (`start it in 9s · your rule`). A clock with no reason on it is DESIGN.md's NEVER A HIDDEN RULE. THE WRITE DECIDES WHAT IS SEEN IMMEDIATELY AND REPAIRS ITSELF ON A REFUSAL, which is offloop.go's own pattern: the row moves on the keystroke, the engine is asked off the loop, and a refusal puts the row back with the engine's sentence. AND NIL NO LONGER MEANS TWO THINGS. It meant both "no project to keep rules in" and "not read yet", so the page drew no rows at all in the instant before the first answer — and coercing it to an empty map draws `ask me` on every row of a project that may be on `decide`, which is the worst sentence this page can say. Whether the door EXISTS is a type assertion, local and free (sheet.autonomyDoor); what it SAYS is the call. So the rows are there from the first frame and a row whose value has not arrived draws an empty column. Co-Authored-By: Claude Opus 5 <[email protected]>
1 parent 36bed22 commit 82b2437

5 files changed

Lines changed: 180 additions & 80 deletions

File tree

‎internal/tui3/app.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3013,6 +3013,13 @@ func (a *app) Init() tea.Cmd {
30133013
standing := []tea.Cmd{a.probeGit(), a.watchTasks(), a.watchWakes(), a.watchDesigns(), a.watchTitles(),
30143014
a.watchRuns(), a.watchQuestions(), a.loadTasks(), a.stirLane(), a.askHeld(), a.watchDriving(), a.watchFollowing(),
30153015
a.linkPingTick(), a.prefetchReplayedPictures(), a.countConversations(), tea.RequestBackgroundColor,
3016+
// AND WHAT THIS PROJECT DOES WITH A QUESTION WHILE NOBODY IS THERE, once,
3017+
// here (autonomysheet.go's [app.readAutonomy]). It is a door, so it may not
3018+
// be asked from the update loop where it is READ — when a question is
3019+
// raised, and on the settings page — and reading it on the way up means
3020+
// neither of those ever pays a round trip. The fold re-stamps whatever
3021+
// questions the attach already replayed.
3022+
a.readAutonomy(),
30163023
// AND THE PULSE'S OWN BEAT, whose first reading is taken now rather than
30173024
// ten seconds from now (pulsebeat.go).
30183025
pulseNow,
@@ -6685,8 +6692,7 @@ func (a *app) slash(line string) tea.Cmd {
66856692
if rest != "" {
66866693
return a.changeAutonomy(rest)
66876694
}
6688-
a.noteBlock(a.autonomySheetText())
6689-
return nil
6695+
return a.sayAutonomy()
66906696

66916697
case "quit":
66926698
// /quit CLOSES THE CONVERSATION IN FRONT, and leaves only when it was the

‎internal/tui3/autonomysheet.go‎

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,36 @@ type autonomyAgent interface {
8787
Autonomy() map[session.AskKind]session.Policy
8888
}
8989

90-
// autonomySheetText is the whole sheet as one block of prose, which is how a
91-
// slash command answers on this surface.
92-
func (a *app) autonomySheetText() string {
90+
// sayAutonomy is `/autonomy` with nothing after it: the rows read off the loop
91+
// and then written into the conversation as one block.
92+
//
93+
// THE READ IS THE COMMAND'S WHOLE WORK, so there is nothing to draw
94+
// optimistically and nothing to put back if it refuses — unlike a keystroke that
95+
// changes a row, which decides what a person sees immediately and repairs itself
96+
// on a refusal (offloop.go). A command that has not answered yet has simply not
97+
// printed yet, which is what every other slash command that asks the engine does.
98+
func (a *app) sayAutonomy() tea.Cmd {
9399
agent, ok := a.agent.(autonomyAgent)
94100
if !ok {
95-
return autonomyNoProjectWord
101+
a.noteBlock(autonomyNoProjectWord)
102+
return nil
96103
}
97-
rules := agent.Autonomy()
104+
return a.offLoop(func() func(bool) tea.Cmd {
105+
rules := agent.Autonomy()
106+
return func(here bool) tea.Cmd {
107+
if !here {
108+
return nil
109+
}
110+
a.noteBlock(a.autonomySheetText(rules))
111+
return nil
112+
}
113+
})
114+
}
115+
116+
// autonomySheetText is the whole sheet as one block of prose, which is how a
117+
// slash command answers on this surface. The rows are handed in because reading
118+
// them is a door ([app.sayAutonomy]).
119+
func (a *app) autonomySheetText(rules map[session.AskKind]session.Policy) string {
98120
lines := make([]string, 0, len(autonomyKinds)+2)
99121
lines = append(lines, autonomyHeadWord)
100122
for _, kind := range autonomyKinds {
@@ -237,10 +259,16 @@ func autonomyKindNamed(word string) session.AskKind {
237259
// shape of question. It is what puts `· your rule` on a running clock
238260
// ([app.questionClockWord]).
239261
//
240-
// IT IS READ WHEN A QUESTION IS RAISED AND NEVER ON THE DRAW PATH. The rows
241-
// live in a file, and the draw path is rebuilt from nothing on every frame; a
242-
// read per frame would be a file opened sixty times a second to answer a
243-
// question whose answer changes when somebody types a slash command.
262+
// IT READS THE CACHE AND NEVER THE DOOR. The rules are a call to another
263+
// process — bare `aforge` talks to its engine over a socket like every hosted
264+
// window does — and this is asked from the update loop, where a surface may not
265+
// wait on a network (offloop.go's law, and its structural test). So the ask is
266+
// [app.readAutonomy], fired once on [app.Init] and again whenever a rule is
267+
// written, and this line only ever reads what came back.
268+
//
269+
// AND IT IS READ WHEN A QUESTION IS RAISED AND NEVER ON THE DRAW PATH. The draw
270+
// path is rebuilt from nothing on every frame; the answer changes when somebody
271+
// types a slash command.
244272
//
245273
// AND AN ABSENT DOOR IS NO RULE RATHER THAN AN UNKNOWN ONE. A surface with no
246274
// autonomy door — a fake in a test, a conversation with no project — has no
@@ -249,21 +277,44 @@ func (a *app) autonomyRuled(kind session.AskKind) bool {
249277
if kind == "" {
250278
return false
251279
}
252-
if a.autonomyRules == nil {
253-
agent, ok := a.agent.(autonomyAgent)
254-
if !ok {
255-
return false
256-
}
257-
rules := agent.Autonomy()
258-
if rules == nil {
259-
rules = map[session.AskKind]session.Policy{}
260-
}
261-
a.autonomyRules = rules
262-
}
263280
rule, ok := a.autonomyRules[kind]
264281
return ok && rule.Kind != "" && rule.Kind != session.PolicyAsk
265282
}
266283

267-
// autonomyChanged forgets the cached rows, so the next question raised reads
268-
// what was just written rather than what was there before it.
269-
func (a *app) autonomyChanged() { a.autonomyRules = nil }
284+
// readAutonomy asks this project's question rules off the update loop and folds
285+
// them in. It is [app.Init]'s and nothing else's to call on the way up.
286+
//
287+
// AND IT RE-STAMPS THE QUESTIONS ALREADY OPEN. `WatchQuestions` replays
288+
// everything still waiting the moment a surface attaches, so the ordinary resume
289+
// path raises questions BEFORE this answer lands — and [questionShown.ruled] is
290+
// consumed as the tail of a running countdown (`start it in 9s · your rule`). A
291+
// question that drew without that tail is a clock a person cannot see the reason
292+
// for, which is the one thing DESIGN.md calls NEVER A HIDDEN RULE. So the fold
293+
// stamps what is already on screen rather than only what arrives next.
294+
func (a *app) readAutonomy() tea.Cmd {
295+
agent, ok := a.agent.(autonomyAgent)
296+
if !ok {
297+
return nil
298+
}
299+
return a.offLoop(func() func(bool) tea.Cmd {
300+
rules := agent.Autonomy()
301+
return func(here bool) tea.Cmd {
302+
if !here {
303+
return nil
304+
}
305+
if rules == nil {
306+
rules = map[session.AskKind]session.Policy{}
307+
}
308+
a.autonomyRules = rules
309+
for i := range a.questions {
310+
a.questions[i].ruled = a.autonomyRuled(a.questions[i].question.Ask)
311+
}
312+
a.touch()
313+
return nil
314+
}
315+
})
316+
}
317+
318+
// autonomyChanged reads the rows again, so the next question raised wears what
319+
// was just written rather than what was there before it.
320+
func (a *app) autonomyChanged() tea.Cmd { return a.readAutonomy() }

‎internal/tui3/settings.go‎

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -906,11 +906,24 @@ type sheet struct {
906906
sessionModel string
907907

908908
// autonomy is what this project does with a question of each shape while
909-
// nobody is there, read when the page opens (settingsautonomy.go). It is nil
910-
// for a conversation with no project to keep rules in, and the section then
911-
// draws nothing at all.
909+
// nobody is there (settingsautonomy.go). It is the conversation's own
910+
// reading, taken on the way up and refreshed after every write, and it is
911+
// nil until that first answer lands.
912912
autonomy map[session.AskKind]session.Policy
913913

914+
// autonomyDoor says this conversation HAS somewhere to keep question rules.
915+
//
916+
// IT IS A DIFFERENT QUESTION FROM `autonomy == nil` AND THE TWO WERE ONE
917+
// FIELD ONCE, which is the bug this exists to prevent: nil meant both "no
918+
// project to keep rules in" and "the rules have not come back yet", so a page
919+
// opened in the instant before the first answer landed drew no rows — and a
920+
// page that drew them anyway would have had to invent values. Whether the
921+
// door exists is a type assertion on the agent, which is local and free; what
922+
// it SAYS is a call to another process. So the rows are decided by this and
923+
// the words by the map above, and a row whose value has not arrived draws an
924+
// empty right-hand column rather than a guess.
925+
autonomyDoor bool
926+
914927
// items is the current list — one tab's rows, or every tab's matches under
915928
// their headings while a search is on. cursor indexes it and skips headings.
916929
items []sheetItem
@@ -1165,12 +1178,15 @@ func (a *app) raiseSettings() {
11651178
a.sheet = sheet{
11661179
registry: a.registry(),
11671180
profileDir: a.profileDir,
1168-
// AND WHAT THIS PROJECT DOES WITH A QUESTION WHILE NOBODY IS THERE, read
1169-
// once for the visit like every other live reading on this page: the rows
1170-
// are the engine's rather than the registry's (settingsautonomy.go), and
1171-
// a page that asked the engine per frame would be paying for an answer
1172-
// that changes only when somebody changes it.
1173-
autonomy: a.autonomyReading(),
1181+
// AND WHAT THIS PROJECT DOES WITH A QUESTION WHILE NOBODY IS THERE. The
1182+
// rows are the engine's rather than the registry's (settingsautonomy.go),
1183+
// and a page that asked the engine per frame would be paying for an
1184+
// answer that changes only when somebody changes it — so this is the
1185+
// conversation-wide reading taken on the way up and refreshed after every
1186+
// write ([app.readAutonomy]), not a door asked here. Opening a page is on
1187+
// the update loop, where a surface may not wait on a network.
1188+
autonomy: a.autonomyRules,
1189+
autonomyDoor: a.hasAutonomyDoor(),
11741190
conns: a.conns,
11751191
modelRows: a.modelConnectionRows,
11761192
sources: a.sources,
@@ -1935,8 +1951,7 @@ func (a *app) activate() tea.Cmd {
19351951
}
19361952
s.msg = ""
19371953
if item.autonomy != nil {
1938-
a.autonomyRowNext(item.autonomy)
1939-
return nil
1954+
return a.autonomyRowNext(item.autonomy)
19401955
}
19411956
if item.role != nil {
19421957
// A ROLE IS A MODEL CHOICE, so it opens the picker the class rows open

‎internal/tui3/settingsautonomy.go‎

Lines changed: 63 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"strings"
55
"time"
66

7+
tea "charm.land/bubbletea/v2"
8+
79
"github.com/Agent-Field/aforge-v2/internal/session"
810
)
911

@@ -51,29 +53,6 @@ const (
5153
"kept for this project · /autonomy <kind> ask · recommend <duration> · decide"
5254
)
5355

54-
// autonomyReading is the rules this project keeps, or nil where they could not
55-
// be read at all. It is the ONE read this page does — the command's own door
56-
// ([autonomyAgent]) — taken when the page opens.
57-
//
58-
// NIL AND EMPTY ARE TWO DIFFERENT ANSWERS AND THE PAGE MUST NOT CONFUSE THEM.
59-
// An EMPTY map is a real reading: a project that has written no rule yet, whose
60-
// every kind is therefore on `ask me`, and the page is exactly where somebody
61-
// comes to write the first one — so it draws all the rows. NIL is not a reading:
62-
// there is no door, or the engine refused the call because it is an older aforge
63-
// than this surface (remote/wire.go's version paragraph). A nil coerced into an
64-
// empty map would put `ask me` on every row of a page nobody could read, and
65-
// what the row claims is WHAT HAPPENS WITHOUT A PERSON — an engine quietly on
66-
// `decide` drawn as one that asks is the worst sentence this page could say. So
67-
// nil travels through and [sheet.autonomyItems] draws no rows at all, which is
68-
// the emptiness law: rules that cannot be read are not drawn as rules that are.
69-
func (a *app) autonomyReading() map[session.AskKind]session.Policy {
70-
agent, ok := a.agent.(autonomyAgent)
71-
if !ok {
72-
return nil
73-
}
74-
return agent.Autonomy()
75-
}
76-
7756
// autonomyRow is one question kind as the settings page holds it, hung off
7857
// [sheetItem] exactly as a role or an account is: the cursor walk, the scroll,
7958
// the pointer and the hover need to know nothing about it.
@@ -95,13 +74,23 @@ type autonomyRow struct {
9574
// over a setting that is stored per project: rules that cannot be kept are not
9675
// drawn as rules that are.
9776
func (s *sheet) autonomyItems(query string) []sheetItem {
98-
rules := s.autonomy
99-
if rules == nil {
77+
if !s.autonomyDoor {
10078
return nil
10179
}
10280
items := make([]sheetItem, 0, len(autonomyKinds))
10381
for _, kind := range autonomyKinds {
104-
row := &autonomyRow{kind: kind, word: autonomyPersonWord(rules[kind])}
82+
// A ROW WHOSE VALUE HAS NOT ARRIVED SAYS NOTHING IN ITS VALUE COLUMN.
83+
// The rules are a call to another process and the rows are not, so the
84+
// section is here from the first frame and fills in a moment later —
85+
// which is the emptiness law rather than a gap: `ask me` printed over a
86+
// project that turns out to be on `decide` is the one sentence this page
87+
// must never say, and it is what a nil read coerced to an empty map says
88+
// on every row.
89+
word := ""
90+
if s.autonomy != nil {
91+
word = autonomyPersonWord(s.autonomy[kind])
92+
}
93+
row := &autonomyRow{kind: kind, word: word}
10594
switch kind {
10695
case session.AskConfirmation:
10796
row.word, row.fixed = autonomyAskWord+" · "+autonomyAlwaysWord, true
@@ -166,15 +155,15 @@ func (s *sheet) autonomyRowLines(row *autonomyRow, selected, hovered bool, width
166155
// and over a clarification, in its own words, and a key that walked the value and
167156
// then watched it snap back would be this page arguing with the engine in front
168157
// of somebody.
169-
func (a *app) autonomyRowNext(row *autonomyRow) {
158+
func (a *app) autonomyRowNext(row *autonomyRow) tea.Cmd {
170159
if row.fixed {
171160
a.sheet.msg = autonomyFixedWord
172-
return
161+
return nil
173162
}
174163
agent, ok := a.agent.(autonomyAgent)
175164
if !ok {
176165
a.sheet.msg = autonomyNoProjectWord
177-
return
166+
return nil
178167
}
179168
// WHAT IT IS NOW COMES FROM THE PAGE'S OWN READING, not from a second ask.
180169
//
@@ -197,22 +186,48 @@ func (a *app) autonomyRowNext(row *autonomyRow) {
197186
default:
198187
next.Kind, next.After = session.PolicyRecommendThenAuto, autonomyRowStep
199188
}
200-
if err := agent.SetAutonomy(row.kind, next); err != nil {
201-
// The engine's refusal is the person's to read, whole.
202-
a.sheet.msg = err.Error()
203-
return
204-
}
205-
a.autonomyChanged()
206-
// AND THE PAGE'S READING MOVES WITH THE WRITE RATHER THAN BEING FETCHED
207-
// AGAIN. The engine took this exact rule — it said so by not refusing — so
208-
// re-asking would be a second round trip to be told what this line already
209-
// knows. A refusal returns above without touching it.
189+
// THE ROW MOVES ON THE KEYSTROKE AND THE WRITE GOES OFF THE LOOP. This is a
190+
// call over a connection — on the ordinary launch the surface talks to its
191+
// own engine process — and a row that sat unchanged for a round trip is a row
192+
// somebody presses twice (offloop.go's law, and the whole account of why).
193+
// So what a person sees is decided here, immediately, and the rare road — the
194+
// engine refusing — puts it back with the engine's own sentence.
195+
was, had := a.sheet.autonomy[row.kind]
196+
wasWord := row.word
210197
if a.sheet.autonomy == nil {
211198
a.sheet.autonomy = map[session.AskKind]session.Policy{}
212199
}
213200
a.sheet.autonomy[row.kind] = next
214201
row.word = autonomyPersonWord(next)
215202
a.sheet.msg = string(row.kind) + " · " + row.word + " · for this project"
203+
return a.offLoop(func() func(bool) tea.Cmd {
204+
err := agent.SetAutonomy(row.kind, next)
205+
return func(here bool) tea.Cmd {
206+
if !here || err == nil {
207+
// AND THE PAGE'S READING MOVES WITH THE WRITE RATHER THAN BEING
208+
// FETCHED AGAIN. The engine took this exact rule — it said so by
209+
// not refusing — so re-asking would be a round trip to be told
210+
// what this press already knows. The conversation-wide cache is
211+
// a different reading and is refreshed, because a question raised
212+
// after this must wear the rule that now exists.
213+
if err == nil {
214+
return a.autonomyChanged()
215+
}
216+
return nil
217+
}
218+
// The engine's refusal is the person's to read, whole — and the row
219+
// goes back to what it was, because it never became what it said.
220+
if had {
221+
a.sheet.autonomy[row.kind] = was
222+
} else {
223+
delete(a.sheet.autonomy, row.kind)
224+
}
225+
row.word = wasWord
226+
a.sheet.msg = err.Error()
227+
a.touch()
228+
return nil
229+
}
230+
})
216231
}
217232

218233
// autonomyFixedWord is what a fixed row says when somebody presses it. It names
@@ -223,3 +238,12 @@ const autonomyFixedWord = "this shape always asks you · " + autonomyAlwaysWord
223238
// `recommend then go`. It is the sheet's own default rather than a number chosen
224239
// here, and `/autonomy <kind> recommend <duration>` is how a person names another.
225240
const autonomyRowStep = 30 * time.Second
241+
242+
// hasAutonomyDoor reports whether this conversation has somewhere to keep
243+
// question rules. It is a type assertion on the agent and nothing else — local,
244+
// free, and safe to ask on the update loop — which is exactly why it is a
245+
// separate question from what the rules SAY ([sheet.autonomyDoor]).
246+
func (a *app) hasAutonomyDoor() bool {
247+
_, ok := a.agent.(autonomyAgent)
248+
return ok
249+
}

0 commit comments

Comments
 (0)