Repository navigation
After #870: one spelling of a withdrawal for every lane, and three claims in the tree that are not true #896
Description
Four follow-ups owed by #870 (efd48cbb6), filed together because three of them
are the same shape: a claim in the tree that is no longer true. None of them
misbehaves today — #870's own behaviour is right and green — but each is a
sentence a reader will believe, and two of them are sentences the model reads.
Base for everything below: dev@efd48cbb6. Items 1–3 are a few lines each
and want one PR. Item 4 is two seams that belong to other lanes and is here so
they are written down somewhere other than a lane report.
1 — the stale ask-back claim survives in code
#870 corrected four places in internal/manual/chat/questions.md that promised
an ask-back (?) gets a reply while the question stands. It did not correct the
fifth, which is the one the next reader of this code will find, in
internal/tui3/question.go (search THE QUESTION STAYS OPEN WHILE THE ASKER ANSWERS):
// THE QUESTION STAYS OPEN WHILE THE ASKER ANSWERS, which is the
// room's own seam ([app.askBackCmd]): the sentence goes to the model
// as the next thing said, the reply lands in the conversation, and
// the block is still there to answer afterwards.
return a.submit(words), truea.submit reaches Agent.Submit, which with a turn running appends to
a.steering and returns. While the turn is parked on the very ask the ? was
pressed beside, that sentence reaches nothing until the question is answered —
so the comment and the page it points at now disagree, and the comment is the
wrong one.
Replication
Deterministic (no model). In internal/session, drive the real doors:
// steps: load_capability{questions} → ask{…} → textResponse("done")
agent, _ := newTestAgent(t, &scriptedCompleter{steps: steps}, func(c *Config) { c.Interactive = true })
turn := mustSubmit(t, agent, "ask me about painting")
waitForTheQuestion(t, agent) // the `ask` tool is parked
stream, err := agent.Submit(context.Background(), "why does this matter?") // what `?` does
// TODAY: the third step is never reached; `completer.requests()` stays at 2
// until the question is answered, and `stream` carries nothing.waitForTheQuestion, askingSteps and askedGenres are already in
internal/session/steerquestion_test.go.
Field (real models). ~/af-l2-drive.sh on the Spark is the shape: ask the
model for a question, then press ? and type instead of answering. Needs
OPENROUTER_API_KEY, about a minute and under a cent.
The fix
Item 1 is the comment only — the behaviour is item 4b's ruling. Replace the
last two clauses with what the code does, in the page's own words:
the sentence goes to the model as the next thing said, and the block is still
there to answer afterwards. While the work is PARKED ON THIS QUESTION the
sentence waits with it: no reply comes until you answer (questions.md, Ask it
something before I decide).
Acceptance
- e2e: the session test above, asserting
completer.requests() == 2while
the question stands and3afterResolveQuestion— throughAgent.Submit,
the door?actually uses, notexecuteAsk. - Unit: the comment quotes the manual's limit sentence verbatim, so the two
cannot drift again. - No manual change: Saying something else is an answer: a question you talk past comes down, and the turn moves at once #870 already did it. The change entry's
invalidatesnames
the comment.
2 — one clause of #870's new Steer invariant is false
internal/session/steer.go (search IT IS ASKED BEFORE THE THREE ARMS):
A parked
askholds the whole tool batch, so no request is out while one
stands —a.generationis nil and the first arm is unreachable — and the
batch it holds is the one no bash can be running inside.
The bolded clause is wrong. runToolsWarm gives every call in a batch its own
goroutine and waits for all of them, so an [ask, bash] batch has the bash
genuinely in flight while the ask is parked. A steer then takes the bash
arm, and the person reads the bash landing instead of the question one.
Nothing breaks — the retire happens before the three arms, so the turn
unwedges exactly as designed and the question comes down. The defect is the
comment, and the landing sentence it predicts.
Replication
Deterministic (no model). Measured on dev@efd48cbb6, in
internal/session: a batch of [ask, bash{"command":"sleep 30"}], then sleep
past steerBashAge (3 s) so the bash arm is the one taken, then
mustSteer(t, agent, "portrait"):
LANDING = "kept bash running as job 1" (the invariant predicts: "took this instead of the question")
and agent.OpenQuestions() is empty — the question was retired.
The fix
Pick one and say which:
- Narrow the clause. Delete
and the batch it holds is the one no bash can be running inside, and say what is actually guaranteed: the retire runs before
all three arms, so the question comes down whatever else is in the batch; the
landing names the loudest thing the sentence did, and a long command in the
same batch is louder than a question. - Or make the landing the question's when one was retired, on the grounds
that a question coming down is the more surprising fact. That costs the job
number, which the person needs to find their process — so if this arm is
taken, the two have to be said together, and that is a wording decision rather
than a one-line fix.
The first is the honest minimum; the second needs a ruling.
Acceptance
- e2e: the
[ask, bash]replication above, asserting whichever landing the
ruling picks and thatOpenQuestions()is empty either way — the second
half is the part that must never regress. - Unit: if the second arm is taken,
internal/manual/chat/screen.md's
landing table andkeys.md's two enumerations carry the combined wording.
3 — two more withdrawal spellings, in the lane #870 did not reach
#870 claimed one spelling of what a withdrawal looks like, two roads onto it:
WithdrawQuestion became claimQuestion + sayWithdrawn, and the talked-past
road takes the same sayWithdrawn. That is true for the ask lane only.
internal/session/task_landing_question.go still hand-rolls two more, and one of
them reaches into the raw map exactly as #870 stopped ask doing:
- in
makeLandingQuestion(searchwhat it is waiting on changed) —
delete(a.questionWords, questionToken(standing, token))undera.mu, then a
Withdrawal{…}literal, thenemitQuestion. - in
retireLandingQuestion(searchgone.Withdrawn = &Withdrawal{) — the same
three steps again, as the fallback whenquestionSaidsays nothing was banked.
Three spellings of one act is the state #870 argued against, and the claim in
internal/session/question.go's sayWithdrawn doc comment ("there must not be
two spellings of what a withdrawal looks like") is currently aspirational.
Replication
Deterministic (no model). A structural law, which is the right shape here —
this is a rule about the tree, not a behaviour:
// internal/session: parse the package with go/parser and fail on any
// composite literal of type Withdrawal outside question.go's sayWithdrawn,
// and on any write to a.questionWords outside question.go.Run it on dev@efd48cbb6 today and it names the two sites above. It joins
make test-laws the day it lands (scripts/laws.sh finds it by its go/parser
import).
The fix
Both sites go through the doors that already exist: claimQuestion /
claimQuestionLocked to take the words, then sayWithdrawn(q, reason) to say
it. The second site's "nothing was banked" fallback is sayWithdrawn on a
question it built itself — legitimate, and it should say so rather than rebuild
the struct.
Acceptance
- e2e:
go test ./internal/session -run 'Landing|Conflict'on the Spark,
green — the landing question's own suite is the door here, and the assertion is
that the withdrawal rows it reads carry the sameWithdrawn.Reasonstrings as
before (the work settled,what it is waiting on changed). - Unit: the structural law above, failing on a re-added hand-roll.
- The change entry's
invalidatesnames the raw-map write that is gone.
4 — the two seams #870 reported and did not take
Neither is #870's to fix and both are written down here so they are not only in a
lane report.
4a — a phase post from the provider blocks on the surface's Update loop
On every call's exit, not only a cancelled one:
provider/client.go defer phase.done()
→ provider/phase.go postPhase → reader(news) ← called SYNCHRONOUSLY
→ session/phasenews.go forwardPhase
→ tui3/tui3.go p.Send(phaseNewsMsg{}) ← bubbletea's msgs chan is UNBUFFERED
So the goroutine that just finished a model call pays one full Bubble Tea
Update+View cycle before it can return to internal/session. That is the one
genuinely unbounded wait left on a call's return path (#870 took the bounded one,
abandonGrace).
Replication (deterministic, no model). In internal/provider, register a
reader through OnPhase that sleeps 250 ms, run any completion against
lanestub, and time the call: today it returns ~250 ms late. The seam is
OnPhase / session.OnPhaseNews — both already exported for exactly this.
Fix: one buffered hop in postPhase so the poster never waits on the reader,
with the drop rule stated (a phase superseded before it was read is news nobody
needs). Acceptance: the timing test above asserting the call returns inside
lane.SpokenWithin/10 with a slow reader attached, plus a structural law that
postPhase does not call the reader on the caller's goroutine. Owner: whoever
holds the ≤50 ms critical-path law (L1 has phasenews.go).
4b — ask-back bypasses Steer, and QuestionAsk does not declare Blocking{Turn: true}
Two halves of one design question, and the reason item 1 exists.
tui3/question.go's ? calls a.submit, which with a turn running queues on
a.steering — so while the work is parked on that question the sentence waits
for the answer to the question it was asked about. Separately, consent, task and
landing questions hardcode Blocking{Turn: true} (session/question.go, search
Blocking{Turn: true}) while QuestionAsk takes it from whatever the model
wrote; when the model omits it, questionOwnsBox hands the composer to the
conversation, which is how a typed sentence became a steer at all.
Replication: item 1's session test is the first half. For the second,
internal/tui3: an open QuestionAsk with Blocking.Turn false and
Input.Kind unset, assert questionOwnsBox is false and a typed enter goes to
steerIn.
Fix: a ruling, not a patch. Either a question yields and is re-put when
somebody asks back (the shape #870 gave the steer), or ask-back opens a turn of
its own. Defaulting Blocking.Turn for QuestionAsk changes what enter does
across the whole surface and is the questions lane's call.
Acceptance: end to end through the tmux suite — press ?, type, and assert
the reply is drawn under the answer before the question is answered, with the
question still standing.
Filed by lane L2 of the speed audit after #870. Items 1–3 have no open
questions; item 4 needs two rulings.
Drafted with CodeAF · reviewed and owned by the author
Activity
santoshkumarradha commentedon Sep 11, 2026
Item 4a is closed by #871 (merged to dev as
e67f1827b).postPhaseNewsandpostLaneNewsboth hand the news to adesk(internal/session/sidecar.go) instead of calling the registered reader down the caller's stack, so the goroutine that just finished a model call no longer pays a Bubble Tea Update+View cycle before it returns. One goroutine per desk at a time, started by the telling that finds it empty and returning when it is empty again; order kept, nothing coalesced and nothing dropped — a phase is a state and a latest-wins slot would serve it, but lane news is a ledger and a dropped row is a request nobody can count again.internal/providerneeded no change: its own header already states the division ("the one live reader hands the news to a desk and asks for a frame"), and the reader it had installed wassession.forwardPhase. Fixing it at the registration side also covers every phase the transport posts, because they all arrive through that one door — so please do NOT add a second hop inprovider/phase.go.Guarded both ways:
TestASlowListenerNeverHoldsTheTurnThatIsTellingItstages a listener as slow as every other auxiliary and asserts message→send ≤ 50 ms and tool→next-send ≤ 50 ms (verified red without the desk — the turn does not merely miss the bound, it never finishes), andTestAListenerIsAlwaysToldFromADeskis the structural half on the laws gate.One known gap in that structural half is filed as #921 §1b: it matches a listener call only as a bare
ident(args), so one reached through a struct field would not be seen.🤖 Generated with Claude Code
AbirAbbas commentedon Sep 16, 2026
Closing as not planned. After #870 the withdrawals all behave; what remains is one false comment in
internal/session/steer.goand two hand-rolledWithdrawalliterals ininternal/session/task_landing_question.go. Fix the comment and use the shared door when next in those files; this does not need an issue.