mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
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:
co-authored by
Merge Sim
parent
72befaf360
commit
1c1cb7115a
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user