mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +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,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)
|
||||
|
||||
+80
-2
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 } : {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
|
||||
Reference in New Issue
Block a user