Skip to content

fix(tui): render actor-hosted transcripts, refuse only runtime-spawned agent hosts - #1964

Merged
wqymi merged 4 commits into
mainfrom
wq-blank-transcript
Jul 31, 2026
Merged

fix(tui): render actor-hosted transcripts, refuse only runtime-spawned agent hosts#1964
wqymi merged 4 commits into
mainfrom
wq-blank-transcript

Conversation

@wqymi

@wqymi wqymi commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

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 }), and
visible: true resolves to ActorRegistry.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 agent is in
SYSTEM_SPAWNED_AGENT_TYPES (agent/config.ts; already the discriminator for
permission routing and the prune/bootstrap/memory/recall skips), read off the
session's own rows. Enforced in session/visibility.ts, from the
routes/session/index.tsx route effect every entry path must pass and from the
session tool's switch.

selectMessages' actor-bucket fallback (context/sync.tsx) is restored so those
sessions 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 verify reason; 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-writer row,
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.

@wqymi
wqymi force-pushed the wq-blank-transcript branch 2 times, most recently from 09b28bc to c359ec2 Compare July 29, 2026 16:53
@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
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).
@wqymi wqymi changed the title fix(tui): never render an internal-machinery session (checkpoint-writer hosts, ask forks, subagent hosts) fix(tui): render actor-hosted transcripts, refuse only runtime-spawned agent hosts Jul 30, 2026
wqymi added 3 commits July 31, 2026 01:30
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
wqymi force-pushed the wq-blank-transcript branch from 624bf7a to 0b458f6 Compare July 30, 2026 17:32
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.
@wqymi
wqymi merged commit 85a7fac into main Jul 31, 2026
6 checks passed
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