fix(orchestration): unify structured-session status across worktree ps and sidebar (#19217)

* fix(orchestration): stop worktree ps reporting a busy structured session as idle

A worktree running a structured Claude or Codex chat read as idle to `orca
worktree ps`, while the desktop sidebar showed the same session working. The
sidebar was right: the host already projects a status summary for every
structured session and publishes it, and the renderer maps it into an agent
row. `worktree ps` simply never consumed it, so the agent-facing surface was
the blind one.

Structured sessions have no PTY, so they reach neither the hook snapshots nor
the retained ones that every other row is built from. This reads the summaries
the host has already published and applies the same projection the sidebar
does — working, attention as blocked, otherwise done — so the CLI and the GUI
cannot disagree about one session.

Two things worth knowing:

The connected-PTY evidence gate had to be skipped for these rows. It exists to
drop a row whose PTY is gone, which is the wrong question for a session that
never had one; a structured row's liveness evidence is the status feed that
produced it. The exemption is keyed on the row being structured, so every
PTY-backed row keeps today's behaviour.

`RuntimeWorktreeAgentRow` needed no change. It was already a non-PTY shape —
paneKey, state, agentType, and no ptyId, connected or writable — so a
structured row fits without inventing a fake terminal coordinate.

The pane key is the DERIVED one the renderer already publishes, never the
orchestration bearer handle or the minted worker pane key: both are
credentials, since `orchestration check` is identity-gated and accepts a
caller-supplied pane key.

`orca terminal list` is deliberately untouched, and a test pins that. Adding
rows there breaks real consumers: mobile mounts a terminal WebView per row that
can never receive a frame, a `connected`-keyed refresh check goes permanently
true and pins shipped clients to a fast cadence with no exit, and the plugin
projection has no field that can carry `writable: false`. Every safe consumer
of a terminal summary checks `ptyId`; the breaking ones key off `connected` or
mere row presence, which no added field can qualify. An honest partial-listing
count there is a separate change.

* fix(runtime): report only live structured sessions in worktree ps

The status feed's `published` map is a broadcast cache, not a roster. It
deliberately never retracts — an evicted idle session is still idle, and a
reloading renderer must not lose every settled row — so enumerating it lists
every session the host has ever opened, and eviction's `forget-session` step
deletes the session from the live map while touching nothing else.

Reading it as a roster made `worktree ps` report a closed chat forever. The
sharp edge is a chat closed while an approval was pending: a deliberate close
does not settle a pending prompt, so the retained summary stays `attention`,
maps to a `blocked` row, and merges the worktree to `permission` for the whole
30-minute freshness window — on the CLI and on the mobile sidebar it backs.

The poller now answers from the sessions the host still holds, intersecting the
live map with the retained projections. `subscribe()` and its snapshot are
untouched: retention there is the point. Gating on the live session set rather
than the visible tabs keeps a headless orchestration worker listed, which is
what the agent-facing surface is for.

Also folds out two things the enumerator left behind: the working/attention/idle
to working/blocked/done mapping now lives once in the shared projection module
instead of once per process, which is what actually enforces "the CLI and the
GUI cannot disagree"; and the structured row source no longer builds a
write-only `payload` behind an `as` cast that compensated for nothing. The
structured source construction moves to its own module to keep
runtime-worktree-agent-rows.ts clear of the 300-line cap.

* test(runtime): execute the structured-host call site in worktree ps

Both structured-row suites called attachRuntimeWorktreeAgentRows directly
with summaries they built themselves, so nothing ever ran getWorktreePs's
own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()`. That
file carries `@ts-nocheck`, so renaming the accessor was green in typecheck
and in the suite, while `orca worktree ps` and mobile's 3s poll would throw
a TypeError for every user — the `?.` optional-chains the host, not the
method. Swapping the call back to a whole-cache read was equally invisible:
the liveness suite injects feed.liveSessionSummaries() itself, and the
string-match guard only needs the identifier to appear somewhere in the file.

Drives the real runtime with a stub host over a real status feed that has
published two sessions and forgotten one, asserting the live session's row
reaches ps output, that the live accessor is the one called, and that the
`?? []` fallback still returns a page with no host installed. The stub is
typed against the real host, so a class-side rename reddens tc here.

* refactor(runtime): admit agent sources before worktree row projection

* fix(runtime): preserve host-authoritative structured status

* Fix structured host session activity lifecycle

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-08 21:50:51 -07:00
committed by GitHub
co-authored by Merge Sim
parent 72befaf360
commit 1c1cb7115a
34 changed files with 1132 additions and 172 deletions
@@ -0,0 +1,120 @@
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
import { describe, expect, it } from 'vitest'
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../shared/structured-agent-session-projection'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
/**
* A structured session has no PTY, so it reaches none of the hook or retained snapshots that every
* other row comes from. Before this, `worktree ps` reported a worktree running one as idle while
* the sidebar showed it working — the CLI, which is the agent-facing surface, was the blind one.
*/
const WORKTREE_ID = 'repo-1::/workspace/app'
function summary(over: Partial<AgentSessionStatusSummary> = {}): AgentSessionStatusSummary {
return {
sessionId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
workspaceId: WORKTREE_ID,
agent: 'claude',
status: 'working',
latestPrompt: 'ship the thing',
updatedAt: 1_757_030_400_000,
hostExecutionOwned: true,
...over
} as AgentSessionStatusSummary
}
function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummary {
const row = {
worktreeId: WORKTREE_ID,
status: 'inactive',
hasHostSidebarActivity: false,
agents: []
} as unknown as RuntimeWorktreePsSummary
const summariesById = new Map<string, RuntimeWorktreePsSummary>([[WORKTREE_ID, row]])
attachRuntimeWorktreeAgentRows({
summaries: summariesById,
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
missingWorktreeIds: new Set(),
workingTerminalEvidenceByWorktreeId: new Map(),
rowSources: collectRuntimeWorktreeAgentSources({
mirroredWorktreeIdByTabId: new Map(),
connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() },
retainedSnapshots: [],
hookSnapshots: [],
structuredSummaries: summaries
}),
orchestrationByPaneKey: null,
getSummary: (map, _p, _m, id) => map.get(id) ?? null
})
return row
}
describe('worktree ps reports structured sessions', () => {
it('a busy structured session is not reported idle', () => {
const row = attach([summary()])
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('working')
expect(row.agents[0]?.agentType).toBe('claude')
expect(row.agents[0]?.prompt).toBe('ship the thing')
})
// The same projection the sidebar applies, so the two surfaces cannot disagree about one session.
it('maps attention to blocked and idle to done', () => {
expect(attach([summary({ status: 'attention' })]).agents[0]?.state).toBe('blocked')
expect(attach([summary({ status: 'idle' })]).agents[0]?.state).toBe('done')
})
it('does not turn a completed host-held session into permission', () => {
const row = attach([summary({ status: 'idle' })])
expect(row.status).toBe('inactive')
expect(row.hasHostSidebarActivity).toBe(false)
})
it('reports the DERIVED pane key, never an orchestration credential', () => {
const row = attach([summary()])
const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
expect(row.agents[0]?.paneKey).toBe(
structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId)
)
})
// Null status means no turn has been persisted; the chat itself shows nothing, so neither does this.
it('omits a session with no projected status', () => {
expect(attach([summary({ status: null })]).agents).toHaveLength(0)
})
})
/**
* The deliberate non-goal. Adding structured rows to `terminal list` was investigated and rejected:
* mobile mounts a terminal WebView per row that can never receive a frame, a `connected`-keyed
* refresh check goes permanently true and pins shipped clients to a fast cadence with no exit, and
* the plugin projection has no field that can carry `writable: false`. Every SAFE consumer of a
* terminal summary checks `ptyId`; the breaking ones key off `connected` or mere row presence,
* which no added field can qualify. A separate change publishes an honest partial-listing count
* there instead. This pins that only `worktree ps` gained the enumerator.
*/
describe('terminal listing is deliberately left alone', () => {
it('only worktree ps consumes the structured status summaries', async () => {
const { readFile } = await import('node:fs/promises')
// orca-runtime-subscribe-to-terminal-resize.ts owns listTerminals.
const listing = await readFile(
new URL('./orca-runtime-subscribe-to-terminal-resize.ts', import.meta.url),
'utf8'
)
// Guard the guard: an empty read would make every assertion below vacuously true.
expect(listing).toContain('async listTerminals(')
expect(listing).not.toContain('liveSessionStatusSummaries')
expect(listing).not.toContain('structuredSummaries')
const worktreePs = await readFile(
new URL('./orca-runtime-get-worktree-ps.ts', import.meta.url),
'utf8'
)
expect(worktreePs).toContain('liveSessionStatusSummaries')
})
})