Skip to content

fix(tui): hide runtime-spawned agent hosts from the Sessions list - #2035

Merged
wqymi merged 1 commit into
mainfrom
fix/checkpoint-writer-tui-visibility
Aug 7, 2026
Merged

fix(tui): hide runtime-spawned agent hosts from the Sessions list#2035
wqymi merged 1 commit into
mainfrom
fix/checkpoint-writer-tui-visibility

Conversation

@wqymi

@wqymi wqymi commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Symptom

In the TUI's Sessions dialog, every checkpoint adds one more internal-machinery session, with a title of the form:

↳ checkpoint-writer: Previous checkpoint: /Users/mi/.local/s…

The user's feedback was "this was supposedly already fixed, why is it still here".

Relationship to #1964: two paths, and the guard cannot substitute for the list

What #1964 fixed was the navigation guardroutes/session/index.tsx uses verifySessionRenderable to refuse a machinery host before rendering, so opening one gets rejected. But the list itself was never filtered, so those hosts kept getting listed. These are two independent paths, and the former cannot substitute for the latter.

The file comment in visibility.ts in fact already spells out this distinction:

Being absent here means "not offered in a list"; what may never be RENDERED is narrower and lives in session/visibility.ts

The server-side capability is complete as well: Session.children(parentID, { visible: true }) filters by the peer actor row, GET /session/:id/children?visible= exposes it, and sync.sync() does fetch child sessions with visible: true.

The leak path is elsewhere: the session.updated arm in sync.tsx unconditionally inserts any session it receives into store.session, bypassing that visible: true fetch. And when session/checkpoint.ts creates the writer host, the title is already set (title: "checkpoint-writer: ${rangeDesc}"), and this happens before the actor row is registered — so it enters the store via session.updated carrying a directly displayable title, and is waved straight through by the filter in dialog-session-list.tsx, which only looks at the parent/child relationship:

.filter((x) => x.parentID === undefined || isChildOfCurrent(x))

Not just a noisy list: it pollutes the orchestrator's session inventory

These hosts are registered as real child sessions. Measured on one orchestrator fleet: 13 of 19 child sessions were checkpoint-writer, all with a turn count of 0. So the impact goes beyond a dirty TUI list — the orchestrator's own session inventory is drowned in these empty shells.

The change

The child-session arm gains a listable(x) check, using the very same classifySession predicate as the guard:

const listable = (x: { id: string; parentID?: string }) =>
  classifySession(x, sync.data.actor?.[x.id]).renderable

Why reuse classifySession instead of writing a new rule: with the list and the guard sharing one predicate, the self-contradictory "it's in the list but opening it is refused" state becomes impossible. The rule has exactly one home.

The actor row is taken from the sync store rather than from an additional network request, because the two populations line up exactly: a host can only be in the store if it was created within this TUI lifecycle (bootstrap loads only roots, and sync.sync() loads child sessions with visible: true), and that same lifecycle also delivers its actor.registered event — so for exactly the set that can leak, the row read here necessarily exists.

The fail-open choice

classifySession judges a session displayable when there is no actor row. This is deliberate, and it is the reason #1964 narrowed the rule down from mode !== "peer" in the first place: the old fail-closed proxy over-blocked 28 sessions (11 ask: forks + 17 pre-registry @explore/@general transcripts), none of which were machinery. Over-hiding real sessions is the more expensive mistake.

The cost is that a writer host that was just created and has not yet registered its actor row shows up briefly once; it has zero messages and disappears a moment later. Compared to the current "one permanent row per checkpoint" that is an order-of-magnitude improvement, and if you do click into it, the guard still refuses.

Regression risk and the test that holds it

The biggest risk is collateral damage to legitimate child sessions. Child sessions the orchestrator creates via session create (actor/spawn.ts, mode: "peer", including the ones prefixed with [topic:…]) must remain visible. They hold a mode: "peer" row and are judged renderable directly by the peer arm of classifySession, so this filter cannot catch them by association.

Adds test/cli/tui/session-list-visibility.test.ts (4 cases), built from real sessions plus real actor rows, pinning:

  1. Constructed from the real titles in the user's list — [topic:memory-switch] memory 开关方案调研 and build: 在 mimocode 引擎侧实现「memory 写入开关」 — asserting they must be renderable, while the writer host must be refused.
  2. The title-swap case: a peer row wearing the writer's title → still displayed; a writer row wearing a [topic:…] title → still hidden. This case exists specifically to prevent someone later taking the shortcut of filtering by title prefix — under which a user session that happens to be named checkpoint-writer: … would vanish into thin air.
  3. A source-level assertion (the dialog has no Solid rendering fixture; same approach and same reasoning as fix(tui): render actor-hosted transcripts, refuse only runtime-spawned agent hosts #1964's assertion on the route guard): that the filter expression really does take listable(x), and that no "checkpoint-writer literal and no startsWith( appears in the code.

Verification

Baseline 8061d5fa0 (latest main).

$ bun typecheck
$ tsgo --noEmit                                                    <- no output, passes

$ bun test test/cli/tui/session-list-visibility.test.ts            -> 4 pass,  0 fail
$ bun test test/session/internal-session-prohibition.test.ts       -> 19 pass, 0 fail
$ bun test test/cli/tui/select-messages.test.ts                    -> 8 pass,  0 fail

Mutation testing (proving the regression pin actually fires): reverting the filter expression to its original form and re-running → 3 pass, 1 fail, reporting

(fail) the Sessions dialog wires the visibility predicate into its child arm
       > filters children through classifySession
Expected to contain: "x.parentID === undefined || (isChildOfCurrent(x) && listable(x))"

That was subsequently restored from git, and git status is clean.

Follow-up notes (unrelated to this PR, but reviewers should know)

The user hit this on npm exec @mimo-ai/cli@latest, which at the time gave them 0.1.9 (released 2026-07-24), whereas #1964 landed on 2026-07-31 — so that binary did not even have the navigation guard, which explains the "it was fixed but it's still here".

0.1.10 was released today (2026-08-05T09:17:36Z, version bump 8061d5fa0 chore: bump version to 0.1.10 (#2034)), and 85a7faca4 (#1964) is confirmed to be one of its ancestors. So 0.1.10 does carry the navigation guard and only lacks this PR's list filter: after upgrading to 0.1.10 the user will be refused when opening one, but they will still be listed until this PR reaches the next release.

#1964 put the render prohibition behind the navigation gate, so opening a
checkpoint-writer host is refused — but the Sessions dialog still LISTED one
`↳ checkpoint-writer: …` row per checkpoint. Those are two separate paths and
the gate cannot stand in for the list.

The leak: sync.sync() fetches children with `visible: true`, but the
`session.updated` arm in sync.tsx inserts EVERY session it sees into the store,
and checkpoint.ts creates the writer host with its title already set — before
the actor row is registered — so it arrives on that path with a display-ready
title and `isChildOfCurrent` passed it straight through.

Filter the child arm through classifySession, the same predicate the gate uses,
so the list cannot disagree with what opening the entry would do. Fails open (no
actor rows ⇒ listed), which is what keeps orchestrator `session create` children
listed: they own a mode "peer" row and classify renderable outright.
@wqymi wqymi changed the title fix(tui): 把 runtime-spawned agent host 从 Sessions 列表里隐藏 fix(tui): hide runtime-spawned agent hosts from the Sessions list Aug 6, 2026
@wqymi
wqymi merged commit c518800 into main Aug 7, 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