fix(tui): render actor-hosted transcripts, refuse only runtime-spawned agent hosts - #1964
Merged
Conversation
wqymi
force-pushed
the
wq-blank-transcript
branch
2 times, most recently
from
July 29, 2026 16:53
09b28bc to
c359ec2
Compare
wqymi
added a commit
that referenced
this pull request
Jul 30, 2026
…hosts Follow-up on this branch's own prohibition, which keyed on "not a peer child" and over-blocked. User ruling: "只有 checkpoint writer 是明确不需要渲染出来的。 之前是说会跳转到 checkpoint writer,因此要禁止跳转进去。" `visible: true` resolves to ActorRegistry.mode === "peer", so "renderable iff root or among the parent's visible children" was a PROXY for internal machinery. Measured on the live DB the proxy refused 1330 child sessions where only 1302 are machinery: it also refused the 11 `session ask` fork-query hosts (tool/session.ts:128, buckets build-1 x7, compose-1 x3, general-1 x1) and the 17 pre-registry @explore/@general children that hold their transcript under `main`. A compose or workflow run that went wrong is exactly when you open those. The criterion is now SYSTEM_SPAWNED_AGENT_TYPES (agent/config.ts) — already "spawned by the runtime, NOT by the model", already the discriminator for permission routing and the prune/bootstrap/memory/recall skips. Read off the session's OWN actor rows, so no parent round-trip. Refuses 1302, admits 200. Fails OPEN, deliberately, where the old rule failed closed: - all 17 no-actor-row children are real pre-registry transcripts, 0/17 has a non-main bucket, so not one is an actor-hosted machinery session; - a writer host cannot present as "no rows" once it holds a message — spawnSubagent registers at actor/spawn.ts:731 before forking the work that writes one (:762), the row is ON DELETE CASCADE, and 1302/1302 carry it. The residual create-then-register window has zero messages, so fail-open's worst case there is an empty pane, not a leaked transcript. Root and peer arms are checked first, and that ordering is load-bearing: one real root in the live DB carries a checkpoint-writer row, from when the writer registered under the session it was checkpointing instead of its own child. Restores selectMessages' actor-bucket fallback, deleted earlier in this branch because the broad prohibition made its population unreachable. That inference is now backwards: machinery is refused at the route BEFORE bucket selection, so the fallback can no longer render a checkpoint-writer transcript, and without it the 11 ask forks are the blank pane #1964 was opened to fix. Also corrects the excluded-set comment in Session.children, sync.tsx and visibility.ts. "ask-tool forks" is REAL — `session ask`'s forkQuery creates a child session titled `ask: …` (tool/session.ts:128) and is the whole of the 11. "workflow subagent sessions" is the phantom: a workflow's agent() spawns mode "subagent" under its OWN sessionID (workflow/runtime.ts:814-816, :945-948) and creates no child session. Exactly three paths create one: spawn.ts:674 (peer), tool/session.ts:128 (ask fork), checkpoint.ts:851 (writer host).
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.
Scope: a checkpoint session must never be rendered — 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.
…hosts Follow-up on this branch's own prohibition, which keyed on "not a peer child" and over-blocked. Narrowed scope: only the runtime-spawned writer / dream / distill hosts are clearly not conversations; everything else a user can reach should render. The prohibition exists because navigation was landing inside a writer host. `visible: true` resolves to ActorRegistry.mode === "peer", so "renderable iff root or among the parent's visible children" was a PROXY for internal machinery. Measured on the live DB the proxy refused 1330 child sessions where only 1302 are machinery: it also refused the 11 `session ask` fork-query hosts (tool/session.ts:128, buckets build-1 x7, compose-1 x3, general-1 x1) and the 17 pre-registry @explore/@general children that hold their transcript under `main`. A compose or workflow run that went wrong is exactly when you open those. The criterion is now SYSTEM_SPAWNED_AGENT_TYPES (agent/config.ts) — already "spawned by the runtime, NOT by the model", already the discriminator for permission routing and the prune/bootstrap/memory/recall skips. Read off the session's OWN actor rows, so no parent round-trip. Refuses 1302, admits 200. Fails OPEN, deliberately, where the old rule failed closed: - all 17 no-actor-row children are real pre-registry transcripts, 0/17 has a non-main bucket, so not one is an actor-hosted machinery session; - a writer host cannot present as "no rows" once it holds a message — spawnSubagent registers at actor/spawn.ts:731 before forking the work that writes one (:762), the row is ON DELETE CASCADE, and 1302/1302 carry it. The residual create-then-register window has zero messages, so fail-open's worst case there is an empty pane, not a leaked transcript. Root and peer arms are checked first, and that ordering is load-bearing: one real root in the live DB carries a checkpoint-writer row, from when the writer registered under the session it was checkpointing instead of its own child. Restores selectMessages' actor-bucket fallback, deleted earlier in this branch because the broad prohibition made its population unreachable. That inference is now backwards: machinery is refused at the route BEFORE bucket selection, so the fallback can no longer render a checkpoint-writer transcript, and without it the 11 ask forks are the blank pane #1964 was opened to fix. Also corrects the excluded-set comment in Session.children, sync.tsx and visibility.ts. "ask-tool forks" is REAL — `session ask`'s forkQuery creates a child session titled `ask: …` (tool/session.ts:128) and is the whole of the 11. "workflow subagent sessions" is the phantom: a workflow's agent() spawns mode "subagent" under its OWN sessionID (workflow/runtime.ts:814-816, :945-948) and creates no child session. Exactly three paths create one: spawn.ts:674 (peer), tool/session.ts:128 (ask fork), checkpoint.ts:851 (writer host).
wqymi
force-pushed
the
wq-blank-transcript
branch
from
July 30, 2026 17:32
624bf7a to
0b458f6
Compare
verifySessionRenderable swallowed the actor fetch with `.catch(() => undefined)` and handed the result to classifySession, where `undefined` already meant "this session has no actor rows". A failed read and a genuinely empty read were therefore the same input, and both took the fail-open arm: one transient failure of `session.actors` rendered a checkpoint-writer host, which is the exact population this prohibition exists to refuse. It did so silently — no log, no toast, no trace that anything had failed. The fail-open evidence does not cover this case. It was measured on rows that are genuinely ABSENT: all 17 no-actor-row children in the live DB are real pre-registry @explore/@general transcripts stored under `main`, none of them machinery. Rows that exist but could not be READ are a different population — every child is a candidate, and 1304 of the 1504 live children carry a system-spawned row, so a read failure that fell through would render a writer host in roughly six cases out of seven. A filter may fail open; a gate must fail closed, and this is a gate. The two states are now distinct. classifySession stays pure and never learns about read failures: `undefined` means "no rows" and nothing else. A failed read goes to the new classifyUnreadableActors, which logs the session id and the cause and refuses with a reason that says "could not verify" rather than naming an agent, so an operator can tell a broken read from the product prohibition. One bounded retry comes first. Failing closed on a single dropped request would make a real transcript briefly unopenable, which is the OTHER error this branch was narrowed to avoid; anything that survives a second attempt is not transient, and a gate that retries until it succeeds is a gate that never closes. A root never reaches the refusal at all, because its verdict does not depend on its rows. Both enforcement points move together: - The route's SDK call now passes `throwOnError`. Without it the generated client RESOLVES `{ data: undefined }` on an HTTP error (gen/client/client.gen.ts:167-177), so a 500 from /session/:id/actors reached the classifier as "no rows" and rendered — the leak did not even need the swallowed catch, and could not have been closed in visibility.ts alone. - The session tool's `switch` wraps listBySession in Effect.exit. It is typed as never-failing, so a DB error surfaces as a defect: invisible to Effect.catch, and left unwrapped it aborts the whole tool call, which reaches the model as a crash rather than a decision. The refusal it returns names the read failure instead of the prohibition and stays model-actionable. Tests assert absent rows and unreadable rows separately on both paths, plus the log record and the retry bound, so the two states cannot be re-collapsed quietly. The prior "unreadable child still renders" test is rewritten rather than relaxed: it asserted both states at once because the swallowing catch made them one, and its surviving absent-rows assertions are kept verbatim.
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.
Two halves. The TUI must render a real transcript that happens to run under an
actor id, and refuse to open a session that exists only to host a runtime-spawned
agent. An earlier revision here got the refusal too wide and deleted the rendering.
What changed
The prohibition keyed on
children(parentID, { visible: true }), andvisible: trueresolves toActorRegistry.mode === "peer"— a list filter,not a statement about what is a conversation. So it really read "every non-peer
child is forbidden", and over-blocked real user-facing sessions.
It now refuses one thing: a session carrying an actor row whose
agentis inSYSTEM_SPAWNED_AGENT_TYPES(agent/config.ts; already the discriminator forpermission routing and the prune/bootstrap/memory/recall skips), read off the
session's own rows. Enforced in
session/visibility.ts, from theroutes/session/index.tsxroute effect every entry path must pass and from thesessiontool'sswitch.selectMessages' actor-bucket fallback (context/sync.tsx) is restored so thosesessions render instead of a blank pane — the bug #1964 was opened for — and is
safe now because machinery is refused at the route before bucket selection runs.
Net on the live DB: 28 sessions newly viewable, 0 newly refused.
One input class now refuses that previously rendered: a child whose actor rows cannot be read — as opposed to genuinely having none — fails closed after one bounded retry, with a distinct
could not verifyreason; the fail-open evidence covers absent rows only.Root and peer are checked before the agent set, and that ordering is
load-bearing: one real root in the live DB carries a
checkpoint-writerrow,from when the writer registered under the session it was checkpointing instead of
its own child. Checking the agent set first would refuse that user's own session.
Verify
bun test test/session/internal-session-prohibition.test.ts test/cli/tui/select-messages.test.ts→ 27 pass / 0 fail, 73
expect()calls.bun typecheck --force→ exit 0.The fail-open argument, measured populations, entry-path list and flip history of
the two rewritten assertions live in the code and tests at those files.