fix(tui): never render an internal-machinery session (checkpoint-writer hosts, ask forks, subagent hosts) - #1964
Open
wqymi wants to merge 2 commits into
Open
fix(tui): never render an internal-machinery session (checkpoint-writer hosts, ask forks, subagent hosts)#1964wqymi wants to merge 2 commits into
wqymi wants to merge 2 commits into
Conversation
wqymi
force-pushed
the
wq-blank-transcript
branch
from
July 29, 2026 16:36
fd56004 to
09b28bc
Compare
Attaching to a session whose turns ran under an actor (checkpoint-writer-1, explore-N, general-N) showed a completely blank transcript over a full history. bucketMessages keys by agentID, but the session view only knew two of the three key shapes: "main" and the peer-child self-id bucket. An actor-hosted session has neither, so the reader returned [] — 1194 sessions in one local DB. Extract the bucket choice into selectMessages() and add the actor case.
wqymi
force-pushed
the
wq-blank-transcript
branch
from
July 29, 2026 16:53
09b28bc to
c359ec2
Compare
User ruling: "永远不允许切进 checkpoint 会话" — never allowed, not merely
filtered out of a list.
The product already hides these sessions at two layers, but neither is
enforced by the renderer:
- session/session.ts:861-862 — list({roots:true}) → isNull(parent_id),
so no child session is listable.
- session/session.ts:519-542 — children(parent,{visible:true}) keeps only
children owning an ActorRegistry row with mode "peer"; its own comment
names checkpoint-writer hosts, ask-tool forks and workflow subagent
sessions as what it drops.
routes/session/index.tsx did a bare session.get and bailed only when the
row was absent, so at least eight paths handed an id straight to the
renderer past both layers: -s/--session, attach --session,
POST /tui/select-session, POST /tui/event, the session tool's `switch`,
MIMOCODE_ROUTE, plugin navigate("session",…) and the session-list dialog's
child injection. Guarding `switch` alone leaves the other seven open, so
the refusal goes in the route effect every path must pass.
Predicate (session/visibility.ts) is the complement of the two hiding
layers, not a new rule: renderable iff root, or present among the parent's
visible children. Fails closed — an unverifiable child is refused, because
a prohibition that fails open is not a prohibition. Rejection reuses the
existing missing-row behaviour: error toast naming the reason, then
navigate home. `session switch` refuses too, with a message the model can
act on, since a tool result is the only affordance that reaches it
mid-turn.
Also deletes arm 4 of selectMessages, added earlier in this branch to
render the newest actor bucket instead of a blank pane. Its entire
population is now unreachable: measured on a 5.4 GB local DB, of the 1295
sessions it served, 0 are roots, 0 have a mode "peer" row, and all 1295
are refused by the guard (buckets checkpoint-writer-N 1284, build-N 7,
compose-N 3, general-N 1). The blank pane is fixed by making the session
unreachable rather than by rendering machinery the product hides. Its two
tests are inverted in place with an in-file note on what they used to
assert.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scope changed mid-flight. This PR began as a selector fix that rendered
actor-hosted sessions instead of a blank pane (arm 4 of
selectMessages). A userruling reversed the requirement, so the PR now forbids opening those sessions
at all and deletes arm 4 in the same commit. The measured tables from the
original investigation are kept verbatim below, because they are now the
justification for the prohibition rather than caveats about a heuristic.
Independent of #1741, #1953 and #1960: this PR does not touch
bootstrap().The ruling
Never allowed — not merely filtered out of a list. Internal-machinery sessions
(checkpoint-writer hosts, ask-tool forks, workflow subagent hosts) must never be
rendered by the TUI.
The product already hides these sessions at two layers — and enforced neither
file:linecli/cmd/tui/component/dialog-session-list.tsx:40→session/session.ts:861-862list({ roots: true })→isNull(SessionTable.parent_id); no child session is listablesession/session.ts:519-542children(parentID, { visible: true })keeps only children owning anActorRegistryrow withmode === "peer"The comment at
session/session.ts:529-533names the excluded population itself:"checkpoint-writer hosts, ask-tool forks, workflow subagent sessions".
The renderer enforced neither.
routes/session/index.tsx:253(pre-PR) did abare
sdk.client.session.get({ sessionID: route.sessionID })and bailed only whenthe row did not exist — it never checked
parent_idor actor mode.Why the renderer is the choke point, not
switchAt least eight paths hand an id straight to the renderer, past both hiding layers:
file:line-s/--sessioncli/cmd/tui/thread.ts:176-180→app.tsx:379-384attach --sessioncli/cmd/tui/attach.ts:26-30,74POST /tui/select-sessionserver/routes/instance/tui.ts:353-382svc.getexistence onlyPOST /tui/eventserver/routes/instance/tui.ts:347-351TuiEventsessiontoolaction:"switch"tool/session.ts:931-932→app.tsx:1131-1136MIMOCODE_ROUTEcontext/route.tsx:38-39navigate("session", …)plugin/api.tsx:77-80context/sync.tsx:955-959Guarding
switchalone would leave the other seven open. All eight converge onone place: the route effect in
routes/session/index.tsx. The refusal goes there.The predicate, and the one I rejected
Chosen — the complement of the two hiding layers (
src/session/visibility.ts):This is not a new classification; it is the two hiding layers composed, so the
prohibition cannot drift from what the lists show. The renderer resolves it by
calling that same server function (
sdk.client.session.children({ sessionID, visible: true })— already used atcontext/sync.tsx:948, no SDK change).Rejected — the roster rule
actor.mode !== "subagent" && !SYSTEM_SPAWNED_AGENT_TYPES.has(actor.agent)(
tool/session.ts:696; same logic asActorRegistry.servesCheckpoint,actor/registry.ts:410-424). Two reasons:actor/registry.ts:421-422,if (!actor) return true). As a render gate that admits every session whoseactor row is absent — ask-tool forks and any pre-registration window — which
is exactly the population being forbidden. A prohibition that fails open is
not a prohibition. The chosen predicate fails closed.
session, keyed
(sessionID, actorID). To classify a session you must pickactorID = sessionID, and measured over the live DB every row withsession_id = actor_idismode='peer'(153build+ 19compose, 0otherwise). So the conjunction reduces to "has a peer row" — identical to the
chosen predicate, minus the fail-closed behaviour.
On the brand-new-child window (the #1741 regression class): there is none
from the perspective of anyone holding the id.
Actor.spawnPeerregisters thepeer row synchronously between
session.createand returning(
actor/spawn.ts:673-702, comment at:679-689: "before spawn resolves andbefore the child's first turn … addressable the instant
session createreturns"). Verified:
session switchto a peer child still publishes.On user forks:
Session.forkcallscreateNextwith noparentID(
session/session.ts:648), so user forks are roots and are always renderable.parentIDis read with truthiness, per AGENTS.md § "Reading a nullable column"(the column arrives as
null;Infomaps it toundefinedatsession/session.ts:70). A test pins thenullshape explicitly.Rejection behaviour
Consistent with what
routes/session/index.tsx:254-261already does for amissing row: error toast naming the reason, then
navigate({ type: "home" }).No crash, no blank transcript, no silent redirect.
session switchrefuses too — a tool result is the only affordance that reachesthe model mid-turn, so a silent no-op would just make it retry. It states why and
what to do instead ("Run
session list…, or switch to this session's parent"),and does not publish
TuiEvent.SessionSelect.Arm 4 is deleted, not scoped
Folding the prohibition into this PR dissolves the sequencing objection: the two
land atomically, so there is no window in which removing arm 4 re-opens the blank
pane. Checked against the measured population before deleting — arm 4 has no
legitimate population left:
main, no self bucket, has actor bucket)parent_id IS NULL, listable byroots: true)actor_registry.mode = 'peer')mode = 'subagent'checkpoint-writer-N/build-N/compose-N/general-N(The DB grew by one checkpoint-writer host between the two measurements; the
structure reproduces exactly.) Every bucket shape is a child with no peer row, so
every one is forbidden. Nothing to scope arm 4 down to — it goes.
Two things the deletion also removes for free:
per arm-4 session is 1) — dead sort, gone with it.
arm 4 was the only consumer of "main is empty → render the newest non-main
bucket", so the 1-in-4613 false positive where real
mainhistory was crowdedout of the fetch window can no longer render a plausible-but-wrong transcript.
selectMessageskeeps its extraction and its tests, now with two arms:The actor/task tool-result click is unaffected: it navigates with an explicit
agentID(routes/session/index.tsx:3344-3348), which is arm 1 — pinned by a newtest.
Original symptom and mechanism (why the blank pane existed)
Attaching to an actor-hosted session rendered a completely blank transcript
while the sidebar correctly showed title, id, workspace and MCP servers, and
Context 0 tokens / 0% used / $0.00 spent, with the full turn sitting in the DB.The writer and reader disagreed on the bucket key.
context/sync.tsx:146bucketsby
agentID(const k = m.agentID ?? "main"); the reader knew only two of thethree shapes that occur:
mainmain(279727 msgs)ses_09b589ea8…(508)checkpoint-writer-1(25191),explore-1,general-46So
buckets["main"]was empty andbuckets[route.sessionID]did not exist →[]→ blank pane over a full history. 1194 sessions in one local DB were affected.This PR fixes that symptom by making those sessions unreachable, not by
rendering them. The blank pane was a symptom of navigating somewhere the
product never intended to expose.
Known interaction, now moot: arm 4 vs the shared 100-message budget
Kept for the record because it motivated the follow-up below, and because it is a
pre-existing property of the sync layer regardless of arm 4.
sync.session.syncrequestssession.messages({ sessionID, limit: 100, agent_id: "*" })(
sync.tsx:865) — one 100-message budget shared across every bucket, while thelive-append trim at
sync.tsx:535caps per bucket. Measured on a 5.4 GB local DB(4613 sessions with messages): 28 sessions have both a
mainbucket and at leastone actor bucket, and of those exactly 1 has
mainentirely crowded out of thenewest 100 (window reproduced as
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY time_created DESC, id DESC) <= 100).With arm 4 deleted this can no longer mis-render anything. The fetch-side cost,
for whoever takes the follow-up: dropping
limit(the endpoint already returns upto 1000 when omitted —
server/routes/instance/session.ts:815-818, no serverchange needed) moves the mean session from 32 to 68 messages fetched, a 2.12x
payload multiplier, worst case 1000 x ~1895 bytes ≈ 1.9 MB on a path the TUI
blocks on during attach.
Review follow-up: one finding inverted, one grading strengthened
The review's only concrete finding is itself stale. It reports that the
description cites
context/sync.tsx:146-156for the bucketer whilebucketMessages"is atsync.tsx:114-124". Verified on this PR's head(
wq-blank-transcript):bucketMessagesis declared atsync.tsx:146andselectMessagesat:164. The description was right; the review's linenumbers are the drifted ones. Nothing to change.
The "newest bucket is a reasonable heuristic" grading understated the bound,
for the shared-budget reason above:
.at(-1)?.idordered buckets by the newestmessage that arrived, not the newest that exists, and arm 4's
msgs.length > 0filter dropped buckets crowded out of the shared 100 — so asession with many actor buckets could still fall through to
[]. Both critiquesare now resolved by deletion rather than by hardening. The named follow-up stands
on its own merits: make
sync.session.sync's message budget per-slice (orfetch per bucket).
Tests
New
test/session/internal-session-prohibition.test.ts(9 cases): the rule(roots, SQL-
NULLparent, fail-closed), the renderer path against realSession+ActorRegistryrows (root / peer child /checkpoint-writer-1host /unregistered fork), and the
switchpath separately.test/cli/tui/select-messages.test.ts: the two arm-4 cases are inverted inplace, not deleted, with an in-file note recording what they used to assert and
why that is no longer the requirement; one case added for the explicit-
agentIDpath. No existing assertion was weakened — the pre-existing switch test
(
test/tool/session-tool.test.ts:302) targets a root session, which thepredicate permits, and passes unmodified.
Revert probe A — renderer guard removed from
routes/session/index.tsxRevert probe B —
switchrefusal removed fromtool/session.tsThe two probes fail disjoint sets — the renderer guard and the
switchrefusalare independently pinned.
Revert probe C — arm 4 restored (replaces this PR's earlier probe)
The earlier probe expected exactly 2 failures with
Received: []. Its replacementis the same two cases, inverted:
Numbers vs baseline
bun test test/session/internal-session-prohibition.test.ts test/cli/tui/select-messages.test.ts test/session/children-visible.test.ts test/tool/session-tool.test.ts test/server/session-select.test.tsc359ec246: 72 pass / 1 skip / 0 fail (73 tests, 4 files — the new file does not exist there)Delta +10 = 9 new prohibition cases + 1 new
selectMessagescase.bun typecheck→ exit 0 (12/12 tasks).Not touched, deliberately
tool/session.ts:684's unfilteredsessions.children(parentID)is not a leakand is byte-identical to
origin/mainin this PR (verified by diff). Everymodel-visible line is built after
.filter(({ actor }) => actor.mode !== "subagent" && !SYSTEM_SPAWNED_AGENT_TYPES.has(actor.agent))at
:696(same at:735,:952,:1024) — stricter thanvisible: true(which is
mode = 'peer'only,session/session.ts:538). Adding{ visible: true }there would widen what gets dropped and could lose a brand-new child before the
just-dispatched exemption at
:698applies, regressing #1741.Rebase history
Rebased
fd56004a9→c359ec246ontoorigin/main(b81670870) whenCONFLICTING/DIRTY after #1960 (
f91eb6334). One hunk incontext/sync.tsx: bothsides appended a different new function after
bucketMessagesand git collapsedthem onto the shared closing brace —
main'snextSessionStatus()and this PR'sselectMessages(). Kept both, in that order; #1960'suseToastOptional()importand use are untouched.
origin/mainwas re-verified atb8167087immediatelybefore this push and had not moved.
What I did NOT verify
session route, so the guard's wiring into the route effect is pinned by a
source-level assertion (guard call present, and ordered before
sync.session.sync) rather than by rendering. The fetch-plus-decide logic itcalls is covered behaviourally against real DB rows. The toast copy and the
home-navigation have not been seen on screen in this PR.
choke-point claim is from reading the navigation code, not from instrumenting
each path.
grew by one checkpoint-writer host during this task. Treat the shape as durable
and the absolute counts as a snapshot.
c59298dbc(lint, typecheck, unit shards 1-4). CI does not exercise the TUI render path, so it does not close the first gap above.