Files
orca/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts
T
Merge Sim 5c83abfe2e 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.
2026-09-07 12:51:41 -07:00

123 lines
4.2 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open'
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
/**
* The whole chain `worktree ps` walks: journal -> status feed -> agent rows -> worktree status.
*
* The feed's `published` map never retracts, so reading it as a roster reports every session the
* app has ever opened. A closed chat that was waiting on an approval is the sharp edge: deliberate
* close does not settle a pending prompt, so the retained summary stays `attention`, which maps to
* a `blocked` row and merges the worktree to `permission` for the 30-minute freshness window.
*/
const WORKTREE_ID = 'repo-1::/workspace/app'
const SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
const IDENTITY = {
provider: 'codex',
threadId: 'thread-1',
turnId: 'turn-1',
ordinal: 0
} as const
let root: string
const journals = createTrackedJournalOpener()
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-structured-ps-liveness-'))
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
/** A session parked on an approval nobody answered — the state a deliberate close leaves behind. */
async function awaitingApproval() {
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: WORKTREE_ID,
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
journalDir: join(root, SESSION)
})
await journal.appendItem(
{ ...IDENTITY, ordinal: 1 },
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'rm the branch' }] },
{ fence: 1 }
)
await journal.appendItem(
{ ...IDENTITY, ordinal: 2 },
{
kind: 'approval',
title: 'Run the command?',
detail: null,
options: [{ id: 'allow', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
{ fence: 1 }
)
const sessions = new Map([
[
SESSION,
{ journal, params: { location: { workspaceId: WORKTREE_ID }, provider: 'codex' as const } }
]
])
const feed = new StructuredAgentSessionStatusFeed({
sessions,
getRecord: () => null,
now: () => Date.now()
})
feed.publish(SESSION, journal)
return { feed, sessions }
}
function worktreeFor(feed: StructuredAgentSessionStatusFeed): RuntimeWorktreePsSummary {
const row = {
worktreeId: WORKTREE_ID,
status: 'inactive',
agents: []
} as unknown as RuntimeWorktreePsSummary
attachRuntimeWorktreeAgentRows({
summaries: new Map([[WORKTREE_ID, row]]),
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
missingWorktreeIds: new Set(),
mirroredWorktreeIdByTabId: new Map(),
connectedPtyEvidence: { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() },
workingTerminalEvidenceByWorktreeId: new Map(),
retainedSnapshots: [],
hookSnapshots: [],
structuredSummaries: feed.liveSessionSummaries(),
orchestrationByPaneKey: null,
getSummary: (map, _paths, _missing, id) => map.get(id) ?? null
})
return row
}
describe('worktree ps and a closed structured chat', () => {
it('reports the blocked row while the session is still held', async () => {
const { feed } = await awaitingApproval()
const row = worktreeFor(feed)
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('blocked')
expect(row.status).toBe('permission')
})
it('stops reporting it once eviction forgets the session', async () => {
const { feed, sessions } = await awaitingApproval()
// `forget-session`, the last eviction step, does exactly this and nothing to the feed.
sessions.delete(SESSION)
const row = worktreeFor(feed)
expect(row.agents).toHaveLength(0)
expect(row.status).toBe('inactive')
})
})