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
+46
View File
@@ -0,0 +1,46 @@
# Structured worktree status validation
Validated on September 7, 2026 in a background Electron dev instance of
`pr19217-review-r2`, based on `ce1024096b` with the source-adapter refactor.
CDP app identity confirmed the checkout; screenshots show the full hidden renderer.
The command output is the real `orca worktree ps --json` response reduced to status,
agent state, provider, and pane key for readability.
## Functional correctness
A real Codex structured session appeared as `working` in `worktree.ps` while the
sidebar showed working. Closing its chat tab removed that exact session's row and
returned the worktree to `active`. A different completed chat remained present,
confirming that closure removed only the selected session.
- [Working: CLI and sidebar](working.png)
- [Closed: CLI and sidebar](closed.png)
The disappearing session is `codex_40677067_f492_4d7d_86dd_ec566ede04c3`.
The host's held-session roster controls eligibility; its retained broadcast cache
is history, not a roster. Failed eviction intentionally keeps an entry for retry.
## Architecture
PTY reconciliation and process admission belong to the PTY source adapter.
Structured input comes from the current host's held-session projections. One
admitted collection feeds row shaping and worktree aggregation, with no structured
boolean bypass. PTY hooks and retained reports still arrive independently, so their
precedence and conservative remote evidence rules remain necessary. No second
persistent status store or provider polling was introduced.
## Validation and limits
Independent final review found no proven issues. Runtime, host lifecycle, status
feed and source-admission suites passed: 1,344 tests, one skipped. Node typecheck,
targeted lint and diff checks passed. Ablating the runtime call to enumerate
retained history caused the executable call-site test to fail with two rows where
one was expected; restoring the live accessor passed both call-site tests.
Live screenshots prove Codex working and closure on macOS. Claude provider turns,
approval/input states, live Windows/Linux/WSL/SSH/relay/mobile scenarios and
release-scale latency/heap measurements remain unverified. Existing tests cover
remote/WSL evidence, monitoring precedence and lifecycle cases. The existing
30-minute freshness rule and CLI activity timestamps are preserved; complete
CLI/sidebar timing parity is not claimed. The wire keeps its existing row shape
and status vocabulary; mobile receives the new rows without a new opcode.
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -38,4 +38,5 @@ export type StructuredAgentSessionAttachContext = {
reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
now: () => number
publishStatus?: (sessionId: string) => void
}
@@ -22,6 +22,7 @@ export class StructuredAgentSessionEventRecovery {
sessions: Map<string, StructuredAgentSessionHostSession>
flushLifecycle: (sessionId: string) => Promise<StructuredAgentSessionSinkBarrier>
publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void
publishStatus?: (sessionId: string) => void
hasResumeCapableHolder: (sessionId: string) => boolean
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
now: () => number
@@ -25,6 +25,7 @@ type HostHandoffAccess = {
flush: (sessionId: string) => Promise<void>
serialize: (sessionId: string, task: () => Promise<void>) => Promise<void>
subscribers: AgentSessionSubscribers
publishStatus?: (sessionId: string) => void
now: () => number
}
@@ -82,6 +83,7 @@ export function createStructuredAgentSessionHostHandoff(
return { state: 'live' }
}
host.session(sessionId).hasProviderChild = false
host.publishStatus?.(sessionId)
try {
await host.flush(sessionId)
host.eventSink(sessionId).unbind()
@@ -243,6 +245,7 @@ export async function acquireNativeHandoffOwner(
return rethrowAfterAgentSessionAcquisitionCleanup(deps.adapter, input.sessionId, error)
}
session.hasProviderChild = true
host.publishStatus?.(input.sessionId)
session.fence = proved.lease.runtimeFence
session.acquisitionGeneration = acquired.acquisitionGeneration ?? null
eventSink.bind({
@@ -93,13 +93,19 @@ export async function resumeStructuredAgentSessionForHold(
export function createStructuredAgentSessionHolds(
context: StructuredAgentSessionLifetimeContext,
input: {
resume: (sessionId: string) => Promise<void>
evict: (sessionId: string) => Promise<void>
reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
attach: Parameters<typeof resumeHeldStructuredAgentSession>[0]['attach']
close: (sessionId: string) => Promise<void>
}
): StructuredAgentSessionHolds {
return new StructuredAgentSessionHolds({
resume: input.resume,
evict: input.evict,
resume: (sessionId) =>
resumeStructuredAgentSessionForHold(
{ ...context, reconcileLeases: input.reconcileLeases },
sessionId,
input.attach
),
evict: input.close,
hasProviderChild: (sessionId) => hasProviderChild(context, sessionId),
isTurnActive: (sessionId) => {
const session = context.sessions.get(sessionId)
@@ -27,7 +27,6 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach-
import {
createStructuredAgentSessionHolds,
evictHeldStructuredAgentSession,
resumeStructuredAgentSessionForHold,
type StructuredAgentSessionLifetimeContext
} from './structured-agent-session-host-lifetime'
import type {
@@ -118,16 +117,13 @@ export class StructuredAgentSessionHost {
flush: (sessionId) => this.flushStreamedEvents(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
subscribers: this.subscribers,
publishStatus: (sessionId) => this.statusFeed.publish(sessionId),
now: this.now
})
this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), {
resume: (sessionId) =>
resumeStructuredAgentSessionForHold(
{ ...this.lifetimeContext(), reconcileLeases: this.reconcileLeases },
sessionId,
(params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params)
),
evict: (sessionId) => this.close(sessionId)
reconcileLeases: this.reconcileLeases,
attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params),
close: (sessionId) => this.close(sessionId)
})
this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), {
reconcile: this.reconcileLeases,
@@ -149,6 +145,7 @@ export class StructuredAgentSessionHost {
flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId),
publishFence: (sessionId, session) =>
this.subscribers.snapshot(sessionId, session.journal, session.fence),
publishStatus: (sessionId) => this.statusFeed.publish(sessionId),
hasResumeCapableHolder: (sessionId) => this.holds.hasResumeCapableHolder(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
now: () => this.now(),
@@ -193,7 +190,8 @@ export class StructuredAgentSessionHost {
subscribers: this.subscribers,
tasks: this.tasks,
reconcileLeases: (sessionId) => this.reconcileLeases(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task)
serialize: (sessionId, task) => this.serialize(sessionId, task),
publishStatus: (sessionId) => this.statusFeed.publish(sessionId)
}
}
/** Releases a session's resources without ending the conversation: the record and journal stay
@@ -202,6 +200,7 @@ export class StructuredAgentSessionHost {
return this.serialize(sessionId, async () => {
await this.handoffs.closeRetainedTuiOwner(sessionId)
await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId)
this.statusFeed.revokeLive(sessionId)
// Whoever asked for the close, the surfaces that were holding this session are looking at a
// session that no longer exists. A failed eviction throws above and keeps them.
this.holds.forget(sessionId)
@@ -213,8 +212,11 @@ export class StructuredAgentSessionHost {
listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions)
getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } =>
this.deps.store.getVisibleSessionTabIndex()
/** Last projected status for every structured session this host still holds, for non-subscribing
* readers. The retained projections of forgotten sessions are deliberately not included. */
readonly liveSessionStatusSummaries = () => this.statusFeed.liveSessionSummaries()
getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex()
setSessionTabVisibility = (sessionId: string, visible: boolean): Promise<void> =>
this.deps.store.setSessionTabVisibility(sessionId, visible)
@@ -53,15 +53,24 @@ async function openJournal(sessionId = SESSION, now?: () => number) {
})
}
function indexed(session: { journal: Awaited<ReturnType<typeof openJournal>> }) {
function indexed(session: {
journal: Awaited<ReturnType<typeof openJournal>>
hasProviderChild?: boolean
}) {
return {
journal: session.journal,
...(session.hasProviderChild !== undefined
? { hasProviderChild: session.hasProviderChild }
: {}),
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
}
}
function feedFor(
sessions: Map<string, { journal: Awaited<ReturnType<typeof openJournal>> }>,
sessions: Map<
string,
{ journal: Awaited<ReturnType<typeof openJournal>>; hasProviderChild?: boolean }
>,
record: Partial<AgentSessionRecord> | null = null,
onStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged']
) {
@@ -88,6 +97,41 @@ function feedFor(
}
describe('StructuredAgentSessionStatusFeed', () => {
it('publishes provider ownership transitions without changing journal time', async () => {
const journal = await openJournal()
const sessions = new Map([[SESSION, { journal, hasProviderChild: true }]])
const { feed, events, dispose } = feedFor(sessions)
events.length = 0
await journal.appendItem(
USER_IDENTITY,
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
{ fence: 1 }
)
feed.publish(SESSION, journal)
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ hostExecutionOwned: true, updatedAt: expect.any(Number) })
})
const firstStatus = events.at(-1)
expect(firstStatus?.type).toBe('status')
if (firstStatus?.type !== 'status') {
throw new Error('status publication missing')
}
const journalTime = firstStatus.session.updatedAt
sessions.get(SESSION)!.hasProviderChild = false
feed.publish(SESSION, journal)
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'idle', updatedAt: journalTime })
})
const secondStatus = events.at(-1)
expect(secondStatus?.type).toBe('status')
if (secondStatus?.type === 'status') {
expect(secondStatus.session).not.toHaveProperty('hostExecutionOwned')
}
dispose()
})
it('opens with every readable session and reports no status before a persisted turn', async () => {
const journal = await openJournal()
const { events } = feedFor(new Map([[SESSION, { journal }]]))
@@ -523,3 +567,37 @@ describe('StructuredAgentSessionStatusFeed', () => {
})
})
})
/**
* `published` 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 this host has ever opened. Eviction's `forget-session` step deletes the
* session from the live map and touches nothing else, so a poller has to intersect with that map.
*/
describe('the polling reader answers from the live sessions, not the retained cache', () => {
it('drops an evicted session from the poll while a late subscriber still sees it', async () => {
const journal = await openJournal()
const sessions = new Map([[SESSION, { journal }]])
const { feed } = feedFor(sessions)
await journal.appendItem(
USER_IDENTITY,
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
{ fence: 1 }
)
feed.publish(SESSION, journal)
expect(feed.liveSessionSummaries().map((summary) => summary.sessionId)).toEqual([SESSION])
// Exactly what eviction's `forget-session` step does; nothing else touches the feed.
sessions.delete(SESSION)
expect(feed.liveSessionSummaries()).toEqual([])
const late: AgentSessionStatusEvent[] = []
feed.subscribe({ id: 'list-2', emit: (event) => late.push(event) })
expect(late).toEqual([
{
type: 'snapshot',
sessions: [expect.objectContaining({ sessionId: SESSION, status: 'idle' })]
}
])
})
})
@@ -30,6 +30,7 @@ export type StructuredAgentSessionStatusSubscriber = {
type StatusFeedSession = {
journal: AgentSessionJournal
params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] }
hasProviderChild?: boolean
}
export type StructuredAgentSessionStatusFeedDeps = {
@@ -46,6 +47,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
a.workspaceId === b.workspaceId &&
a.agent === b.agent &&
a.status === b.status &&
a.hostExecutionOwned === b.hostExecutionOwned &&
a.rewindBlockedReason === b.rewindBlockedReason &&
// Settled activity changes ranking; streaming active turns must stay quiet.
(a.status !== 'idle' || a.updatedAt === b.updatedAt) &&
@@ -76,6 +78,27 @@ export class StructuredAgentSessionStatusFeed {
return () => this.unsubscribe(subscriber.id)
}
/**
* Summaries for the sessions this host still holds, for readers that poll instead of subscribing.
*
* `published` never retracts, so it is a broadcast cache and not a roster: enumerating it lists
* every session ever opened here. A caller asking what is running gets the live intersection,
* while the retained view a subscriber opens on stays whole.
*
* Deliberately does NOT re-project: a subscriber's snapshot is the live read, and re-running the
* journal reduction per caller would make an enumerating command pay for every session it lists.
*/
liveSessionSummaries(): AgentSessionStatusSummary[] {
const summaries: AgentSessionStatusSummary[] = []
for (const [sessionId] of this.deps.sessions) {
const summary = this.published.get(sessionId)
if (summary) {
summaries.push(summary)
}
}
return summaries
}
unsubscribe(id: string): void {
const subscriber = this.subscribers.get(id)
if (!subscriber) {
@@ -89,6 +112,20 @@ export class StructuredAgentSessionStatusFeed {
}
}
/** Revoke live execution authority while retaining the last projection for reload history. */
revokeLive(sessionId: string): void {
const previous = this.published.get(sessionId)
if (!previous) {
return
}
const { hostExecutionOwned: _hostExecutionOwned, ...retained } = previous
this.published.set(sessionId, retained)
this.broadcast({
type: 'status',
session: retained
})
}
/** Re-projects one session after its journal changed; equal projections are not re-sent. */
publish(sessionId: string, journal?: AgentSessionJournal, options?: { replay?: boolean }): void {
const session = this.deps.sessions.get(sessionId)
@@ -126,6 +163,7 @@ export class StructuredAgentSessionStatusFeed {
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider,
...(session.hasProviderChild ? { hostExecutionOwned: true as const } : {}),
...projectStructuredAgentSessionStatusSummary(items),
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
? { rewindBlockedReason: 'outcome-unknown' as const }
@@ -32,6 +32,7 @@ export type StructuredAgentSessionUnexpectedExitContext = {
sessions: Map<string, StructuredAgentSessionHostSession>
flushLifecycle: (sessionId: string) => Promise<StructuredAgentSessionSinkBarrier>
publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void
publishStatus?: (sessionId: string) => void
hasResumeCapableHolder: (sessionId: string) => boolean
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
now: () => number
@@ -59,6 +60,7 @@ export async function settleUnexpectedStructuredAgentSessionExit(
if (!record || record.lease.handoffStage !== null) {
// The handoff coordinator owns an already-started transition.
session.hasProviderChild = false
context.publishStatus?.(unexpectedEvent.sessionId)
return null
}
@@ -117,6 +119,7 @@ export async function settleUnexpectedStructuredAgentSessionExit(
context.onBarrierError?.(unexpectedEvent.sessionId, error)
} finally {
session.hasProviderChild = false
context.publishStatus?.(unexpectedEvent.sessionId)
if (released) {
session.fence = released.lease.runtimeFence
context.publishFence(unexpectedEvent.sessionId, session)
@@ -21,6 +21,7 @@ export async function replaceClaudeRewindOwner(
return rewindRefusal('outcome-unknown')
}
session.hasProviderChild = false
context.publishStatus?.(sessionId)
const head = agentSessionProviderHandleChainHead(
context.deps.store.getRecord(sessionId)!.providerHandleChain
)?.handle
@@ -1,4 +1,5 @@
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
import { OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner } from './orca-runtime-structured-agent-session-recover-tui-owner'
import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude'
import type { RuntimeWorktreePsResult } from '../../shared/runtime-types'
@@ -9,6 +10,7 @@ import {
applyRuntimeWorktreePsTerminalActivity
} from './runtime-worktree-ps-activity'
import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { compareWorktreePs } from './runtime-worktree-status-projection'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { Repo } from '../../shared/repo-types'
@@ -102,11 +104,15 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
summaries,
pathIndex: runtimeWorktreeSummaryPathIndex,
missingWorktreeIds: missingRuntimeWorktreeIds,
mirroredWorktreeIdByTabId,
connectedPtyEvidence,
workingTerminalEvidenceByWorktreeId,
retainedSnapshots: this.agentRows.values(),
hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [],
rowSources: collectRuntimeWorktreeAgentSources({
mirroredWorktreeIdByTabId,
connectedPtyEvidence,
retainedSnapshots: this.agentRows.values(),
hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? [],
// Broadcast history outlives closed sessions; only the host roster is eligible.
structuredSummaries: getStructuredAgentSessionHost()?.liveSessionStatusSummaries() ?? []
}),
orchestrationByPaneKey: this.agentOrchestrationProjection.buildByPaneKey(),
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec'
import { TEST_WORKTREE_ID, store } from '../orca-runtime-test-fixtures.spec'
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../shared/agent-session-wire'
import type { AgentSessionJournal } from '../../native-chat/agent-session-journal/journal-store'
import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host'
import {
getStructuredAgentSessionHost,
setStructuredAgentSessionHost
} from '../../native-chat/agent-session-wire/structured-agent-session-registry'
import { StructuredAgentSessionStatusFeed } from '../../native-chat/agent-session-wire/structured-agent-session-status-feed'
/**
* The production wiring, not the projection. Both structured-row suites call
* `attachRuntimeWorktreeAgentRows` directly with summaries they built themselves, so nothing
* executed `getWorktreePs`'s own `getStructuredAgentSessionHost()?.liveSessionStatusSummaries()`
* — and that file carries `@ts-nocheck`, so renaming the accessor stayed green in typecheck AND
* in the suite while `orca worktree ps` and mobile's poll would throw for every user.
*/
const HELD_SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
const FORGOTTEN_SESSION = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e'
const OBSERVED_AT = 1_757_030_400_000
function runningTurn(prompt: string): AgentJournalRenderItem[] {
return [
{
itemId: 'user-1',
sequence: 1,
revision: 1,
observedAt: OBSERVED_AT,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: prompt }] }
},
{
itemId: 'turn-1',
sequence: 2,
revision: 1,
observedAt: OBSERVED_AT,
body: {
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
}
}
]
}
function journalWith(prompt: string): AgentSessionJournal {
return {
isReadOnly: false,
lastActivityAt: () => OBSERVED_AT,
snapshot: () => ({ items: runningTurn(prompt) })
} as unknown as AgentSessionJournal
}
/** A real feed the host still holds one session on, having forgotten the other. Its `published`
* cache never retracts, so the two views genuinely differ. */
function statusFeed(): StructuredAgentSessionStatusFeed {
const session = (prompt: string) => ({
journal: journalWith(prompt),
params: { location: { workspaceId: TEST_WORKTREE_ID }, provider: 'claude' as const }
})
const sessions = new Map([
[HELD_SESSION, session('ship the thing')],
[FORGOTTEN_SESSION, session('rm the branch')]
])
const feed = new StructuredAgentSessionStatusFeed({
sessions,
getRecord: () => null,
now: () => OBSERVED_AT
})
feed.publish(HELD_SESSION)
feed.publish(FORGOTTEN_SESSION)
// `forget-session`, the last eviction step, drops the session and leaves the feed alone.
sessions.delete(FORGOTTEN_SESSION)
return feed
}
/** Everything the feed retained, read through the snapshot a subscriber opens on. No production
* code reads this; it is here so swapping the call site back to a whole-cache read is a one-line
* edit that this suite must catch. */
function retainedSummaries(feed: StructuredAgentSessionStatusFeed): AgentSessionStatusSummary[] {
let retained: AgentSessionStatusSummary[] = []
feed.subscribe({
id: 'retained-probe',
emit: (event: AgentSessionStatusEvent) => {
if (event.type === 'snapshot') {
retained = event.sessions
}
}
})()
return retained
}
function installHost(feed: StructuredAgentSessionStatusFeed) {
const liveSessionStatusSummaries = vi.fn(() => feed.liveSessionSummaries())
const retainedSessionStatusSummaries = vi.fn(() => retainedSummaries(feed))
// Typed against the real host, so renaming the accessor on the class reddens `tc` here — the
// caller cannot, because `orca-runtime-get-worktree-ps.ts` is `@ts-nocheck`.
const host: Pick<StructuredAgentSessionHost, 'liveSessionStatusSummaries'> & {
retainedSessionStatusSummaries: () => AgentSessionStatusSummary[]
} = { liveSessionStatusSummaries, retainedSessionStatusSummaries }
setStructuredAgentSessionHost(host as unknown as StructuredAgentSessionHost)
return { liveSessionStatusSummaries, retainedSessionStatusSummaries }
}
describe('worktree ps reads the installed structured host', () => {
afterEach(() => {
setStructuredAgentSessionHost(null)
})
it('reports the held session and asks the host for its live summaries', async () => {
const feed = statusFeed()
const { liveSessionStatusSummaries } = installHost(feed)
const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs()
const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID)
expect(worktree).toBeDefined()
// Exactly one: the forgotten session is still in the feed's retained cache, so a call site
// that enumerated that cache instead would report two.
expect(worktree?.agents).toHaveLength(1)
expect(worktree?.agents[0]).toMatchObject({
state: 'working',
agentType: 'claude',
prompt: 'ship the thing'
})
// Pins the call site to the live-intersecting accessor, not merely to some accessor.
expect(liveSessionStatusSummaries).toHaveBeenCalledTimes(1)
})
it('succeeds with no structured rows when no host is installed', async () => {
// Guard the guard: these specs share one module registry, so state the premise.
expect(getStructuredAgentSessionHost()).toBeNull()
const { worktrees } = await new OrcaRuntimeService(store).getWorktreePs()
const worktree = worktrees.find((entry) => entry.worktreeId === TEST_WORKTREE_ID)
expect(worktree).toBeDefined()
expect(worktree?.agents).toEqual([])
})
})
+1
View File
@@ -85,6 +85,7 @@ await import('./orca-runtime-tests/mobile-summaries.spec')
await import('./orca-runtime-tests/mobile-summaries-part-02.spec')
await import('./orca-runtime-tests/mobile-summaries-part-03.spec')
await import('./orca-runtime-tests/mobile-summaries-part-04.spec')
await import('./orca-runtime-tests/worktree-ps-structured-host.spec')
await import('./orca-runtime-tests/terminal-sleep-and-teardown.spec')
await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec')
await import('./orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec')
@@ -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')
})
})
+10 -132
View File
@@ -1,34 +1,10 @@
import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus,
pickParsedAgentStatusPayload,
type AgentStatusIpcPayload,
type ParsedAgentStatusPayload
} from '../../shared/agent-status-types'
import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence'
import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-types'
import type { RuntimeWorktreeAgentRow, RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection'
import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity'
export type RuntimeAgentRowSnapshot = {
paneKey: string
ptyId: string
worktreeId?: string
tabId?: string
connectionId: string | null
payload: ParsedAgentStatusPayload
stateStartedAt: number
updatedAt: number
}
type ConnectedPtyEvidence = {
tabIds: ReadonlySet<string>
paneKeys: ReadonlySet<string>
ptyIds: ReadonlySet<string>
}
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
export type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources'
type OrchestrationDisplay = {
taskTitle?: string | null
@@ -36,38 +12,15 @@ type OrchestrationDisplay = {
parentPaneKey?: string | null
}
type RuntimeWorktreeAgentSource = {
paneKey: string
ptyId?: string
tabId?: string
worktreeId?: string
connectionId: string | null
payload: ParsedAgentStatusPayload
state: ParsedAgentStatusPayload['state']
workingMode?: ParsedAgentStatusPayload['workingMode']
agentType: string | null
prompt: string
lastAssistantMessage: string | null
toolName: string | null
toolInput: string | null
interrupted: boolean
stateStartedAt: number
updatedAt: number
restoredUnconfirmed?: boolean
}
export function attachRuntimeWorktreeAgentRows(args: {
summaries: Map<string, RuntimeWorktreePsSummary>
pathIndex: RuntimeWorktreeSummaryPathIndex
missingWorktreeIds: Set<string>
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
connectedPtyEvidence: ConnectedPtyEvidence
rowSources: ReadonlyMap<string, RuntimeWorktreeAgentSource>
workingTerminalEvidenceByWorktreeId: ReadonlyMap<
string,
readonly RuntimeWorkingTerminalEvidence[]
>
retainedSnapshots: Iterable<RuntimeAgentRowSnapshot>
hookSnapshots: readonly AgentStatusIpcPayload[]
orchestrationByPaneKey: Record<string, OrchestrationDisplay> | null | undefined
getSummary: (
summaries: Map<string, RuntimeWorktreePsSummary>,
@@ -76,88 +29,11 @@ export function attachRuntimeWorktreeAgentRows(args: {
worktreeId: string
) => RuntimeWorktreePsSummary | null
}): void {
const rowSources = new Map<string, RuntimeWorktreeAgentSource>()
const { rowSources } = args
const now = Date.now()
for (const snapshot of args.retainedSnapshots) {
const { payload } = snapshot
rowSources.set(snapshot.paneKey, {
paneKey: snapshot.paneKey,
ptyId: snapshot.ptyId,
tabId: snapshot.tabId,
worktreeId: snapshot.worktreeId,
connectionId: snapshot.connectionId,
payload,
state: payload.state,
...(payload.workingMode ? { workingMode: payload.workingMode } : {}),
agentType: payload.agentType ?? null,
prompt: payload.prompt,
lastAssistantMessage: payload.lastAssistantMessage ?? null,
toolName: payload.toolName ?? null,
toolInput: payload.toolInput ?? null,
interrupted: payload.interrupted ?? false,
stateStartedAt: snapshot.stateStartedAt,
updatedAt: snapshot.updatedAt
})
}
for (const entry of args.hookSnapshots) {
if (entry.restoredUnconfirmed === true) {
continue
}
const existing = rowSources.get(entry.paneKey)
const hookPayload = pickParsedAgentStatusPayload(entry)
if (existing && existing.updatedAt > entry.receivedAt) {
if (
entry.workingMode === 'monitoring' &&
now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS &&
terminalStatusPayloadMatchesHook(hookPayload, existing.payload)
) {
existing.workingMode = 'monitoring'
if (existing.payload.workingMode === undefined) {
existing.payload = { ...existing.payload, workingMode: 'monitoring' }
}
}
continue
}
rowSources.set(entry.paneKey, {
paneKey: entry.paneKey,
ptyId: existing?.ptyId,
tabId: entry.tabId,
worktreeId: entry.worktreeId,
connectionId: entry.connectionId,
payload: hookPayload,
state: entry.state,
...(entry.workingMode ? { workingMode: entry.workingMode } : {}),
agentType: entry.agentType ?? null,
prompt: entry.prompt,
lastAssistantMessage: entry.lastAssistantMessage ?? null,
toolName: entry.toolName ?? null,
toolInput: entry.toolInput ?? null,
interrupted: entry.interrupted ?? false,
stateStartedAt: entry.stateStartedAt,
updatedAt: entry.receivedAt
})
}
if (rowSources.size === 0) {
return
}
const rowsByWorktree = new Map<string, RuntimeWorktreeAgentRow[]>()
for (const source of rowSources.values()) {
const tabId =
source.tabId ??
parsePaneKey(source.paneKey)?.tabId ??
parseLegacyNumericPaneKey(source.paneKey)?.tabId
const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined
if (
tabId !== undefined &&
mirroredWorktreeId === undefined &&
(source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) &&
!args.connectedPtyEvidence.tabIds.has(tabId) &&
!args.connectedPtyEvidence.paneKeys.has(source.paneKey) &&
(source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId))
) {
continue
}
const worktreeId = mirroredWorktreeId ?? source.worktreeId
const { worktreeId } = source
if (!worktreeId) {
continue
}
@@ -204,13 +80,15 @@ export function attachRuntimeWorktreeAgentRows(args: {
let hasForegroundWorkingAgent = false
const monitoringSources: RuntimeWorktreeAgentSource[] = []
for (const row of rows) {
if (!isFreshNonDoneAgentStatus(row, now)) {
const source = rowSources.get(row.paneKey)
const hostHeldStructuredSession =
source?.authority === 'structured-host' && row.state !== 'done'
if (!hostHeldStructuredSession && !isFreshNonDoneAgentStatus(row, now)) {
continue
}
summary.hasHostSidebarActivity = true
if (row.state === 'working') {
if (row.workingMode === 'monitoring') {
const source = rowSources.get(row.paneKey)
if (source) {
monitoringSources.push(source)
}
@@ -0,0 +1,21 @@
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
export type RuntimeWorktreeAgentSource = {
paneKey: string
ptyId?: string
tabId?: string
worktreeId?: string
connectionId: string | null
state: ParsedAgentStatusPayload['state']
workingMode?: ParsedAgentStatusPayload['workingMode']
agentType: string | null
prompt: string
lastAssistantMessage: string | null
toolName: string | null
toolInput: string | null
interrupted: boolean
stateStartedAt: number
updatedAt: number
/** Structured host projections remain authoritative after PTY freshness expiry. */
authority?: 'structured-host'
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
import type { RuntimeAgentRowSnapshot } from './runtime-worktree-pty-agent-sources'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
const paneKey = 'worktree:tab:0'
const now = Date.now()
const retained: RuntimeAgentRowSnapshot = {
paneKey,
ptyId: 'pty',
tabId: 'tab',
worktreeId: 'worktree',
connectionId: null,
payload: { state: 'working', prompt: 'implement', agentType: 'codex' },
stateStartedAt: now,
updatedAt: now
}
const base = {
retainedSnapshots: [retained],
hookSnapshots: [] as AgentStatusIpcPayload[],
structuredSummaries: [],
mirroredWorktreeIdByTabId: new Map<string, string>(),
connectedPtyEvidence: {
tabIds: new Set<string>(),
paneKeys: new Set<string>(),
ptyIds: new Set<string>()
}
}
describe('worktree agent source admission', () => {
it('rejects a disconnected local terminal before row assembly', () => {
expect(collectRuntimeWorktreeAgentSources(base).size).toBe(0)
const connected = {
...base,
connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) }
}
expect(collectRuntimeWorktreeAgentSources(connected).get(paneKey)?.state).toBe('working')
})
it('keeps remote evidence and resolves mirrored workspace ownership', () => {
const remote = { ...retained, connectionId: 'ssh-connection' }
expect(collectRuntimeWorktreeAgentSources({ ...base, retainedSnapshots: [remote] }).size).toBe(
1
)
const sources = collectRuntimeWorktreeAgentSources({
...base,
mirroredWorktreeIdByTabId: new Map([['tab', 'remote-worktree']])
})
expect(sources.get(paneKey)?.worktreeId).toBe('remote-worktree')
})
it('preserves fresh monitoring enrichment on a newer retained report', () => {
const hook: AgentStatusIpcPayload = {
...retained.payload,
paneKey,
tabId: 'tab',
worktreeId: 'worktree',
connectionId: null,
stateStartedAt: now - 1,
receivedAt: now - 1,
workingMode: 'monitoring'
}
const sources = collectRuntimeWorktreeAgentSources({
...base,
hookSnapshots: [hook],
connectedPtyEvidence: { ...base.connectedPtyEvidence, ptyIds: new Set(['pty']) }
})
expect(sources.get(paneKey)).toMatchObject({ updatedAt: now, workingMode: 'monitoring' })
})
})
@@ -0,0 +1,22 @@
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import { collectRuntimeWorktreePtyAgentSources } from './runtime-worktree-pty-agent-sources'
import { structuredRuntimeWorktreeAgentSources } from './runtime-worktree-structured-agent-rows'
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
/** One admitted roster for row and worktree-status projection. */
export function collectRuntimeWorktreeAgentSources(
args: Parameters<typeof collectRuntimeWorktreePtyAgentSources>[0] & {
structuredSummaries: readonly AgentSessionStatusSummary[]
}
): ReadonlyMap<string, RuntimeWorktreeAgentSource> {
const sources = new Map<string, RuntimeWorktreeAgentSource>()
for (const source of collectRuntimeWorktreePtyAgentSources(args)) {
sources.set(source.paneKey, source)
}
for (const source of structuredRuntimeWorktreeAgentSources(args.structuredSummaries)) {
if (!sources.has(source.paneKey)) {
sources.set(source.paneKey, source)
}
}
return sources
}
@@ -0,0 +1,121 @@
import {
AGENT_STATUS_STALE_AFTER_MS,
pickParsedAgentStatusPayload,
type AgentStatusIpcPayload,
type ParsedAgentStatusPayload
} from '../../shared/agent-status-types'
import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract'
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
export type RuntimeAgentRowSnapshot = {
paneKey: string
ptyId: string
worktreeId?: string
tabId?: string
connectionId: string | null
payload: ParsedAgentStatusPayload
stateStartedAt: number
updatedAt: number
}
export type ConnectedPtyEvidence = {
tabIds: ReadonlySet<string>
paneKeys: ReadonlySet<string>
ptyIds: ReadonlySet<string>
}
/** Reconcile terminal status, then admit rows using their execution-host evidence. */
export function collectRuntimeWorktreePtyAgentSources(args: {
retainedSnapshots: Iterable<RuntimeAgentRowSnapshot>
hookSnapshots: readonly AgentStatusIpcPayload[]
mirroredWorktreeIdByTabId: ReadonlyMap<string, string>
connectedPtyEvidence: ConnectedPtyEvidence
}): RuntimeWorktreeAgentSource[] {
const rowSources = new Map<
string,
RuntimeWorktreeAgentSource & { payload: ParsedAgentStatusPayload }
>()
const now = Date.now()
for (const snapshot of args.retainedSnapshots) {
const { payload } = snapshot
rowSources.set(snapshot.paneKey, {
paneKey: snapshot.paneKey,
ptyId: snapshot.ptyId,
tabId: snapshot.tabId,
worktreeId: snapshot.worktreeId,
connectionId: snapshot.connectionId,
payload,
state: payload.state,
...(payload.workingMode ? { workingMode: payload.workingMode } : {}),
agentType: payload.agentType ?? null,
prompt: payload.prompt,
lastAssistantMessage: payload.lastAssistantMessage ?? null,
toolName: payload.toolName ?? null,
toolInput: payload.toolInput ?? null,
interrupted: payload.interrupted ?? false,
stateStartedAt: snapshot.stateStartedAt,
updatedAt: snapshot.updatedAt
})
}
for (const entry of args.hookSnapshots) {
if (entry.restoredUnconfirmed === true) {
continue
}
const existing = rowSources.get(entry.paneKey)
const hookPayload = pickParsedAgentStatusPayload(entry)
if (existing && existing.updatedAt > entry.receivedAt) {
if (
entry.workingMode === 'monitoring' &&
now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS &&
terminalStatusPayloadMatchesHook(hookPayload, existing.payload)
) {
existing.workingMode = 'monitoring'
if (existing.payload.workingMode === undefined) {
existing.payload = { ...existing.payload, workingMode: 'monitoring' }
}
}
continue
}
rowSources.set(entry.paneKey, {
paneKey: entry.paneKey,
ptyId: existing?.ptyId,
tabId: entry.tabId,
worktreeId: entry.worktreeId,
connectionId: entry.connectionId,
payload: hookPayload,
state: entry.state,
...(entry.workingMode ? { workingMode: entry.workingMode } : {}),
agentType: entry.agentType ?? null,
prompt: entry.prompt,
lastAssistantMessage: entry.lastAssistantMessage ?? null,
toolName: entry.toolName ?? null,
toolInput: entry.toolInput ?? null,
interrupted: entry.interrupted ?? false,
stateStartedAt: entry.stateStartedAt,
updatedAt: entry.receivedAt
})
}
const sources: RuntimeWorktreeAgentSource[] = []
for (const source of rowSources.values()) {
const tabId =
source.tabId ??
parsePaneKey(source.paneKey)?.tabId ??
parseLegacyNumericPaneKey(source.paneKey)?.tabId
const mirroredWorktreeId = tabId ? args.mirroredWorktreeIdByTabId.get(tabId) : undefined
if (
tabId !== undefined &&
mirroredWorktreeId === undefined &&
(source.connectionId === null || isWslHookRelayConnectionId(source.connectionId)) &&
!args.connectedPtyEvidence.tabIds.has(tabId) &&
!args.connectedPtyEvidence.paneKeys.has(source.paneKey) &&
(source.ptyId === undefined || !args.connectedPtyEvidence.ptyIds.has(source.ptyId))
) {
continue
}
const worktreeId = mirroredWorktreeId ?? source.worktreeId
sources.push({ ...source, tabId, worktreeId })
}
return sources
}
@@ -0,0 +1,157 @@
import { collectRuntimeWorktreeAgentSources } from './runtime-worktree-agent-sources'
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,
summaries = feed.liveSessionSummaries()
): 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(),
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, _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')
})
it('keeps an aged host-held working state authoritative', async () => {
const { feed } = await awaitingApproval()
const aged = feed.liveSessionSummaries().map((summary) => ({
...summary,
hostExecutionOwned: true as const,
updatedAt: Date.now() - 30 * 60 * 1000 - 1,
status: 'working' as const
}))
const row = worktreeFor(feed, aged)
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('working')
expect(row.status).toBe('working')
expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt)
})
it('keeps an aged host-held approval state authoritative', async () => {
const { feed } = await awaitingApproval()
const aged = feed.liveSessionSummaries().map((summary) => ({
...summary,
hostExecutionOwned: true as const,
updatedAt: Date.now() - 30 * 60 * 1000 - 1
}))
const row = worktreeFor(feed, aged)
expect(row.agents).toHaveLength(1)
expect(row.agents[0]?.state).toBe('blocked')
expect(row.status).toBe('permission')
expect(row.agents[0]?.updatedAt).toBe(aged[0]?.updatedAt)
})
})
@@ -0,0 +1,48 @@
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionStatusState,
structuredAgentSessionTabId
} from '../../shared/structured-agent-session-projection'
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
/**
* Row sources for the structured (non-PTY) sessions a host still holds.
*
* A structured session reaches none of the hook or retained snapshots every other row comes from,
* so `worktree ps` projects it from the host's status feed instead. The feed's retained
* projections are not a roster — the caller passes only sessions the host still holds.
*/
export function structuredRuntimeWorktreeAgentSources(
summaries: readonly AgentSessionStatusSummary[]
): RuntimeWorktreeAgentSource[] {
const sources: RuntimeWorktreeAgentSource[] = []
for (const summary of summaries) {
// No turn has been persisted yet, so there is nothing to report - the same read the chat shows.
if (!summary.status) {
continue
}
const tabId = structuredAgentSessionTabId(summary.sessionId)
// The DERIVED pane key the renderer already publishes, never the orchestration bearer handle
// or the minted worker pane key: both of those are credentials.
sources.push({
paneKey: structuredAgentSessionPaneKey(tabId, summary.sessionId),
tabId,
worktreeId: summary.workspaceId,
connectionId: null,
// The shared mapping the sidebar applies, so the CLI and the GUI cannot disagree about one
// session. No hook payload: nothing reads one off a structured row.
state: structuredAgentSessionStatusState(summary.status),
agentType: summary.agent,
prompt: summary.latestPrompt,
lastAssistantMessage: summary.lastAssistantMessage ?? null,
toolName: summary.toolName ?? null,
toolInput: summary.toolInput ?? null,
interrupted: false,
stateStartedAt: summary.updatedAt,
updatedAt: summary.updatedAt,
...(summary.hostExecutionOwned ? { authority: 'structured-host' as const } : {})
})
}
return sources
}
@@ -7,6 +7,7 @@ import type {
AgentSessionStatusSummary
} from '../../../../shared/agent-session-wire'
import { resolveAttention } from '../sidebar/smart-attention'
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { Tab } from '../../../../shared/tab-types'
import type { AppState } from '@/store/types'
@@ -88,6 +89,7 @@ function summary(overrides: Partial<AgentSessionStatusSummary> = {}): AgentSessi
workspaceId: 'wt-1',
agent: 'codex',
status: 'working',
hostExecutionOwned: true,
latestPrompt: 'hello',
providerSession,
updatedAt: 1,
@@ -180,6 +182,26 @@ describe('StructuredAgentSessionStatusBridge', () => {
// Hiddenness is the host's side of this: see structured-agent-session-subscribers.test.ts,
// which drives an unsubscribed journal through the feed. Here the transport is a mock, so
// only the summary-to-store mapping is under test.
it('keeps host-held working evidence active past the normal freshness window', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
const updatedAt = Date.now() - 30 * 60 * 1000 - 1
act(() => feed().emit({ type: 'status', session: summary({ updatedAt }) }))
const entry = statuses()[0]
expect(entry).toEqual(expect.objectContaining({ state: 'working', structuredHostOwned: true }))
expect(isExplicitAgentStatusFresh(entry, Date.now(), 30 * 60 * 1000)).toBe(true)
})
it('clears host-held evidence when the status stream disconnects', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
act(() => feed().emit({ type: 'status', session: summary() }))
expect(statuses()).toHaveLength(1)
act(() => feed().emit({ type: 'end' }))
expect(statuses()).toHaveLength(1)
expect(statuses()[0]).not.toHaveProperty('structuredHostOwned')
})
it('maps each host status onto the sidebar agent state', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
@@ -2,7 +2,10 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire'
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionStatusState
} from '../../../../shared/structured-agent-session-projection'
import type { Tab } from '../../../../shared/tab-types'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
@@ -67,12 +70,8 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
return
}
const desired = {
state:
summary.status === 'working'
? 'working'
: summary.status === 'attention'
? 'blocked'
: 'done',
// Shared with `worktree ps`, so the CLI and this row cannot disagree about one session.
state: structuredAgentSessionStatusState(summary.status),
prompt: summary.latestPrompt,
agentType: tab.agentSessionAgent,
// The host projects these from the journal so the row reads like a hook-reported one:
@@ -99,6 +98,7 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
current.tabId === tab.id &&
current.worktreeId === tab.worktreeId &&
current.terminalResumeEligible === false &&
current.structuredHostOwned === summary.hostExecutionOwned &&
agentProviderSessionsEqual(
tab.agentSessionAgent,
current.providerSession,
@@ -119,12 +119,13 @@ function projectStatus(tab: StructuredTab, summary: AgentSessionStatusSummary |
desired.state !== 'done' && current?.state === desired.state
? current.stateStartedAt
: summary.updatedAt,
evidenceObservedAt: Date.now()
evidenceObservedAt: summary.updatedAt
},
{ tabId: tab.id, worktreeId: tab.worktreeId },
{
...(summary.providerSession ? { providerSession: summary.providerSession } : {}),
terminalResumeEligible: false
terminalResumeEligible: false,
...(summary.hostExecutionOwned ? { structuredHostOwned: true as const } : {})
}
)
}
+8 -2
View File
@@ -18,14 +18,20 @@ import {
export function isExplicitAgentStatusFresh(
entry: Pick<
AgentStatusEntry,
'updatedAt' | 'evidenceObservedAt' | 'mirroredEvidenceReceivedAt' | 'restoredUnconfirmed'
| 'updatedAt'
| 'evidenceObservedAt'
| 'mirroredEvidenceReceivedAt'
| 'restoredUnconfirmed'
| 'structuredHostOwned'
>,
now: number,
staleAfterMs: number
): boolean {
// Why: an unconfirmed hydrated row may describe a turn that ended while no receiver was up; never fresh.
return (
entry.restoredUnconfirmed !== true && now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs
entry.restoredUnconfirmed !== true &&
(entry.structuredHostOwned === true ||
now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs)
)
}
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../shared/agent-session-wire'
const mocks = vi.hoisted(() => ({ subscribe: vi.fn() }))
vi.mock('./structured-agent-session-client', () => ({
subscribeStructuredAgentSessionStatus: mocks.subscribe
}))
vi.mock('./runtime-rpc-client', () => ({ runtimeEnvironmentSupportsCapability: vi.fn() }))
import {
getStructuredAgentSessionStatusFeed,
resetStructuredAgentSessionStatusFeedsForTests
} from './structured-agent-session-status-feed'
type Subscription = {
emit: (event: AgentSessionStatusEvent) => void
unsubscribe: ReturnType<typeof vi.fn>
}
const subscriptions: Subscription[] = []
const owned: AgentSessionStatusSummary = {
sessionId: 'running',
workspaceId: 'workspace',
agent: 'codex',
status: 'working',
latestPrompt: 'work',
updatedAt: 1,
hostExecutionOwned: true
}
const done: AgentSessionStatusSummary = {
...owned,
sessionId: 'completed',
status: 'idle',
hostExecutionOwned: undefined
}
function subscription(index = 0): Subscription {
const value = subscriptions[index]
if (!value) {
throw new Error('missing subscription')
}
return value
}
describe('structured status feed execution authority lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
resetStructuredAgentSessionStatusFeedsForTests()
subscriptions.length = 0
mocks.subscribe.mockReset()
mocks.subscribe.mockImplementation((_target, emit: Subscription['emit']) => {
const unsubscribe = vi.fn(() => emit({ type: 'end' }))
subscriptions.push({ emit, unsubscribe })
return Promise.resolve({ unsubscribe })
})
})
afterEach(() => {
resetStructuredAgentSessionStatusFeedsForTests()
vi.useRealTimers()
})
it('revokes on end before reentrant unsubscribe and ignores late frames', async () => {
const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' })
feed.activate()
await vi.advanceTimersByTimeAsync(0)
subscription().emit({ type: 'snapshot', sessions: [owned, done] })
subscription().emit({ type: 'end' })
expect(subscription().unsubscribe).toHaveBeenCalledOnce()
expect(feed.getSnapshot().get('running')).toEqual({ ...owned, hostExecutionOwned: undefined })
expect(feed.getSnapshot().get('completed')).toBe(done)
subscription().emit({ type: 'status', session: owned })
expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined()
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(250)
subscription(1).emit({ type: 'snapshot', sessions: [done] })
expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined()
subscription(1).emit({ type: 'status', session: owned })
expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true)
})
it('retains history without ownership while stopped and until remount receives fresh evidence', async () => {
const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' })
const deactivate = feed.activate()
await vi.advanceTimersByTimeAsync(0)
subscription().emit({ type: 'snapshot', sessions: [owned, done] })
deactivate()
expect(vi.getTimerCount()).toBe(0)
expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined()
expect(feed.getSnapshot().get('running')?.updatedAt).toBe(owned.updatedAt)
expect(feed.getSnapshot().get('completed')).toBe(done)
feed.activate()
expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined()
await vi.advanceTimersByTimeAsync(0)
subscription().emit({ type: 'status', session: owned })
expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBeUndefined()
subscription(1).emit({ type: 'snapshot', sessions: [owned] })
expect(feed.getSnapshot().get('running')?.hostExecutionOwned).toBe(true)
})
it('does not notify or reallocate already unowned historical rows on teardown', async () => {
const feed = getStructuredAgentSessionStatusFeed({ kind: 'local' })
const deactivate = feed.activate()
await vi.advanceTimersByTimeAsync(0)
subscription().emit({ type: 'snapshot', sessions: [done] })
const previous = feed.getSnapshot()
const listener = vi.fn()
feed.subscribe(listener)
deactivate()
expect(feed.getSnapshot()).toBe(previous)
expect(listener).not.toHaveBeenCalled()
})
})
@@ -82,6 +82,32 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
handle?.unsubscribe()
handle = null
}
const revokeSnapshotOwnership = (): void => {
let next: Map<string, AgentSessionStatusSummary> | null = null
for (const [sessionId, summary] of snapshot) {
if (!summary.hostExecutionOwned) {
continue
}
if (!next) {
next = new Map(snapshot)
}
const { hostExecutionOwned: _owned, ...retained } = summary
next.set(sessionId, retained)
}
if (next) {
snapshot = next
emit()
}
}
const fenceCandidateAndReconnect = (candidate: number): void => {
if (candidate !== generation) {
return
}
generation += 1
revokeSnapshotOwnership()
dropHandle()
scheduleReconnect(generation)
}
let open = (): void => {}
const scheduleReconnect = (candidate: number): void => {
if (!active(candidate) || reconnectTimer) {
@@ -104,22 +130,19 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
return
}
if (event.type === 'end') {
dropHandle()
scheduleReconnect(candidate)
fenceCandidateAndReconnect(candidate)
return
}
applyEvent(event)
},
() => {
if (active(candidate)) {
dropHandle()
scheduleReconnect(candidate)
fenceCandidateAndReconnect(candidate)
}
},
() => {
if (active(candidate)) {
dropHandle()
scheduleReconnect(candidate)
fenceCandidateAndReconnect(candidate)
}
}
)
@@ -130,7 +153,13 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
opened.unsubscribe()
}
})
.catch(() => scheduleReconnect(candidate))
.catch(() => {
if (active(candidate)) {
fenceCandidateAndReconnect(candidate)
} else {
scheduleReconnect(candidate)
}
})
}
open = (): void => {
const candidate = ++generation
@@ -163,6 +192,7 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed {
generation += 1
clearReconnect()
dropHandle()
revokeSnapshotOwnership()
reconnectAttempt = 0
}
@@ -108,6 +108,8 @@ export type AgentStatusRouting = {
}
export type AgentStatusMetadata = {
/** Structured status rows remain fresh while the host owns the session; cleared on feed loss. */
structuredHostOwned?: true
providerSession?: AgentProviderSessionMetadata
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
@@ -227,6 +227,7 @@ export function buildAgentStatusLiveEntry(
...(timing?.evidenceObservedAt !== undefined
? { evidenceObservedAt: timing.evidenceObservedAt }
: {}),
...(metadata?.structuredHostOwned === true ? { structuredHostOwned: true as const } : {}),
stateStartedAt,
agentType: identity.agentType,
model:
+2
View File
@@ -196,6 +196,8 @@ export type AgentSessionStatusSummary = {
agent: AgentSessionRecord['provider']
/** Null until the journal holds a persisted user or assistant message. */
status: StructuredAgentSessionProjectedStatus | null
/** Present only while this host has the provider child executing the session. */
hostExecutionOwned?: true
latestPrompt: string
/** Provider model in force for the next turn; absent until the host has read the options. */
model?: string
+3 -1
View File
@@ -37,6 +37,7 @@ export function isFreshNonDoneAgentStatus(
| 'evidenceObservedAt'
| 'mirroredEvidenceReceivedAt'
| 'restoredUnconfirmed'
| 'structuredHostOwned'
>
| undefined,
now = Date.now(),
@@ -47,6 +48,7 @@ export function isFreshNonDoneAgentStatus(
entry &&
entry.state !== 'done' &&
entry.restoredUnconfirmed !== true &&
now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs
(entry.structuredHostOwned === true ||
now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs)
)
}
+2
View File
@@ -114,6 +114,8 @@ export type AgentStatusEntry = {
* which is the delivery/ordering clock a relay reconnect must restamp to stay monotonic.
* Absent for locally derived rows and old hosts; freshness falls back to `updatedAt`. */
evidenceObservedAt?: number
/** True only while a host-held structured session is represented by its live status feed. */
structuredHostOwned?: true
/** Timestamp (ms) when the current `state` was first reported.
* Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */
stateStartedAt: number
@@ -299,6 +299,14 @@ export function projectStructuredAgentSessionStatusSummary(
}
}
/** The agent-status state one projected session status stands for. Shared across the process
* boundary so `worktree ps` and the sidebar cannot disagree about the same session. */
export function structuredAgentSessionStatusState(
status: StructuredAgentSessionProjectedStatus
): 'working' | 'blocked' | 'done' {
return status === 'working' ? 'working' : status === 'attention' ? 'blocked' : 'done'
}
export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string {
const bytes = sha256(new TextEncoder().encode(sessionId))
const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('')