diff --git a/docs/review-evidence/pr-19217/README.md b/docs/review-evidence/pr-19217/README.md new file mode 100644 index 00000000000..ca664ae0c9a --- /dev/null +++ b/docs/review-evidence/pr-19217/README.md @@ -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. diff --git a/docs/review-evidence/pr-19217/closed.png b/docs/review-evidence/pr-19217/closed.png new file mode 100644 index 00000000000..7f1d8160844 Binary files /dev/null and b/docs/review-evidence/pr-19217/closed.png differ diff --git a/docs/review-evidence/pr-19217/working.png b/docs/review-evidence/pr-19217/working.png new file mode 100644 index 00000000000..8ee57052134 Binary files /dev/null and b/docs/review-evidence/pr-19217/working.png differ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts index 412aa88025d..986fcefadbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -38,4 +38,5 @@ export type StructuredAgentSessionAttachContext = { reconcileLeases: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise now: () => number + publishStatus?: (sessionId: string) => void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 5f9894c3280..728942b6347 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -22,6 +22,7 @@ export class StructuredAgentSessionEventRecovery { sessions: Map flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void + publishStatus?: (sessionId: string) => void hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise now: () => number diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index 7c8c65a5292..3495409f127 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -25,6 +25,7 @@ type HostHandoffAccess = { flush: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise 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({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index ba62db1704e..d1664bc985b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -93,13 +93,19 @@ export async function resumeStructuredAgentSessionForHold( export function createStructuredAgentSessionHolds( context: StructuredAgentSessionLifetimeContext, input: { - resume: (sessionId: string) => Promise - evict: (sessionId: string) => Promise + reconcileLeases: (sessionId: string) => Promise + attach: Parameters[0]['attach'] + close: (sessionId: string) => Promise } ): 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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 257c7a4e4f7..2df2fdf5412 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -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 => this.deps.store.setSessionTabVisibility(sessionId, visible) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index c1b52f879d4..1e3b9bb25a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -53,15 +53,24 @@ async function openJournal(sessionId = SESSION, now?: () => number) { }) } -function indexed(session: { journal: Awaited> }) { +function indexed(session: { + journal: Awaited> + 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> }>, + sessions: Map< + string, + { journal: Awaited>; hasProviderChild?: boolean } + >, record: Partial | 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' })] + } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index cfd85ff4648..61d9649587e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -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 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts index 87fe0cbbeec..ddc4af9f2d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.ts @@ -32,6 +32,7 @@ export type StructuredAgentSessionUnexpectedExitContext = { sessions: Map flushLifecycle: (sessionId: string) => Promise publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void + publishStatus?: (sessionId: string) => void hasResumeCapableHolder: (sessionId: string) => boolean serialize: (sessionId: string, task: () => Promise) => Promise 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) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts index f1108df181c..13d2b51f284 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-claude-owner.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 240156d93c6..3dbd8809018 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -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) diff --git a/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts new file mode 100644 index 00000000000..58e7de1b674 --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/worktree-ps-structured-host.spec.ts @@ -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 & { + 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([]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 829c2ca321e..74098040c27 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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') diff --git a/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts new file mode 100644 index 00000000000..84966846be7 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-rows-structured.test.ts @@ -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 { + 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([[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') + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 1853f704471..80802cf11a3 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -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 - paneKeys: ReadonlySet - ptyIds: ReadonlySet -} +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 pathIndex: RuntimeWorktreeSummaryPathIndex missingWorktreeIds: Set - mirroredWorktreeIdByTabId: ReadonlyMap - connectedPtyEvidence: ConnectedPtyEvidence + rowSources: ReadonlyMap workingTerminalEvidenceByWorktreeId: ReadonlyMap< string, readonly RuntimeWorkingTerminalEvidence[] > - retainedSnapshots: Iterable - hookSnapshots: readonly AgentStatusIpcPayload[] orchestrationByPaneKey: Record | null | undefined getSummary: ( summaries: Map, @@ -76,88 +29,11 @@ export function attachRuntimeWorktreeAgentRows(args: { worktreeId: string ) => RuntimeWorktreePsSummary | null }): void { - const rowSources = new Map() + 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() 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) } diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts new file mode 100644 index 00000000000..2e8bc833b23 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -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' +} diff --git a/src/main/runtime/runtime-worktree-agent-sources.test.ts b/src/main/runtime/runtime-worktree-agent-sources.test.ts new file mode 100644 index 00000000000..c0395cd6831 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-sources.test.ts @@ -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(), + connectedPtyEvidence: { + tabIds: new Set(), + paneKeys: new Set(), + ptyIds: new Set() + } +} + +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' }) + }) +}) diff --git a/src/main/runtime/runtime-worktree-agent-sources.ts b/src/main/runtime/runtime-worktree-agent-sources.ts new file mode 100644 index 00000000000..44ce7848960 --- /dev/null +++ b/src/main/runtime/runtime-worktree-agent-sources.ts @@ -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[0] & { + structuredSummaries: readonly AgentSessionStatusSummary[] + } +): ReadonlyMap { + const sources = new Map() + 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 +} diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts new file mode 100644 index 00000000000..04e8dffc04e --- /dev/null +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -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 + paneKeys: ReadonlySet + ptyIds: ReadonlySet +} + +/** Reconcile terminal status, then admit rows using their execution-host evidence. */ +export function collectRuntimeWorktreePtyAgentSources(args: { + retainedSnapshots: Iterable + hookSnapshots: readonly AgentStatusIpcPayload[] + mirroredWorktreeIdByTabId: ReadonlyMap + 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 +} diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts new file mode 100644 index 00000000000..063ee0b36cf --- /dev/null +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows.ts b/src/main/runtime/runtime-worktree-structured-agent-rows.ts new file mode 100644 index 00000000000..c4d075fbd9c --- /dev/null +++ b/src/main/runtime/runtime-worktree-structured-agent-rows.ts @@ -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 +} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index c4376dbf66e..1276287aca0 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -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 = {}): 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() + 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() + 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() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index a72f28d7c08..47d6aec460f 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -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 } : {}) } ) } diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 51e306becca..67dbbf85c6f 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -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) ) } diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts new file mode 100644 index 00000000000..4f98700d806 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-status-feed-lifecycle.test.ts @@ -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 +} +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() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/structured-agent-session-status-feed.ts index 2b550679315..8308ac8729a 100644 --- a/src/renderer/src/runtime/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/structured-agent-session-status-feed.ts @@ -82,6 +82,32 @@ function createOwner(target: RuntimeClientTarget): OwnedStatusFeed { handle?.unsubscribe() handle = null } + const revokeSnapshotOwnership = (): void => { + let next: Map | 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 } diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index 39bbde535d1..453fef02139 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -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 diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index 5c3ef89bdaf..63ba0a5afc9 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -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: diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 70d464d5392..0f7c453752f 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -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 diff --git a/src/shared/agent-status-freshness.ts b/src/shared/agent-status-freshness.ts index 84a3218e3dd..616879b93e9 100644 --- a/src/shared/agent-status-freshness.ts +++ b/src/shared/agent-status-freshness.ts @@ -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) ) } diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 128e2f80f82..cc0ad487565 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -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 diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 02ad872c211..1301f766e2b 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -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('')