Skip to content

fix(tui): never render an internal-machinery session (checkpoint-writer hosts, ask forks, subagent hosts) - #1964

Open
wqymi wants to merge 2 commits into
mainfrom
wq-blank-transcript
Open

fix(tui): never render an internal-machinery session (checkpoint-writer hosts, ask forks, subagent hosts)#1964
wqymi wants to merge 2 commits into
mainfrom
wq-blank-transcript

Conversation

@wqymi

@wqymi wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

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 user
ruling 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

永远不允许切进 checkpoint 会话

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

layer file:line rule
session list cli/cmd/tui/component/dialog-session-list.tsx:40session/session.ts:861-862 list({ roots: true })isNull(SessionTable.parent_id); no child session is listable
child list session/session.ts:519-542 children(parentID, { visible: true }) keeps only children owning an ActorRegistry row with mode === "peer"

The comment at session/session.ts:529-533 names 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 a
bare sdk.client.session.get({ sessionID: route.sessionID }) and bailed only when
the row did not exist — it never checked parent_id or actor mode.

Why the renderer is the choke point, not switch

At least eight paths hand an id straight to the renderer, past both hiding layers:

path file:line guard before this PR
-s / --session cli/cmd/tui/thread.ts:176-180app.tsx:379-384 none
attach --session cli/cmd/tui/attach.ts:26-30,74 none
POST /tui/select-session server/routes/instance/tui.ts:353-382 svc.get existence only
POST /tui/event server/routes/instance/tui.ts:347-351 none — publishes any TuiEvent
session tool action:"switch" tool/session.ts:931-932app.tsx:1131-1136 none
MIMOCODE_ROUTE context/route.tsx:38-39 none
plugin navigate("session", …) plugin/api.tsx:77-80 none
session-list dialog child injection context/sync.tsx:955-959 visible-only, but the route accepts anything

Guarding switch alone would leave the other seven open. All eight converge on
one 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):

A session is renderable iff it is a root, or it appears among its
parent's children(parentID, { visible: true }).

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 at context/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 as ActorRegistry.servesCheckpoint,
actor/registry.ts:410-424). Two reasons:

  1. It fails OPEN on a missing actor row (actor/registry.ts:421-422,
    if (!actor) return true). As a render gate that admits every session whose
    actor 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.
  2. It adds no discriminating power here. It classifies an actor within a
    session, keyed (sessionID, actorID). To classify a session you must pick
    actorID = sessionID, and measured over the live DB every row with
    session_id = actor_id is mode='peer' (153 build + 19 compose, 0
    otherwise). 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.spawnPeer registers the
peer row synchronously between session.create and returning
(actor/spawn.ts:673-702, comment at :679-689: "before spawn resolves and
before the child's first turn … addressable the instant session create
returns"). Verified: session switch to a peer child still publishes.

On user forks: Session.fork calls createNext with no parentID
(session/session.ts:648), so user forks are roots and are always renderable.

parentID is read with truthiness, per AGENTS.md § "Reading a nullable column"
(the column arrives as null; Info maps it to undefined at
session/session.ts:70). A test pins the null shape explicitly.

Rejection behaviour

Consistent with what routes/session/index.tsx:254-261 already does for a
missing row: error toast naming the reason, then navigate({ type: "home" }).
No crash, no blank transcript, no silent redirect.

session switch refuses too — a tool result is the only affordance that reaches
the 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
:

quantity at review time re-measured now
sessions where arm 4 fires (no main, no self bucket, has actor bucket) 1294 1295
of those, roots (parent_id IS NULL, listable by roots: true) 0 0
of those, visible peer children (actor_registry.mode = 'peer') 0 0
of those, mode = 'subagent' 1294 1295
bucket shape checkpoint-writer-N / build-N / compose-N / general-N 1283 / 7 / 3 / 1 1284 / 7 / 3 / 1
max distinct buckets per arm-4 session 1 1
refused by the new predicate 1295 / 1295 (100%)

(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:

  • The "several actor buckets" tie-break never fired in practice (max buckets
    per arm-4 session is 1) — dead sort, gone with it.
  • The shared-100-message-budget hazard documented below disappears entirely:
    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 main history was crowded
    out of the fetch window can no longer render a plausible-but-wrong transcript.

selectMessages keeps its extraction and its tests, now with two arms:

if (agentID !== "main" || buckets?.["main"]?.length) return buckets?.[agentID] ?? []
return buckets?.[sessionID] ?? []

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 new
test.

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:146 buckets
by agentID (const k = m.agentID ?? "main"); the reader knew only two of the
three shapes that occur:

bucket key example covered before?
main main (279727 msgs) yes
own sessionID (spawn.ts peer child) ses_09b589ea8… (508) yes, by the fallback
actor id (actor-hosted child) checkpoint-writer-1 (25191), explore-1, general-46 no

So buckets["main"] was empty and buckets[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.sync requests session.messages({ sessionID, limit: 100, agent_id: "*" })
(sync.tsx:865) — one 100-message budget shared across every bucket, while the
live-append trim at sync.tsx:535 caps per bucket. Measured on a 5.4 GB local DB
(4613 sessions with messages): 28 sessions have both a main bucket and at least
one actor bucket, and of those exactly 1 has main entirely crowded out of the
newest 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 up
to 1000 when omitted — server/routes/instance/session.ts:815-818, no server
change 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-156 for the bucketer while
bucketMessages "is at sync.tsx:114-124". Verified on this PR's head
(wq-blank-transcript): bucketMessages is declared at sync.tsx:146 and
selectMessages at :164. The description was right; the review's line
numbers 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)?.id ordered buckets by the newest
message that arrived, not the newest that exists, and arm 4's
msgs.length > 0 filter dropped buckets crowded out of the shared 100 — so a
session with many actor buckets could still fall through to []. Both critiques
are 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 (or
fetch per bucket)
.

Tests

New test/session/internal-session-prohibition.test.ts (9 cases): the rule
(roots, SQL-NULL parent, fail-closed), the renderer path against real
Session + ActorRegistry rows (root / peer child / checkpoint-writer-1 host /
unregistered fork), and the switch path separately.

test/cli/tui/select-messages.test.ts: the two arm-4 cases are inverted in
place, 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-agentID
path. No existing assertion was weakened — the pre-existing switch test
(test/tool/session-tool.test.ts:302) targets a root session, which the
predicate permits, and passes unmodified.

Revert probe A — renderer guard removed from routes/session/index.tsx

error: expect(received).toBeGreaterThan(expected)
Expected: > -1
Received: -1
(fail) internal-machinery sessions are never rendered — renderer path > the session route wires the guard in before it syncs the transcript [21.22ms]
 8 pass
 1 fail

Revert probe B — switch refusal removed from tool/session.ts

(fail) internal-machinery sessions are never rendered — session tool switch path > switch refuses a checkpoint-writer host without publishing SessionSelect [893.01ms]
(fail) internal-machinery sessions are never rendered — session tool switch path > switch refuses an unregistered child fork without publishing [280.54ms]
(fail) internal-machinery sessions are never rendered — session tool switch path > switch refuses an id with no session row without publishing [272.37ms]
 6 pass
 3 fail

The two probes fail disjoint sets — the renderer guard and the switch refusal
are independently pinned.

Revert probe C — arm 4 restored (replaces this PR's earlier probe)

The earlier probe expected exactly 2 failures with Received: []. Its replacement
is the same two cases, inverted:

error: expect(received).toEqual(expected)
- []
+ [
+   {
+     "agentID": "general-2",
+     "id": "m9",
+   },
+ ]
(fail) selectMessages > does NOT fall back to an actor-hosted bucket (prohibition, not blank-pane fallback) [2.32ms]
(fail) selectMessages > does NOT pick the newest bucket when an empty-main session has several actor buckets [0.60ms]
 5 pass
 2 fail

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.ts

  • pristine baseline at c359ec246: 72 pass / 1 skip / 0 fail (73 tests, 4 files — the new file does not exist there)
  • this PR: 82 pass / 1 skip / 0 fail (83 tests, 5 files)

Delta +10 = 9 new prohibition cases + 1 new selectMessages case. bun typecheckexit 0 (12/12 tasks).

Not touched, deliberately

tool/session.ts:684's unfiltered sessions.children(parentID) is not a leak
and is byte-identical to origin/main in this PR (verified by diff). Every
model-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 than visible: 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 :698 applies, regressing #1741.

Rebase history

Rebased fd56004a9c359ec246 onto origin/main (b81670870) when
CONFLICTING/DIRTY after #1960 (f91eb6334). One hunk in context/sync.tsx: both
sides appended a different new function after bucketMessages and git collapsed
them onto the shared closing brace — main's nextSessionStatus() and this PR's
selectMessages(). Kept both, in that order; #1960's useToastOptional() import
and use are untouched. origin/main was re-verified at b8167087 immediately
before this push and had not moved.

What I did NOT verify

  • No live TUI run of the refusal. There is no Solid render harness for the
    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 it
    calls is covered behaviourally against real DB rows. The toast copy and the
    home-navigation have not been seen on screen in this PR.
  • No verification that all eight entry paths reach this effect at runtime. The
    choke-point claim is from reading the navigation code, not from instrumenting
    each path.
  • The 1295-session measurement is one local DB, read-only, and it moves — it
    grew by one checkpoint-writer host during this task. Treat the shape as durable
    and the absolute counts as a snapshot.
  • CI now green 6/6 on head c59298dbc (lint, typecheck, unit shards 1-4). CI does not exercise the TUI render path, so it does not close the first gap above.

@wqymi
wqymi force-pushed the wq-blank-transcript branch from fd56004 to 09b28bc Compare July 29, 2026 16:36
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
wqymi force-pushed the wq-blank-transcript branch from 09b28bc to c359ec2 Compare July 29, 2026 16:53
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.
@wqymi wqymi changed the title fix(tui): render actor-hosted sessions instead of a blank transcript fix(tui): never render an internal-machinery session (checkpoint-writer hosts, ask forks, subagent hosts) Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant