feat(agent-status): add bounded execution evidence

This commit is contained in:
Brennan Benson
2026-09-15 10:52:48 -07:00
parent 5218260a53
commit f4cf7e0d9c
67 changed files with 2767 additions and 238 deletions
+2 -1
View File
@@ -13,7 +13,8 @@ const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, strin
blocked: '#ef4444',
waiting: '#ef4444',
interrupted: '#ef4444',
idle: 'rgba(115,115,115,0.4)'
idle: 'rgba(115,115,115,0.4)',
unverifiable: '#f59e0b'
}
const WORKING_COLOR = '#eab308'
@@ -26,6 +26,16 @@ function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeA
}
describe('agentDotState', () => {
const observedLive = {
executionId: 'exec-1',
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 2,
observedAt: 0,
inventoryCoverage: 'complete' as const,
verdict: 'live' as const
}
it('maps known states through and unknown to idle', () => {
expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working')
expect(
@@ -57,6 +67,22 @@ describe('agentDotState', () => {
'interrupted'
)
})
it('retains stale pending attention and marks stale working as unverifiable with host evidence', () => {
const stale = AGENT_STATUS_STALE_AFTER_MS + 1
expect(
agentDotState(
row({ state: 'waiting', updatedAt: 0, executionObservation: observedLive }),
stale
)
).toBe('waiting')
expect(
agentDotState(
row({ state: 'working', updatedAt: 0, executionObservation: observedLive }),
stale
)
).toBe('unverifiable')
})
})
describe('agentDisplayLabel', () => {
+12 -1
View File
@@ -1,4 +1,5 @@
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { resolveAgentStatusPresentation } from '../../../src/shared/agent-execution-observation'
// Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts:
// 30 min). Defined locally rather than imported because a runtime-value import
@@ -16,15 +17,23 @@ export type AgentDotState =
| 'waiting'
| 'done'
| 'idle'
| 'unverifiable'
| 'interrupted'
export function agentDotState(
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
row: Pick<
RuntimeWorktreeAgentRow,
'state' | 'workingMode' | 'interrupted' | 'updatedAt' | 'executionObservation'
>,
now: number
): AgentDotState {
if (row.interrupted) {
return 'interrupted'
}
if (row.executionObservation) {
const projected = resolveAgentStatusPresentation(row, now, AGENT_STATUS_STALE_AFTER_MS)
return projected.state
}
switch (row.state) {
case 'blocked':
case 'waiting':
@@ -60,6 +69,8 @@ export function agentStateLabel(state: AgentDotState): string {
return 'Done'
case 'idle':
return 'Idle'
case 'unverifiable':
return 'No recent update'
}
}
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHookServer, _internals } from './server'
import { PANE } from './server.test-fixtures'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 }))
}))
beforeEach(() => {
_internals.resetCachesForTests()
})
describe('host execution observation publication', () => {
it('publishes evidence into the existing row and advances unchanged captures', () => {
const server = new AgentHookServer()
const publisher = server.createStatusStorePublisher({
executionHostId: 'local',
ownerEpoch: 'host-epoch'
})
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{ paneKey: PANE, payload: { state: 'waiting', agentType: 'codex' } },
'conn-1'
)
listener.mockClear()
const frames: unknown[] = []
const unsubscribe = publisher.subscribe((frame) => frames.push(frame))
frames.length = 0
const first = {
executionId: 'exec-1',
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: 1_000,
inventoryCoverage: 'complete' as const,
verdict: 'live' as const
}
const second = { ...first, captureRevision: 2 }
expect(server.publishExecutionObservation(PANE, first)).toBe(true)
expect(server.getStatusSnapshot()[0]?.executionObservation).toEqual(first)
expect(listener).toHaveBeenCalledOnce()
expect(server.publishExecutionObservation(PANE, first)).toBe(false)
expect(server.publishExecutionObservation(PANE, second)).toBe(true)
expect(server.getStatusSnapshot()[0]?.executionObservation?.captureRevision).toBe(2)
const delta = frames
.toReversed()
.find(
(frame): frame is { type: 'delta'; changes: { type: string; row?: unknown }[] } =>
typeof frame === 'object' && frame !== null && 'type' in frame && frame.type === 'delta'
)
expect(delta?.changes).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'set',
row: expect.objectContaining({
executionObservation: expect.objectContaining({ captureRevision: 2 })
})
})
])
)
unsubscribe()
publisher.dispose()
})
it('does not create a status row for an attachment that is no longer present', () => {
const server = new AgentHookServer()
const observation = {
executionId: 'exec-1',
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: 1_000,
inventoryCoverage: 'partial' as const,
verdict: 'unverifiable' as const
}
expect(server.publishExecutionObservation(PANE, observation)).toBe(false)
expect(server.getStatusSnapshot()).toEqual([])
})
it('rejects a changed attachment binding or host identity', () => {
const server = new AgentHookServer()
server.ingestRemote(
{ paneKey: PANE, payload: { state: 'working', agentType: 'codex' } },
'conn-1'
)
const first = {
executionId: 'exec-1',
runId: 'run-1',
role: 'root' as const,
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: 1_000,
inventoryCoverage: 'complete' as const,
verdict: 'live' as const
}
expect(server.publishExecutionObservation(PANE, first)).toBe(true)
expect(server.publishExecutionObservation(PANE, { ...first, runId: 'run-2' })).toBe(false)
expect(server.publishExecutionObservation(PANE, { ...first, hostId: 'ssh:target' })).toBe(false)
expect(server.getStatusSnapshot()[0]?.executionObservation).toEqual(first)
})
it('accepts a replacement only when the host supplies the exact attachment proof', () => {
const server = new AgentHookServer()
server.ingestRemote(
{ paneKey: PANE, payload: { state: 'working', agentType: 'codex' } },
'conn-1'
)
const oldObservation = {
executionId: 'exec-old',
runId: 'run-old',
role: 'root' as const,
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: 1_000,
inventoryCoverage: 'complete' as const,
verdict: 'live' as const
}
const replacement = {
...oldObservation,
executionId: 'exec-new',
runId: 'run-new',
captureRevision: 1
}
expect(server.publishExecutionObservation(PANE, oldObservation)).toBe(true)
expect(
server.publishExecutionObservation(PANE, replacement, {
executionId: 'exec-new',
runId: 'run-new',
role: 'root',
hostId: 'local',
hostEpoch: 'epoch-1',
paneKey: PANE
})
).toBe(true)
expect(server.getStatusSnapshot()[0]?.executionObservation).toEqual(replacement)
expect(
server.publishExecutionObservation(
PANE,
{ ...replacement, captureRevision: 2 },
{
executionId: 'exec-new',
runId: 'run-new',
role: 'root',
hostId: 'local',
hostEpoch: 'epoch-1',
paneKey: PANE
}
)
).toBe(true)
expect(server.getStatusSnapshot()[0]?.executionObservation?.captureRevision).toBe(2)
})
})
@@ -0,0 +1,109 @@
import {
parseAgentExecutionObservation,
type AgentExecutionAttachment,
type AgentExecutionObservation
} from '../../../shared/agent-execution-observation'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state'
import type {
AgentHookStatusFreshnessObservation,
EnrichedAgentHookEventPayload
} from './server-types'
type PublishExecutionObservationArgs = {
paneKey: string
observation: AgentExecutionObservation
attachment?: AgentExecutionAttachment
state: HookListenerState
runtimeObservedStatusPaneKeys: ReadonlySet<string>
commitStatusRowMutation: (
before: EnrichedAgentHookEventPayload | null | undefined,
after: EnrichedAgentHookEventPayload | null | undefined
) => boolean
emitEnrichedStatus: (entry: EnrichedAgentHookEventPayload) => void
emitStatusFreshnessObservation: (status: AgentHookStatusFreshnessObservation) => void
}
function isEnrichedStatusRow(
value: AgentHookEventPayload | undefined
): value is EnrichedAgentHookEventPayload {
return (
value !== undefined &&
typeof value === 'object' &&
'receivedAt' in value &&
typeof value.receivedAt === 'number' &&
'stateStartedAt' in value &&
typeof value.stateStartedAt === 'number'
)
}
export function publishExecutionObservationIntoStore({
paneKey,
observation,
attachment,
state,
runtimeObservedStatusPaneKeys,
commitStatusRowMutation,
emitEnrichedStatus,
emitStatusFreshnessObservation
}: PublishExecutionObservationArgs): boolean {
const parsedObservation = parseAgentExecutionObservation(observation)
if (!parsedObservation) {
return false
}
const current = state.lastStatusByPaneKey.get(paneKey)
if (!isEnrichedStatusRow(current)) {
return false
}
const currentExecutionId =
'executionId' in current && typeof current.executionId === 'string'
? current.executionId
: undefined
const currentRunId =
'runId' in current && typeof current.runId === 'string' ? current.runId : undefined
if (
(currentExecutionId !== undefined && currentExecutionId !== parsedObservation.executionId) ||
(currentRunId !== undefined && currentRunId !== parsedObservation.runId)
) {
return false
}
const previous = current.executionObservation
const hasExactAttachmentProof =
attachment?.paneKey === paneKey &&
attachment.executionId === parsedObservation.executionId &&
attachment.hostId === parsedObservation.hostId &&
attachment.hostEpoch === parsedObservation.hostEpoch &&
attachment.runId === parsedObservation.runId &&
attachment.role === parsedObservation.role &&
attachment.continuityOf === parsedObservation.continuityOf
const sameObservationAttachment =
previous?.executionId === parsedObservation.executionId &&
previous.hostEpoch === parsedObservation.hostEpoch &&
previous.hostId === parsedObservation.hostId &&
previous.runId === parsedObservation.runId &&
previous.role === parsedObservation.role &&
previous.continuityOf === parsedObservation.continuityOf
if (
previous &&
((!sameObservationAttachment && !hasExactAttachmentProof) ||
(sameObservationAttachment && previous.captureRevision >= parsedObservation.captureRevision))
) {
return false
}
const enriched: EnrichedAgentHookEventPayload = {
...current,
executionObservation: parsedObservation
}
state.lastStatusByPaneKey.set(paneKey, enriched)
commitStatusRowMutation(current, enriched)
emitEnrichedStatus(enriched)
emitStatusFreshnessObservation({
paneKey,
state: enriched.payload.state,
receivedAt: enriched.receivedAt,
observedInCurrentRuntime: runtimeObservedStatusPaneKeys.has(paneKey),
...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}),
...(enriched.terminalHandle ? { terminalHandle: enriched.terminalHandle } : {})
})
return true
}
@@ -19,6 +19,10 @@ import {
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type { AgentHookSource } from '../../../shared/agent-hook-relay'
import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types'
import type {
AgentExecutionAttachment,
AgentExecutionObservation
} from '../../../shared/agent-execution-observation'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
import type { SpoolRecord } from '../../../shared/agent-hook-spool'
import type {
@@ -42,6 +46,7 @@ import type {
StatusFreshnessListener,
StatusRowMutationListener
} from './server-types'
import { publishExecutionObservationIntoStore } from './server-execution-observation'
/** Shared mutable state for the layered hook-server implementation. */
export abstract class AgentHookServerState {
@@ -119,7 +124,34 @@ export abstract class AgentHookServerState {
createAgentStatusAuthorityId('main-agent-hooks')
)
/**
* Publishes host evidence into the existing hook-store row. The evidence is
* deliberately not persisted: a restart must re-observe the attachment, and
* a newer capture is meaningful even when the reported state is unchanged.
*/
publishExecutionObservation(
paneKey: string,
observation: AgentExecutionObservation,
attachment?: AgentExecutionAttachment
): boolean {
return publishExecutionObservationIntoStore({
paneKey,
observation,
attachment,
state: this.state,
runtimeObservedStatusPaneKeys: this.runtimeObservedStatusPaneKeys,
commitStatusRowMutation: (before, after) => this.commitStatusRowMutation(before, after),
emitEnrichedStatus: (entry) => this.emitEnrichedStatus(entry),
emitStatusFreshnessObservation: (status) => this.emitStatusFreshnessObservation(status)
})
}
protected abstract withdrawReplayObservation(paneKey: string): void
protected abstract commitStatusRowMutation(
before: EnrichedAgentHookEventPayload | null | undefined,
after: EnrichedAgentHookEventPayload | null | undefined,
emit?: boolean
): boolean
protected abstract ingestSpoolRecord(record: SpoolRecord): void
protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void
protected abstract buildStatusChangeNotification(): {
@@ -46,6 +46,14 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
// Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival.
return {
...payload,
// Keep host evidence across ordinary hook updates only when the same
// concrete terminal handle owns both events. A pane-key reuse must not
// inherit the prior attachment's verdict.
...(previous?.executionObservation &&
previous.terminalHandle &&
payload.terminalHandle === previous.terminalHandle
? { executionObservation: previous.executionObservation }
: {}),
receivedAt: now,
evidenceObservedAt: observedAt ?? this.resolveEvidenceObservedAt(payload, previous, now),
stateStartedAt
@@ -68,6 +68,7 @@ export function toAgentStatusIpcPayload(
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
...(entry.observation ? { observation: entry.observation } : {}),
...(entry.executionObservation ? { executionObservation: entry.executionObservation } : {}),
...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}),
...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}),
...entry.payload
@@ -5,6 +5,7 @@ import type {
AgentStatusState
} from '../../../shared/agent-status-types'
import type { AgentStatusObservation } from '../../../shared/agent-status-observation'
import type { AgentExecutionObservation } from '../../../shared/agent-execution-observation'
import type { AgentKind } from '../../../shared/telemetry-events'
import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types'
@@ -19,6 +20,8 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
stateStartedAt: number
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
observation?: AgentStatusObservation
/** Host-owned evidence for this exact attachment; omitted from persistence. */
executionObservation?: AgentExecutionObservation
/** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */
restoredUnconfirmed?: true
/** User-hidden resume identity retained solely for destructive liveness checks. */
@@ -36,6 +39,7 @@ export type PersistedAgentHookEventPayload = Omit<
// Why: revision counters are in-memory and the authority id is regenerated per process, so
// a stored observation could only rehydrate as a stale ordering claim from a dead authority.
| 'observation'
| 'executionObservation'
// Same: a terminal handle is issued by one runtime and means nothing to the next.
| 'terminalHandle'
> & {
+6 -1
View File
@@ -60,6 +60,7 @@ import {
stripSequencedStartupResumeArgv
} from './host-env/codex-resume'
import { ensureLinuxTerminalOrcaCliShimDir } from '../../cli/linux-terminal-orca-cli-shim'
import { agentHookServer } from '../../agent-hooks/server'
export function registerPtyHandlers(
mainWindow: BrowserWindow,
@@ -232,7 +233,11 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv: session.trustedTerminalHandleEnv,
retiredRejectedPtyIds: session.retiredRejectedPtyIds,
reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId,
mainWindow
mainWindow,
// The host server remains the single status writer; C6 only supplies an
// attachment observation and never constructs replica frames.
publishExecutionObservation: (paneKey, observation, attachment) =>
agentHookServer.publishExecutionObservation(paneKey, observation, attachment)
})
installPtySnapshotIpcHandlers({ runtime, pendingData: session.pendingData })
@@ -18,6 +18,10 @@ import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/cod
import type { StablePaneOwner } from '../pane/stable-owner'
import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types'
import type { finishPtyShutdown } from '../provider/liveness'
import type {
AgentExecutionAttachment,
AgentExecutionObservation
} from '../../../../shared/agent-execution-observation'
export type PtyRuntimeControllerDeps = {
runtime?: OrcaRuntimeService
@@ -84,6 +88,12 @@ export type PtyRuntimeControllerDeps = {
retiredRejectedPtyIds: Map<string, NodeJS.Timeout>
reversibleStopOwnersByPtyId: Map<string, number>
mainWindow: BrowserWindow
/** Host-owned status sink; C6 never constructs replica frames itself. */
publishExecutionObservation?: (
paneKey: string,
observation: AgentExecutionObservation,
attachment?: AgentExecutionAttachment
) => boolean
}
export type { StablePaneOwner }
+54 -2
View File
@@ -1,4 +1,11 @@
import { randomUUID } from 'node:crypto'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { AgentExecutionObservationService } from '../../../runtime/agent-execution-observation-service'
import { buildAgentExecutionAttachments } from '../../../runtime/agent-execution-observation-attachments'
import { agentSessionOwners } from '../pane/agent-session-owners'
import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state'
import { getSshPtyProvider } from '../provider/registry'
import { claimRuntimePaneCreate, makePaneSpawnReservationKey } from '../pane/spawn-reservation'
import type { PtyRuntimeControllerDeps } from './controller-deps'
import { spawnPtyFromRuntimeController } from './spawn'
@@ -37,7 +44,7 @@ import {
export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): void {
const { runtime, adoptStablePane, requestSerializedBuffer } = deps
runtime?.setPtyController({
const controller = {
claimStablePaneCreate: (args) => {
const paneKey = makePaneKey(args.tabId, args.leafId)
const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey)
@@ -88,5 +95,50 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
waitForRendererSerializerFromRuntimeController(ptyId, afterGeneration, timeoutMs, signal),
getSize: (ptyId) => getSizeFromRuntimeController(ptyId),
resize: (ptyId, cols, rows) => resizePtyFromRuntimeController(ptyId, cols, rows)
})
}
runtime?.setPtyController(controller)
if (runtime && deps.publishExecutionObservation) {
const hostEpochSeed = randomUUID()
const getHostEpoch = (hostId: ExecutionHostId): string => {
const parsed = parseExecutionHostId(hostId)
if (parsed?.kind === 'ssh') {
const provider = getSshPtyProvider(parsed.targetId)
const generation =
provider &&
'providerGeneration' in provider &&
typeof provider.providerGeneration === 'number'
? provider.providerGeneration
: null
if (generation !== null && Number.isSafeInteger(generation) && generation > 0) {
return `${hostId}:${generation}`
}
}
return `${hostId}:${hostEpochSeed}`
}
const previous = observationServicesByRuntime.get(runtime)
previous?.stop()
const observationService = new AgentExecutionObservationService(
{
getController: (hostId) => (parseExecutionHostId(hostId) ? controller : null),
getHostEpoch,
getAttachments: () =>
buildAgentExecutionAttachments(agentSessionOwners.list(), {
ptyOwnership,
ptyIncarnationById,
getHostEpoch
}),
publish: (observation, attachment) => {
if (attachment?.paneKey) {
deps.publishExecutionObservation?.(attachment.paneKey, observation, attachment)
}
}
},
{ maxConcurrentHostScans: 2 }
)
observationServicesByRuntime.set(runtime, observationService)
observationService.start()
}
}
const observationServicesByRuntime = new WeakMap<object, AgentExecutionObservationService>()
@@ -32,7 +32,7 @@ export async function listProcessesWithHostScopeFromRuntimeController(
: LOCAL_EXECUTION_HOST_ID
try {
return {
processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()),
processes: await provider.listProcesses(opts),
hostId
}
} catch (error) {
@@ -57,7 +57,7 @@ export async function listProcessesFromRuntimeController(
opts?: { deadlineMs?: number; includeForegroundProcessEvidence?: boolean }
) {
if (connectionId === null) {
return localProvider.listProcesses()
return localProvider.listProcesses(opts)
}
if (connectionId !== undefined) {
try {
+1 -1
View File
@@ -154,7 +154,7 @@ export async function getForegroundProcessFromRuntimeController(ptyId: string) {
export async function inspectProcessFromRuntimeController(
ptyId: string,
options?: { expectedIncarnationId?: string }
options?: { expectedIncarnationId?: string; scanChildProcesses?: boolean }
) {
return inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId, options)
}
@@ -0,0 +1,64 @@
import {
LOCAL_EXECUTION_HOST_ID,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { parseAppSshPtyId } from '../providers/ssh-pty-id'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import type { AgentExecutionAttachment } from '../../shared/agent-execution-observation'
import { makePaneKey } from '../../shared/stable-pane-id'
type HostOwnership = ReadonlyMap<string, string | null>
type PtyIncarnations = ReadonlyMap<string, string>
/**
* Derives observation attachments only from committed owner and process indexes.
* Missing host or incarnation ownership is intentionally omitted rather than
* guessed as local or inferred from a spawn operation.
*/
export function buildAgentExecutionAttachments(
owners: readonly AgentSessionOwnerBinding[],
options: {
ptyOwnership: HostOwnership
ptyIncarnationById: PtyIncarnations
getHostEpoch: (hostId: ExecutionHostId) => string
}
): AgentExecutionAttachment[] {
const attachments: AgentExecutionAttachment[] = []
for (const owner of owners) {
const connectionId = options.ptyOwnership.get(owner.ptyId)
const incarnationId = options.ptyIncarnationById.get(owner.ptyId)
const parsedSshPty = connectionId === undefined ? parseAppSshPtyId(owner.ptyId) : null
const hostId =
connectionId === undefined
? parsedSshPty
? toSshExecutionHostId(parsedSshPty.connectionId)
: null
: connectionId === null
? LOCAL_EXECUTION_HOST_ID
: toSshExecutionHostId(connectionId)
if (!hostId) {
continue
}
const paneKey = makePaneKey(owner.surface.tabId, owner.surface.leafId)
attachments.push({
executionId: owner.statusBinding.attachment.executionId,
runId: owner.statusBinding.runId,
role: owner.statusBinding.role,
...(owner.statusBinding.continuityOf
? { continuityOf: owner.statusBinding.continuityOf }
: {}),
hostId,
paneKey,
hostEpoch: options.getHostEpoch(hostId),
...(incarnationId
? {
processIncarnation: `${owner.ptyId}:${incarnationId}`,
processId: owner.ptyId,
processIncarnationId: incarnationId
}
: {})
})
}
return attachments
}
@@ -0,0 +1,116 @@
import { isShellProcess } from '../../shared/agent-detection'
import type {
ForegroundProcessEvidence,
RemoteForegroundEvidence
} from '../../shared/foreground-process-evidence'
import type { AgentExecutionAttachment } from '../../shared/agent-execution-observation'
import type { AgentStatusRunVerdict } from '../../shared/agent-status-run'
import type { PtyProcessInfo } from '../providers/pty-process-info'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import { withTimeoutResult } from './runtime-async-boundaries'
export function processIncarnation(process: PtyProcessInfo): string | null {
return process.incarnationId ? `${process.id}:${process.incarnationId}` : null
}
export function processIncarnations(processes: readonly PtyProcessInfo[]): ReadonlySet<string> {
const identities = new Set<string>()
for (const process of processes) {
const identity = processIncarnation(process)
if (identity) {
identities.add(identity)
}
}
return identities
}
export async function inspectProcessVerdicts(
controller: RuntimePtyController,
attachments: readonly AgentExecutionAttachment[],
processes: readonly PtyProcessInfo[],
deadlineAtMs: number,
signal: AbortSignal
): Promise<ReadonlyMap<string, AgentStatusRunVerdict>> {
const processByIdentity = new Map(
processes
.map((process) => {
const identity = processIncarnation(process)
return identity ? ([identity, process] as const) : null
})
.filter((entry): entry is readonly [string, PtyProcessInfo] => entry !== null)
)
const verdicts = new Map<string, AgentStatusRunVerdict>()
const inspectable = attachments.filter(
(attachment) => attachment.processIncarnation && attachment.processId
)
const workers = Math.min(8, Math.max(1, inspectable.length))
let next = 0
const inspectOne = async (): Promise<void> => {
while (next < inspectable.length) {
const attachment = inspectable[next++]
if (signal.aborted || !attachment.processIncarnation || !attachment.processId) {
return
}
if (!processByIdentity.has(attachment.processIncarnation)) {
verdicts.set(attachment.processIncarnation, 'exited')
continue
}
const process = processByIdentity.get(attachment.processIncarnation)
if (process?.foregroundProcessEvidence) {
verdicts.set(
attachment.processIncarnation,
verdictFromForegroundEvidence(process.foregroundProcessEvidence)
)
continue
}
if (!controller.inspectProcess) {
verdicts.set(attachment.processIncarnation, 'unverifiable')
continue
}
const inspection = await withTimeoutResult(
controller.inspectProcess(attachment.processId, {
expectedIncarnationId: attachment.processIncarnationId,
scanChildProcesses: true
}),
Math.max(1, deadlineAtMs - Date.now())
)
verdicts.set(
attachment.processIncarnation,
inspection.ok ? verdictFromInspection(inspection.value) : 'unverifiable'
)
}
}
await Promise.all(Array.from({ length: workers }, () => inspectOne()))
return verdicts
}
function verdictFromInspection(
inspection: Awaited<ReturnType<NonNullable<RuntimePtyController['inspectProcess']>>>
): AgentStatusRunVerdict {
const evidence = inspection.foregroundProcessEvidence
if (evidence) {
return verdictFromForegroundEvidence(evidence)
}
if (inspection.childProcessEvidence === 'children' || inspection.hasChildProcesses) {
return 'live'
}
if (inspection.childProcessEvidence === 'unverifiable') {
return 'unverifiable'
}
if (inspection.childProcessEvidence === 'no-children') {
return 'exited'
}
if (inspection.foregroundProcess === null) {
return 'unverifiable'
}
return isShellProcess(inspection.foregroundProcess) ? 'exited' : 'live'
}
function verdictFromForegroundEvidence(
evidence: ForegroundProcessEvidence | RemoteForegroundEvidence
): AgentStatusRunVerdict {
if (evidence.verdict === 'exited' || evidence.verdict === 'unverifiable') {
return evidence.verdict
}
return evidence.shellOwnsEveryTtyProcessGroup === true ? 'exited' : 'live'
}
@@ -0,0 +1,295 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { PtyProcessInfo } from '../providers/pty-process-info'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import { AgentExecutionObservationService } from './agent-execution-observation-service'
import { buildAgentExecutionAttachments } from './agent-execution-observation-attachments'
describe('AgentExecutionObservationService', () => {
it('uses the owning SSH controller and exact process incarnation', async () => {
const controllerBase = {
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
}
const sshController = {
...controllerBase,
listProcesses: vi.fn(async () => [
{ id: 'pty-1', incarnationId: 'inc-1', cwd: '/tmp', title: 'agent' }
])
}
const localController = { ...controllerBase, listProcessesWithHostScope: vi.fn() }
const published: unknown[] = []
const service = new AgentExecutionObservationService({
getController: (hostId) => (hostId.startsWith('ssh:') ? sshController : localController),
getHostEpoch: () => 'relay-epoch-1',
getAttachments: () => [
{
executionId: 'exec-1',
hostId: 'ssh:target',
hostEpoch: 'relay-epoch-1',
processIncarnation: 'pty-1:inc-1'
}
],
publish: (observation) => published.push(observation),
listDeadlineMs: 1_000
})
const result = service.observe('exec-1')
await new Promise((resolve) => setTimeout(resolve, 30))
await expect(result).resolves.toMatchObject({
executionId: 'exec-1',
hostId: 'ssh:target',
verdict: 'live',
inventoryCoverage: 'complete'
})
expect(sshController.listProcesses).toHaveBeenCalledWith('target', expect.any(Object))
expect(localController.listProcessesWithHostScope).not.toHaveBeenCalled()
expect(published).toHaveLength(1)
service.stop()
})
it('derives exact attachments from committed owner bindings and never guesses host or process', () => {
const owner: AgentSessionOwnerBinding = {
claim: {
digestVersion: 1,
keyId: 'key',
identityDigest: 'a'.repeat(43),
worktreeScopeDigest: 'b'.repeat(43),
agent: 'codex'
},
generation: 'generation-1',
phase: 'live' as const,
ptyId: 'pty-1',
surface: {
worktreeId: 'worktree-1',
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111',
terminalHandle: 'term_handle'
},
statusBinding: {
runId: 'run-1',
attachment: { executionId: 'attachment-1' },
role: 'root' as const,
continuityOf: 'run-0'
}
}
const ptyOwnership = new Map<string, string | null>([['pty-1', 'ssh-target']])
const ptyIncarnationById = new Map<string, string>([['pty-1', 'inc-1']])
expect(
buildAgentExecutionAttachments([owner], {
ptyOwnership,
ptyIncarnationById,
getHostEpoch: (hostId) => `${hostId}:epoch-1`
})
).toEqual([
{
executionId: 'attachment-1',
runId: 'run-1',
role: 'root',
continuityOf: 'run-0',
hostId: 'ssh:ssh-target',
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
hostEpoch: 'ssh:ssh-target:epoch-1',
processIncarnation: 'pty-1:inc-1',
processId: 'pty-1',
processIncarnationId: 'inc-1'
}
])
expect(
buildAgentExecutionAttachments([owner], {
ptyOwnership: new Map(),
ptyIncarnationById,
getHostEpoch: () => 'epoch-1'
})
).toEqual([])
expect(
buildAgentExecutionAttachments([owner], {
ptyOwnership,
ptyIncarnationById: new Map(),
getHostEpoch: () => 'epoch-1'
})
).toEqual([
{
executionId: 'attachment-1',
runId: 'run-1',
role: 'root',
continuityOf: 'run-0',
hostId: 'ssh:ssh-target',
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
hostEpoch: 'epoch-1'
}
])
const remoteOwner = { ...owner, ptyId: 'ssh:ssh-target@@pty-2' }
expect(
buildAgentExecutionAttachments([remoteOwner], {
ptyOwnership: new Map(),
ptyIncarnationById: new Map(),
getHostEpoch: (hostId) => `${hostId}:epoch-2`
})
).toEqual([
{
executionId: 'attachment-1',
runId: 'run-1',
role: 'root',
continuityOf: 'run-0',
hostId: 'ssh:ssh-target',
paneKey: 'tab-1:11111111-1111-4111-8111-111111111111',
hostEpoch: 'ssh:ssh-target:epoch-2'
}
])
})
it('does not fall back to a local controller when an SSH controller is unavailable', async () => {
const localController = {
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcessesWithHostScope: vi.fn(async () => ({ processes: [], hostIds: ['local'] }))
}
const service = new AgentExecutionObservationService({
getController: () => null,
getHostEpoch: () => 'epoch-1',
getAttachments: () => [
{
executionId: 'exec-1',
hostId: 'ssh:target',
hostEpoch: 'epoch-1',
processIncarnation: 'pty-1:inc-1'
}
],
publish: () => undefined
})
await expect(service.observe('exec-1')).resolves.toMatchObject({ verdict: 'unverifiable' })
expect(localController.listProcessesWithHostScope).not.toHaveBeenCalled()
service.stop()
})
it('uses host process evidence to distinguish an agent exit from a live PTY shell', async () => {
const controller: RuntimePtyController = {
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'bash',
listProcessesWithHostScope: vi.fn(
async (): Promise<{
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}> => ({
processes: [{ id: 'pty-1', incarnationId: 'inc-1', cwd: '/tmp', title: 'shell' }],
hostIds: ['local' satisfies ExecutionHostId]
})
),
inspectProcess: vi.fn(async () => ({
foregroundProcess: 'bash',
hasChildProcesses: false,
childProcessEvidence: 'no-children' as const
}))
}
const service = new AgentExecutionObservationService({
getController: () => controller,
getHostEpoch: () => 'epoch-1',
getAttachments: () => [
{
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
processIncarnation: 'pty-1:inc-1',
processId: 'pty-1',
processIncarnationId: 'inc-1'
}
],
publish: () => undefined
})
const result = service.observe('exec-1')
await new Promise((resolve) => setTimeout(resolve, 30))
await expect(result).resolves.toMatchObject({ verdict: 'exited' })
expect(controller.inspectProcess).toHaveBeenCalledWith('pty-1', {
expectedIncarnationId: 'inc-1',
scanChildProcesses: true
})
service.stop()
})
it('does not overlap a timed-out host inventory while the provider call is still pending', async () => {
let resolveInventory!: (value: {
processes: PtyProcessInfo[]
hostIds: ExecutionHostId[]
}) => void
const listProcessesWithHostScope = vi.fn(
() =>
new Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }>((resolve) => {
resolveInventory = resolve
})
)
const controller: RuntimePtyController = {
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcessesWithHostScope
}
const service = new AgentExecutionObservationService({
getController: () => controller,
getHostEpoch: () => 'epoch-1',
getAttachments: () => [
{
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
processIncarnation: 'pty-1:inc-1'
}
],
publish: () => undefined,
listDeadlineMs: 5
})
await expect(service.observe('exec-1')).resolves.toMatchObject({ verdict: 'unverifiable' })
await new Promise((resolve) => setTimeout(resolve, 30))
await expect(service.observe('exec-1')).resolves.toMatchObject({ verdict: 'unverifiable' })
expect(listProcessesWithHostScope).toHaveBeenCalledOnce()
resolveInventory({ processes: [], hostIds: ['local'] })
service.stop()
})
it('bounds nested process inspection by the host scan deadline', async () => {
let listCalls = 0
const controller: RuntimePtyController = {
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcessesWithHostScope: vi.fn(async () => {
listCalls += 1
return {
processes: [{ id: 'pty-1', incarnationId: 'inc-1', cwd: '/tmp', title: 'agent' }],
hostIds: ['local' as const]
}
}),
inspectProcess: vi.fn(() => new Promise<never>(() => {}))
}
const service = new AgentExecutionObservationService({
getController: () => controller,
getHostEpoch: () => 'epoch-1',
getAttachments: () => [
{
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
processIncarnation: 'pty-1:inc-1',
processId: 'pty-1',
processIncarnationId: 'inc-1'
}
],
publish: () => undefined,
listDeadlineMs: 5
})
await expect(service.observe('exec-1')).resolves.toMatchObject({ verdict: 'unverifiable' })
await new Promise((resolve) => setTimeout(resolve, 25))
await expect(service.observe('exec-1')).resolves.toMatchObject({ verdict: 'unverifiable' })
expect(listCalls).toBe(2)
service.stop()
})
})
@@ -0,0 +1,234 @@
import {
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import {
AgentExecutionObservationScheduler,
type AgentExecutionAttachment,
type AgentExecutionHostInventory,
type AgentExecutionObservation,
type AgentExecutionObservationSchedulerOptions
} from '../../shared/agent-execution-observation'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
import { withTimeoutResult } from './runtime-async-boundaries'
import { inspectProcessVerdicts, processIncarnations } from './agent-execution-observation-process'
type Dependencies = {
/** The controller is selected by execution host; no local fallback is allowed for SSH. */
getController(hostId: ExecutionHostId): RuntimePtyController | null
/** Host-local epoch changes when a daemon/relay restarts and invalidates old captures. */
getHostEpoch(hostId: ExecutionHostId): string
/** Reads the committed owner registry; this is the sole attachment source. */
getAttachments(): readonly AgentExecutionAttachment[]
publish(observation: AgentExecutionObservation, attachment?: AgentExecutionAttachment): void
listDeadlineMs?: number
}
/**
* Runtime composition for the shared observation scheduler.
*
* The provider inventory is authoritative only for the host it answers for.
* A missing controller, timeout, relay failure, or incomplete host scope is
* deliberately surfaced as `unverifiable`; no local process list can answer
* for an SSH attachment.
*/
export class AgentExecutionObservationService {
private readonly scheduler: AgentExecutionObservationScheduler
private readonly deadlineMs: number
/**
* Provider calls cannot all be cancelled (notably older SSH relays). Keep a
* timed-out host operation fenced until it settles so a retry never overlaps
* the still-running RPC and multiplies host work.
*/
private readonly inFlightHostOperations = new Map<ExecutionHostId, Promise<void>>()
private pollingHandle: ReturnType<typeof setTimeout> | null = null
private stopped = false
constructor(
private readonly deps: Dependencies,
options: AgentExecutionObservationSchedulerOptions = {}
) {
this.deadlineMs = Math.max(1, deps.listDeadlineMs ?? 2_000)
this.scheduler = new AgentExecutionObservationScheduler(
(hostId, signal, attachments) => this.scanHost(hostId, signal, attachments),
(observation) => {
const attachment = deps
.getAttachments()
.find((candidate) => candidate.executionId === observation.executionId)
if (
!attachment ||
attachment.hostId !== observation.hostId ||
attachment.hostEpoch !== observation.hostEpoch
) {
return
}
deps.publish(observation, attachment)
},
options
)
}
/** Reconciles the registry before each poll; stale reservations cannot linger. */
syncAttachments(): void {
const current = new Map(
this.deps.getAttachments().map((attachment) => [attachment.executionId, attachment])
)
const known = new Set<string>()
for (const attachment of current.values()) {
known.add(attachment.executionId)
this.scheduler.register(attachment)
}
for (const executionId of this.scheduler.getRegisteredExecutionIds()) {
if (!known.has(executionId)) {
this.scheduler.unregister(executionId)
}
}
}
observe(executionId: string): Promise<AgentExecutionObservation> {
this.syncAttachments()
return this.scheduler.request(executionId)
}
observeAll(): Promise<AgentExecutionObservation[]> {
this.syncAttachments()
return Promise.all(
this.deps.getAttachments().map((attachment) => this.scheduler.request(attachment.executionId))
)
}
getPublished(executionId: string): AgentExecutionObservation | undefined {
return this.scheduler.getPublished(executionId)
}
/** Starts one bounded host sweep cadence shared by every committed attachment. */
start(intervalMs = 5_000): void {
if (this.pollingHandle !== null) {
return
}
this.stopped = false
const poll = (): void => {
this.pollingHandle = null
if (this.stopped) {
return
}
void this.observeAll().catch((error) => {
console.warn('[agent-execution-observation] sweep failed', error)
})
this.pollingHandle = setTimeout(poll, Math.max(100, intervalMs))
this.pollingHandle.unref?.()
}
poll()
}
stop(): void {
this.stopped = true
if (this.pollingHandle !== null) {
clearTimeout(this.pollingHandle)
this.pollingHandle = null
}
this.scheduler.stop()
}
private async scanHost(
hostId: ExecutionHostId,
signal: AbortSignal,
attachments: readonly AgentExecutionAttachment[]
): Promise<AgentExecutionHostInventory> {
const controller = this.deps.getController(hostId)
if (!controller || signal.aborted) {
throw new Error('execution_observation_controller_unavailable')
}
if (this.inFlightHostOperations.has(hostId)) {
throw new Error('execution_observation_host_scan_in_flight')
}
const parsed = parseExecutionHostId(hostId)
const deadlineMs = Date.now() + this.deadlineMs
const operation = this.readHostInventory(
hostId,
parsed,
controller,
signal,
attachments,
deadlineMs
)
const settled = operation.then(
() => undefined,
() => undefined
)
this.inFlightHostOperations.set(hostId, settled)
void settled.then(() => {
if (this.inFlightHostOperations.get(hostId) === settled) {
this.inFlightHostOperations.delete(hostId)
}
})
const result = await withTimeoutResult(operation, this.deadlineMs)
if (!result.ok) {
throw new Error('execution_observation_timeout')
}
return result.value
}
private async readHostInventory(
hostId: ExecutionHostId,
parsed: ReturnType<typeof parseExecutionHostId>,
controller: RuntimePtyController,
signal: AbortSignal,
attachments: readonly AgentExecutionAttachment[],
deadlineMs: number
): Promise<AgentExecutionHostInventory> {
if (parsed?.kind === 'local' && controller.listProcessesWithHostScope) {
const result = await controller.listProcessesWithHostScope({
deadlineMs,
includeForegroundProcessEvidence: true
})
if (signal.aborted) {
throw new Error('execution_observation_aborted')
}
const hostIds = new Set(result.hostIds)
return {
hostId,
hostEpoch: this.deps.getHostEpoch(hostId),
capturedAt: Date.now(),
inventoryCoverage: hostIds.has(hostId) ? 'complete' : 'partial',
processIncarnations: processIncarnations(result.processes),
verdictByProcessIncarnation: await inspectProcessVerdicts(
controller,
attachments,
result.processes,
deadlineMs,
signal
)
}
}
if (parsed?.kind === 'ssh' && controller.listProcesses) {
const processes = await controller.listProcesses(parsed.targetId, {
deadlineMs,
includeForegroundProcessEvidence: true
})
if (signal.aborted) {
throw new Error('execution_observation_aborted')
}
return {
hostId,
hostEpoch: this.deps.getHostEpoch(hostId),
capturedAt: Date.now(),
inventoryCoverage: 'complete',
processIncarnations: processIncarnations(processes),
verdictByProcessIncarnation: await inspectProcessVerdicts(
controller,
attachments,
processes,
deadlineMs,
signal
)
}
}
throw new Error('execution_observation_inventory_unavailable')
}
}
export function hostIdForConnection(connectionId: string): ExecutionHostId {
return toSshExecutionHostId(connectionId)
}
@@ -156,4 +156,31 @@ describe('selectFreshAgentRowForMobileTab', () => {
})
).toBeNull()
})
it('retains a stale pending row when its exact attachment has host evidence', () => {
const selected = selectFreshAgentRowForMobileTab({
paneKey: PANE_KEY,
terminalHandle: HANDLE,
hookRows: [
row({
state: 'waiting',
evidenceObservedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1,
executionObservation: {
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: Date.now(),
inventoryCoverage: 'partial',
verdict: 'unverifiable'
}
})
]
})
expect(selected).toMatchObject({
payload: { state: 'waiting' },
executionObservation: { executionId: 'exec-1', verdict: 'unverifiable' }
})
})
})
@@ -8,6 +8,7 @@ import {
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types'
import { mapExplicitAgentStateToRuntimeTerminalStatus } from './runtime-worktree-status-projection'
import type { AgentExecutionObservation } from '../../shared/agent-execution-observation'
/** One hook-server row projected into the shape the runtime's own readers consume. */
export type RuntimeAgentRowSnapshot = {
@@ -20,6 +21,7 @@ export type RuntimeAgentRowSnapshot = {
updatedAt: number
evidenceObservedAt?: number
providerSession?: AgentProviderSessionMetadata
executionObservation?: AgentExecutionObservation
}
function isLiveObservation(row: AgentStatusIpcPayload): boolean {
@@ -95,8 +97,12 @@ export function selectFreshAgentRowForMobileTab(args: {
let match: AgentStatusIpcPayload | null = null
const now = Date.now()
for (const row of args.hookRows) {
if (!isLiveObservation(row)) {
continue
}
// Why: the shared projection, not hook age, demotes an observed row or retains its pending wait.
if (
!isLiveObservation(row) ||
!row.executionObservation &&
now - (row.evidenceObservedAt ?? row.receivedAt) > AGENT_STATUS_STALE_AFTER_MS
) {
continue
@@ -130,6 +136,7 @@ export function selectFreshAgentRowForMobileTab(args: {
...(match.providerSession ? { providerSession: match.providerSession } : {}),
...(match.evidenceObservedAt !== undefined
? { evidenceObservedAt: match.evidenceObservedAt }
: {})
: {}),
...(match.executionObservation ? { executionObservation: match.executionObservation } : {})
}
}
@@ -114,7 +114,10 @@ export function buildRuntimeMobileAgentStatus(
: {}),
tabId: tab.parentTabId,
terminalTitle,
...providerSession
...providerSession,
...(liveRow.executionObservation
? { executionObservation: liveRow.executionObservation }
: {})
},
ownerAgent,
ownerOptions
@@ -18,6 +18,11 @@ export function renewRuntimeMobileAgentStatusFromPtyTitle(
if (!status || !pty) {
return status
}
// Host evidence is the exact attachment's execution fact. A local OSC title
// cannot replace it with a synthetic done row or a different owner.
if (status.executionObservation) {
return status
}
// Same-class Claude title repaints can postdate a fresh permission hook without
// contradicting it; only a working or released-pane title retires the question (#11761).
if (
@@ -122,6 +127,7 @@ export function selectRuntimeHookAgentRowForPane(
let live: AgentStatusIpcPayload | null = null
const freshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS
for (const entry of rows) {
const hasExecutionObservation = entry.executionObservation !== undefined
if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) {
session = entry
}
@@ -129,7 +135,7 @@ export function selectRuntimeHookAgentRowForPane(
entry.agentType &&
(entry.providerSessionOnly !== true ||
(entry.agentType === 'pi' && entry.providerSession != null)) &&
(entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter &&
(hasExecutionObservation || (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter) &&
(!agent || entry.receivedAt > agent.receivedAt)
) {
agent = entry
@@ -138,7 +144,7 @@ export function selectRuntimeHookAgentRowForPane(
entry.providerSessionOnly !== true &&
// Restored rows cannot prove liveness because the turn may have ended while offline (#12346).
entry.restoredUnconfirmed !== true &&
(entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter &&
(hasExecutionObservation || (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter) &&
(!live || entry.receivedAt > live.receivedAt)
) {
live = entry
@@ -157,6 +163,7 @@ export function selectRuntimeHookAgentRowForPane(
...(live.evidenceObservedAt !== undefined
? { evidenceObservedAt: live.evidenceObservedAt }
: {}),
...(live.executionObservation ? { executionObservation: live.executionObservation } : {}),
stateStartedAt: live.stateStartedAt ?? live.receivedAt,
...(live.worktreeId ? { worktreeId: live.worktreeId } : {})
}
@@ -175,6 +182,9 @@ export function resolveRuntimeHookLiveAgentRow(
if (live.payload.interactivePrompt != null) {
return live
}
if (live.executionObservation) {
return live
}
// This is the pane's only wall-clock title timestamp comparable to when the hook evidence
// was observed; replay delivery order must not make old evidence outrank a newer title.
return !nonAgentTitle &&
@@ -224,7 +224,9 @@ export function projectRuntimeMobileSessionTabs(
// live status on every republication.
const keepFullAgentStatus =
normalizedTabAgentStatus &&
(!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal)
(normalizedTabAgentStatus.executionObservation !== undefined ||
!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) ||
hasLiveAgentSignal)
const agentStatus = keepFullAgentStatus
? { agentStatus: normalizedTabAgentStatus }
: // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript.
@@ -104,7 +104,12 @@ export type RuntimeTerminalAgentStatusEvent = {
export type HookLiveAgentRow = Pick<
RuntimeAgentRowSnapshot,
'payload' | 'updatedAt' | 'evidenceObservedAt' | 'stateStartedAt' | 'worktreeId'
| 'payload'
| 'updatedAt'
| 'evidenceObservedAt'
| 'stateStartedAt'
| 'worktreeId'
| 'executionObservation'
>
export type RuntimePtyDataAdmission = Readonly<{
@@ -1,9 +1,13 @@
import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus
} from '../../shared/agent-status-types'
import type { RuntimeWorktreeAgentRow, RuntimeWorktreePsSummary } from '../../shared/runtime-types'
import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection'
import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity'
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
import { resolveAgentStatusPresentation } from '../../shared/agent-execution-observation'
export type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection'
type OrchestrationDisplay = {
@@ -62,7 +66,8 @@ export function attachRuntimeWorktreeAgentRows(args: {
interrupted: source.interrupted,
stateStartedAt: source.stateStartedAt,
updatedAt: source.updatedAt,
...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {})
...(source.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {}),
...(source.executionObservation ? { executionObservation: source.executionObservation } : {})
}
const rows = rowsByWorktree.get(summary.worktreeId)
if (rows) {
@@ -81,11 +86,23 @@ export function attachRuntimeWorktreeAgentRows(args: {
let hasForegroundWorkingAgent = false
const monitoringSources: RuntimeWorktreeAgentSource[] = []
for (const row of rows) {
const effectiveState = row.executionObservation
? resolveAgentStatusPresentation(row, now, AGENT_STATUS_STALE_AFTER_MS).state
: row.state
if (row.executionObservation) {
if (
effectiveState !== 'working' &&
effectiveState !== 'blocked' &&
effectiveState !== 'waiting'
) {
continue
}
}
if (!isFreshNonDoneAgentStatus(row, now)) {
continue
}
summary.hasHostSidebarActivity = true
if (row.state === 'working') {
if (effectiveState === 'working') {
if (row.workingMode === 'monitoring') {
const source = rowSources.get(row.paneKey)
if (source) {
@@ -1,5 +1,6 @@
import type { StructuredHostStatus } from '../../shared/agent-hook-listener/listener-event'
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
import type { AgentExecutionObservation } from '../../shared/agent-execution-observation'
export type RuntimeWorktreeAgentSource = {
paneKey: string
@@ -19,4 +20,5 @@ export type RuntimeWorktreeAgentSource = {
updatedAt: number
/** Projected by the structured session host; `owned` rows stay fresh past the staleness window. */
structuredHost?: StructuredHostStatus
executionObservation?: AgentExecutionObservation
}
@@ -51,7 +51,8 @@ export function collectRuntimeWorktreePtyAgentSources(args: {
stateStartedAt: entry.stateStartedAt,
// A replay advances delivery order, not the age of the evidence shown by worktree.ps.
updatedAt: entry.evidenceObservedAt ?? entry.receivedAt,
...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {})
...(entry.structuredHost ? { structuredHost: entry.structuredHost } : {}),
...(entry.executionObservation ? { executionObservation: entry.executionObservation } : {})
})
}
const sources: RuntimeWorktreeAgentSource[] = []
@@ -4,6 +4,7 @@ import {
type AgentStatusEntry,
type AgentStatusState
} from '../../../../shared/agent-status-types'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
import type {
ActivityEventState,
ActivityHookLiveAgentState,
@@ -20,6 +21,19 @@ export function freshActivityLiveAgentState(
entry: AgentStatusEntry,
now: number
): ActivityLiveAgentState | null {
if (entry.executionObservation) {
const presentation = resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS)
if (
presentation.state !== 'working' &&
presentation.state !== 'blocked' &&
presentation.state !== 'waiting'
) {
return null
}
return presentation.state === 'working' && entry.workingMode === 'monitoring'
? 'monitoring'
: presentation.state
}
if (
!isActivityHookLiveAgentState(entry.state) ||
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
@@ -32,6 +32,7 @@ import {
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
import type { BrowserWorkspace } from '../../../../shared/browser-workspace-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
@@ -158,8 +159,16 @@ function buildLiveAgentStatusPaneIdsByTabId(
if (!paneId) {
continue
}
const presentation = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS)
: null
if (presentation?.state === 'unverifiable') {
stalePaneIds.add(paneId)
continue
}
if (
entry.restoredUnconfirmed !== true &&
!presentation &&
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
stalePaneIds.add(paneId)
@@ -15,6 +15,7 @@ import {
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
import {
createWorktreeTabBucketProjection,
@@ -119,13 +120,18 @@ export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshot
const shouldDecay =
!isFresh &&
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
const state = entry.executionObservation
? resolveAgentStatusPresentation(entry, args.now, AGENT_STATUS_STALE_AFTER_MS).state
: shouldDecay
? 'idle'
: entry.state
currentAgents.set(paneKey, {
row: {
paneKey,
entry,
tab: owner.tab,
agentType: entry.agentType ?? 'unknown',
state: shouldDecay ? 'idle' : entry.state,
state,
startedAt: agentStartedAt(entry)
},
worktreeId: owner.worktreeId
@@ -11,6 +11,17 @@ type ReactElementLike = {
props: Record<string, unknown>
}
type NoteTargetHarness = {
paneKey: string
tabId: string
leafId: string
agentType: TuiAgent
tabTitle: string
status: 'eligible' | 'disabled'
disabledReason?: string
executionCaveat?: string
}
const TAB_A = 'tab-a'
const TAB_B = 'tab-b'
const LEAF_A = '11111111-1111-4111-8111-111111111111'
@@ -28,15 +39,7 @@ const harness = vi.hoisted(() => ({
track: vi.fn(),
toastMessage: vi.fn(),
worktreeAgentRows: [] as DashboardAgentRowData[],
noteTargets: [] as {
paneKey: string
tabId: string
leafId: string
agentType: TuiAgent
tabTitle: string
status: 'eligible' | 'disabled'
disabledReason?: string
}[],
noteTargets: new Array<NoteTargetHarness>(),
now: 600_000
}))
@@ -392,6 +395,29 @@ describe('ReviewNotesSendMenuContent', () => {
expect(collectText(items[1])).toContain('Codex')
})
it('shows an execution caveat while keeping an observed route eligible', () => {
harness.noteTargets = [
{
paneKey: makePaneKey(TAB_A, LEAF_A),
tabId: TAB_A,
leafId: LEAF_A,
agentType: 'claude',
tabTitle: 'Claude',
status: 'eligible',
executionCaveat: 'Execution could not be verified'
}
]
setStore({
tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Claude' })] },
terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') }
})
const item = findByType(render(), 'DropdownMenuItem')
expect(item.props.disabled).toBe(false)
expect(item.props.title).toBe('Execution could not be verified')
expect(collectText(item)).toContain('Execution could not be verified')
})
it('orders send targets by the current worktree agent rows and shows status timing', () => {
const paneKeyA = makePaneKey(TAB_A, LEAF_A)
const paneKeyB = makePaneKey(TAB_B, LEAF_B)
@@ -249,9 +249,11 @@ function AgentTargetMenuItem({
const state = agentRowDotState(agent?.state ?? 'idle', agent?.entry.workingMode)
const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null
const disabledReason = target.status === 'disabled' ? target.disabledReason : undefined
const executionCaveat = target.executionCaveat
const secondaryParts = [
agentStateLabel(state),
...(timeAgo ? [timeAgo] : []),
...(executionCaveat ? [executionCaveat] : []),
...(tabTitle ? [tabTitle] : [])
]
return (
@@ -261,7 +263,7 @@ function AgentTargetMenuItem({
// Why: surface the ineligibility reason (permission/stale/no-terminal) as a
// hover tooltip rather than inline text, matching DashboardAgentRow's
// title-attribute treatment of the same disabledReason.
title={disabledReason}
title={disabledReason ?? executionCaveat}
className="min-w-[240px] gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
{/* Why: the ancestor's actionable disabled reason must win on every hit area. */}
@@ -269,7 +271,7 @@ function AgentTargetMenuItem({
state={state}
size="sm"
className="shrink-0"
title={disabledReason ? null : undefined}
title={disabledReason || executionCaveat ? null : undefined}
/>
<AgentIcon agent={agentTypeToIconAgent(target.agentType ?? agent?.agentType)} size={14} />
<span className="grid min-w-0 flex-1 text-left">
@@ -1,5 +1,6 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
export type PetAnimationName =
| 'idle'
@@ -47,15 +48,19 @@ function agentStateAnimation(
let hasDone = false
for (const entry of entries) {
if (!isExplicitAgentStatusFresh(entry, now, staleAfterMs)) {
const presentation = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, staleAfterMs)
: null
if (!presentation && !isExplicitAgentStatusFresh(entry, now, staleAfterMs)) {
continue
}
if (entry.state === 'blocked' || entry.state === 'waiting') {
const state = presentation?.state ?? entry.state
if (state === 'blocked' || state === 'waiting') {
return 'waiting'
}
if (entry.state === 'working' && entry.workingMode !== 'monitoring') {
if (state === 'working' && entry.workingMode !== 'monitoring') {
hasWorking = true
} else if (entry.state === 'done') {
} else if (state === 'done') {
hasDone = true
}
}
@@ -0,0 +1,136 @@
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { isSyntheticAgentPermissionTitle } from '../../../../shared/synthetic-agent-title'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { PaneInput } from './smart-attention'
export type TabPaneInputSources = {
entriesByTabId: ReadonlyMap<string, AgentStatusEntry[]>
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
}
export function collectTabPaneInputs(
tab: Pick<TerminalTab, 'id' | 'title'>,
worktreeLastActivityAt: number,
sources: TabPaneInputSources,
now: number
): PaneInput[] {
const panes: PaneInput[] = []
const hasLivePty = tabHasLivePty(sources.ptyIdsByTabId, tab.id)
const hookLeafIds = new Set<string>()
const permissionHookLeafIds = new Set<string>()
for (const entry of sources.entriesByTabId.get(tab.id) ?? []) {
panes.push({ kind: 'hook', entry, hasLivePty })
const leafId = leafIdFromPaneKey(entry.paneKey)
if (leafId !== null) {
permissionHookLeafIds.add(leafId)
}
if (entry.executionObservation && leafId !== null) {
hookLeafIds.add(leafId)
continue
}
if (
!entry.restoredUnconfirmed &&
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
continue
}
if (leafId !== null) {
hookLeafIds.add(leafId)
}
}
if (!hasLivePty) {
return panes
}
const paneTitles = sources.runtimePaneTitlesByTabId[tab.id]
if (!paneTitles || Object.keys(paneTitles).length === 0) {
const coveredLeafIds = isSyntheticAgentPermissionTitle(tab.title)
? permissionHookLeafIds
: hookLeafIds
if (coveredLeafIds.size === 0) {
panes.push({
kind: 'title',
status: classifyTitleActivity(tab.title),
worktreeLastActivityAt
})
}
return panes
}
const tabLayout = sources.terminalLayoutsByTabId?.[tab.id]
const paneTitleEntries = Object.entries(paneTitles)
for (const [runtimePaneId, title] of paneTitleEntries) {
const coveredLeafIds = isSyntheticAgentPermissionTitle(title)
? permissionHookLeafIds
: hookLeafIds
const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId)
const hasSingleUnmappedHook =
leafId === null && coveredLeafIds.size === 1 && paneTitleEntries.length === 1
if ((leafId !== null && coveredLeafIds.has(leafId)) || hasSingleUnmappedHook) {
continue
}
panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt })
}
return panes
}
function leafIdFromPaneKey(paneKey: string): string | null {
const separator = paneKey.lastIndexOf(':')
return separator === -1 ? null : paneKey.slice(separator + 1) || null
}
export function buildExplicitEntriesByTabId(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
migrationUnsupportedByPtyId?: Record<string, MigrationUnsupportedPtyEntry>
): Map<string, AgentStatusEntry[]> {
const byTab = new Map<string, AgentStatusEntry[]>()
const pushEntry = (entry: AgentStatusEntry): void => {
const parsed = parsePaneKey(entry.paneKey)
if (!parsed) {
return
}
const bucket = byTab.get(parsed.tabId)
if (bucket) {
bucket.push(entry)
} else {
byTab.set(parsed.tabId, [entry])
}
}
for (const entry of Object.values(agentStatusByPaneKey ?? {})) {
pushEntry(entry)
}
for (const entry of Object.values(migrationUnsupportedByPtyId ?? {})) {
const agentEntry = migrationUnsupportedToAgentStatusEntry(entry)
if (agentEntry) {
pushEntry(agentEntry)
}
}
return byTab
}
export function buildExplicitEntriesByWorktreeId(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
): Map<string, AgentStatusEntry[]> {
const byWorktree = new Map<string, AgentStatusEntry[]>()
for (const entry of Object.values(agentStatusByPaneKey ?? {})) {
if (!entry.worktreeId || !parsePaneKey(entry.paneKey)) {
continue
}
const bucket = byWorktree.get(entry.worktreeId)
if (bucket) {
bucket.push(entry)
} else {
byWorktree.set(entry.worktreeId, [entry])
}
}
return byWorktree
}
@@ -63,7 +63,8 @@ function makeEntry(overrides: Partial<AgentStatusEntry> & { paneKey: string }):
stateHistory: overrides.stateHistory ?? [],
interrupted: overrides.interrupted,
sessionBoundary: overrides.sessionBoundary,
restoredUnconfirmed: overrides.restoredUnconfirmed
restoredUnconfirmed: overrides.restoredUnconfirmed,
executionObservation: overrides.executionObservation
}
}
@@ -126,6 +127,48 @@ describe('mostRecentAttentionInHistory', () => {
})
})
describe('execution observation projection', () => {
it('ranks an observed exited Working attachment as uncertainty, not live work', () => {
const entry = makeEntry({
paneKey: paneKey('tab-1', LEAF_1),
executionObservation: {
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: NOW,
inventoryCoverage: 'complete',
verdict: 'exited'
}
})
expect(resolveAttention([hookPane(entry, true)], NOW)).toEqual({
cls: 4,
attentionTimestamp: NOW
})
})
it('keeps an old observed waiting attachment in the needs-input class', () => {
const entry = makeEntry({
paneKey: paneKey('tab-1', LEAF_1),
state: 'waiting',
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
executionObservation: {
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
inventoryCoverage: 'partial',
verdict: 'unverifiable'
}
})
expect(resolveAttention([hookPane(entry, true)], NOW)).toMatchObject({
cls: 1,
cause: 'waiting'
})
})
})
describe('resolveAttention', () => {
it('returns idle when there are no panes', () => {
expect(resolveAttention([], NOW)).toEqual(IDLE)
@@ -317,14 +360,18 @@ describe('resolveAttention', () => {
})
})
it('skips stale entries (updatedAt older than the freshness window)', () => {
it('retains a pending question beyond the status freshness window', () => {
const entry = makeEntry({
paneKey: 't:1',
state: 'blocked',
stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000,
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000
})
expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE)
expect(resolveAttention([hookPane(entry)], NOW)).toEqual({
cls: 1,
attentionTimestamp: NOW - AGENT_STATUS_STALE_AFTER_MS - 60_000,
cause: 'blocked'
})
})
it('takes the most attention-demanding class across multiple panes', () => {
@@ -1,10 +1,6 @@
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { isSyntheticAgentPermissionTitle } from '../../../../shared/synthetic-agent-title'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
import type { AgentStatus } from '../../../../shared/agent-detection'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
@@ -16,6 +12,19 @@ import {
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
import {
collectTabPaneInputs,
buildExplicitEntriesByTabId,
buildExplicitEntriesByWorktreeId,
type TabPaneInputSources
} from './smart-attention-pane-inputs'
export {
collectTabPaneInputs,
buildExplicitEntriesByTabId,
buildExplicitEntriesByWorktreeId
} from './smart-attention-pane-inputs'
export type { TabPaneInputSources } from './smart-attention-pane-inputs'
/**
* Ordinal class for the "Smart" sort. Lower number = more attention-demanding.
@@ -57,7 +66,6 @@ export type WorktreeAttention = {
}
export const IDLE: WorktreeAttention = { cls: 5, attentionTimestamp: 0 }
export function hasFreshAttributedAgentStatus(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
now: number,
@@ -66,7 +74,15 @@ export function hasFreshAttributedAgentStatus(
const freshUnstampedTabIds = new Set<string>()
for (const entry of Object.values(agentStatusByPaneKey ?? {})) {
const parsed = parsePaneKey(entry.paneKey)
if (parsed === null || !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
const presentation = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS)
: null
if (
parsed === null ||
(presentation?.state === 'unverifiable'
? true
: !presentation && !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS))
) {
continue
}
if (entry.worktreeId) {
@@ -82,7 +98,6 @@ export function hasFreshAttributedAgentStatus(
tabs.some((tab) => freshUnstampedTabIds.has(tab.id))
)
}
/**
* Return the timestamp of the most recent `done`/`blocked`/`waiting` history row, ignoring
* interrupted `done` rows (Ctrl+C). Returns `null` when no qualifying row exists.
@@ -106,7 +121,6 @@ export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]):
}
return max > 0 ? max : null
}
/**
* One pane's contribution to a worktree's attention class. Fresh hook entries win; hookless
* panes fall back to the title heuristic (design doc Edge case 9). Authority is per-pane, not per-worktree.
@@ -117,7 +131,6 @@ export type PaneInput =
| { kind: 'hook'; entry: AgentStatusEntry; hasLivePty: boolean }
// Why: TerminalTab has no per-tab lastActivityAt; the worktree-level value suffices for cross-worktree ordering.
| { kind: 'title'; status: AgentStatus | null; worktreeLastActivityAt: number }
/**
* Resolve a worktree's class + attention timestamp from its panes' inputs.
* A stale hook entry lands in Class 4 or 5 depending on live-PTY evidence; the worktree falls to
@@ -136,7 +149,23 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
if (pane.kind === 'hook') {
const entry = pane.entry
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
const presentation = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS)
: null
if (presentation?.state === 'unverifiable') {
const observedAt =
entry.executionObservation?.observedAt ?? agentStatusEvidenceObservedAt(entry)
if (
Number.isFinite(observedAt) &&
(4 < bestCls || (bestCls === 4 && observedAt > bestTs))
) {
bestCls = 4
bestTs = observedAt
bestCause = undefined
}
continue
}
if (!presentation && !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
// Why: a pane Orca still holds a PTY for outranks a genuinely empty one — the user may
// know why it went quiet (a long build), which Orca never can. It never outranks a
// reporting pane, and it never claims the agent finished.
@@ -158,11 +187,12 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
continue
}
if (entry.state === 'blocked' || entry.state === 'waiting') {
const effectiveState = presentation?.state ?? entry.state
if (effectiveState === 'blocked' || effectiveState === 'waiting') {
cls = 1
ts = entry.stateStartedAt
cause = entry.state
} else if (entry.state === 'done') {
cause = effectiveState
} else if (effectiveState === 'done') {
// Why: null covers interrupted `done` (Ctrl+C — user is finished with it) and idle session
// boundaries; neither is attention.
const completedAt = agentEntryCompletionAt(entry)
@@ -176,7 +206,7 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
}
cls = 2
ts = completedAt
} else {
} else if (effectiveState === 'working') {
// working
cls = 3
// Why: sort Class 3 by most recent prior attention so a just-started turn outranks one working for an hour.
@@ -189,6 +219,8 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
} else {
ts = prior
}
} else {
continue
}
} else {
// Title-heuristic fallback: only fires for panes with no fresh hook entry.
@@ -219,147 +251,6 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
: { cls: bestCls, attentionTimestamp: bestTs }
}
/**
* Build a `tabId → entries[]` index over `agentStatusByPaneKey`, keyed by the paneKey's
* `tabId` prefix. Built once per sort so each worktree's resolution is O(T), not a full-map scan.
*/
export function buildExplicitEntriesByTabId(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined,
migrationUnsupportedByPtyId?: Record<string, MigrationUnsupportedPtyEntry>
): Map<string, AgentStatusEntry[]> {
const byTab = new Map<string, AgentStatusEntry[]>()
const pushEntry = (entry: AgentStatusEntry): void => {
const parsed = parsePaneKey(entry.paneKey)
// Why: skip malformed/legacy-numeric paneKeys rather than bucketing unroutable rows under a tab.
if (!parsed) {
return
}
const bucket = byTab.get(parsed.tabId)
if (bucket) {
bucket.push(entry)
} else {
byTab.set(parsed.tabId, [entry])
}
}
for (const entry of Object.values(agentStatusByPaneKey ?? {})) {
pushEntry(entry)
}
for (const entry of Object.values(migrationUnsupportedByPtyId ?? {})) {
const agentEntry = migrationUnsupportedToAgentStatusEntry(entry)
if (agentEntry) {
pushEntry(agentEntry)
}
}
return byTab
}
function buildExplicitEntriesByWorktreeId(
agentStatusByPaneKey: Record<string, AgentStatusEntry> | undefined
): Map<string, AgentStatusEntry[]> {
const byWorktree = new Map<string, AgentStatusEntry[]>()
for (const entry of Object.values(agentStatusByPaneKey ?? {})) {
if (!entry.worktreeId || !parsePaneKey(entry.paneKey)) {
continue
}
const bucket = byWorktree.get(entry.worktreeId)
if (bucket) {
bucket.push(entry)
} else {
byWorktree.set(entry.worktreeId, [entry])
}
}
return byWorktree
}
/**
* Extract the stable leaf id from a `${tabId}:${leafId}` paneKey.
*/
function leafIdFromPaneKey(paneKey: string): string | null {
return parsePaneKey(paneKey)?.leafId ?? null
}
/** Renderer state a single tab's panes are resolved from. */
export type TabPaneInputSources = {
entriesByTabId: ReadonlyMap<string, AgentStatusEntry[]>
ptyIdsByTabId: Record<string, string[]>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
}
/**
* One terminal tab's contribution to an attention resolution: its hook entries, plus the
* title heuristic for panes no fresh hook covers. Gated on `tabHasLivePty` so a slept tab's
* stale working-pattern title can't leak through.
*/
export function collectTabPaneInputs(
tab: Pick<TerminalTab, 'id' | 'title'>,
worktreeLastActivityAt: number,
sources: TabPaneInputSources,
now: number
): PaneInput[] {
const panes: PaneInput[] = []
const hasLivePty = tabHasLivePty(sources.ptyIdsByTabId, tab.id)
// Why: leaves covered by a hook entry skip the title fallback so we don't double-count them.
const hookLeafIds = new Set<string>()
// Stale hooks still suppress one-shot permission titles, matching worktree and tab status dots.
const permissionHookLeafIds = new Set<string>()
for (const entry of sources.entriesByTabId.get(tab.id) ?? []) {
panes.push({ kind: 'hook', entry, hasLivePty })
const leafId = leafIdFromPaneKey(entry.paneKey)
if (leafId !== null) {
permissionHookLeafIds.add(leafId)
}
// Why: restored rows own their co-restored title without asserting live state.
if (
!entry.restoredUnconfirmed &&
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
continue
}
if (leafId !== null) {
hookLeafIds.add(leafId)
}
}
// Why: runtimePaneTitlesByTabId survives sleep, so a slept tab's stale working-pattern title would leak in without this gate.
if (!hasLivePty) {
return panes
}
const paneTitles = sources.runtimePaneTitlesByTabId[tab.id]
if (!paneTitles || Object.keys(paneTitles).length === 0) {
const coveredLeafIds = isSyntheticAgentPermissionTitle(tab.title)
? permissionHookLeafIds
: hookLeafIds
if (coveredLeafIds.size === 0) {
// Why: unmounted tabs (restored-but-unvisited) expose only the legacy tab title.
panes.push({
kind: 'title',
status: classifyTitleActivity(tab.title),
worktreeLastActivityAt
})
}
return panes
}
// Why: split-pane tabs host multiple agents, one title each; mirrors getWorkingAgentsPerWorktree precedence.
const tabLayout = sources.terminalLayoutsByTabId?.[tab.id]
const paneTitleEntries = Object.entries(paneTitles)
for (const [runtimePaneId, title] of paneTitleEntries) {
const coveredLeafIds = isSyntheticAgentPermissionTitle(title)
? permissionHookLeafIds
: hookLeafIds
const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId)
const hasSingleUnmappedHook =
leafId === null && coveredLeafIds.size === 1 && paneTitleEntries.length === 1
if ((leafId !== null && coveredLeafIds.has(leafId)) || hasSingleUnmappedHook) {
continue
}
panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt })
}
return panes
}
/**
* Build the per-worktree attention map consumed by the smart comparator.
* Hook authority is per-pane; panes without a fresh hook fall back to the title heuristic,
@@ -390,7 +281,6 @@ export function buildAttentionByWorktree(
terminalLayoutsByTabId
}
const result = new Map<string, WorktreeAttention>()
for (const worktree of worktrees) {
const tabs = tabsByWorktree?.[worktree.id] ?? []
// Why: hook stamps can precede tab mirroring; once mirrored, live tab ownership wins so both worktrees aren't promoted.
@@ -103,6 +103,36 @@ describe('a stale entry on a pane Orca still holds', () => {
expect(rows[0].state).toBe('unverifiable')
expect(getCompactAgentSecondary(rows[0], NOW)).toBe('No update in 34m')
})
it('uses exact host evidence instead of treating a recent exited row as Working', () => {
const observed = {
executionId: 'exec-1',
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: NOW,
inventoryCoverage: 'complete' as const,
verdict: 'exited' as const
}
expect(rowState(entry({ updatedAt: NOW, executionObservation: observed }), LIVE)).toBe(
'unverifiable'
)
})
it('keeps an old waiting row visible when its exact attachment is uncertain', () => {
const observed = {
executionId: 'exec-1',
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: NOW - SILENT_FOR_MS,
inventoryCoverage: 'partial' as const,
verdict: 'unverifiable' as const
}
expect(rowState(entry({ state: 'waiting', executionObservation: observed }), LIVE)).toBe(
'waiting'
)
})
})
describe('negative controls', () => {
@@ -11,6 +11,7 @@ import {
type AgentStatusEntry,
type AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
export type WorktreeAgentActivitySummary = {
hasPermission: boolean
@@ -123,7 +124,12 @@ function getWorktreeAgentActivitySummaries(
addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId)
continue
}
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
const presentation = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS)
: null
const effectiveState = presentation?.state ?? entry.state
const isUnverifiable = effectiveState === 'unverifiable'
if (!presentation && !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
// Why: staleness ends this row's authority but not the pane's identity — see
// `stalePaneIdsByTabId`. Dropping both let Orca's self-authored permission title outlive
// the row it came from and pin the card to a question nobody was asking.
@@ -131,10 +137,19 @@ function getWorktreeAgentActivitySummaries(
continue
}
addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId)
if (entry.state === 'done') {
if (isUnverifiable) {
// Keep the pane covered by first-party evidence and suppress title
// heuristics; uncertainty is not an idle or completed state.
addStalePaneId(summary, paneIdentity.tabId, paneIdentity.paneId)
continue
}
if (effectiveState === 'idle') {
continue
}
if (effectiveState === 'done') {
addParentPaneId(summary, orchestration, worktreeId, tabIdToWorktreeId)
}
applyLiveAgentState(summary, entry)
applyLiveAgentState(summary, { ...entry, state: effectiveState })
}
for (const unsupported of Object.values(state.migrationUnsupportedByPtyId ?? {})) {
@@ -28,6 +28,7 @@ import {
} from './worktree-agent-row-fallback-tab'
import { resolveRowAgentType } from './worktree-agent-row-type'
import { entryWithRuntimeOrchestration } from './worktree-agent-row-orchestration'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
if (!node) {
@@ -180,13 +181,18 @@ export function buildWorktreeAgentRows(args: {
rowEntry.state === 'blocked' ||
rowEntry.state === 'waiting')
const startedAt = effectiveWorktreeAgentRowStartedAt(rowEntry)
const projectedState = rowEntry.executionObservation
? resolveAgentStatusPresentation(rowEntry, args.now, AGENT_STATUS_STALE_AFTER_MS).state
: shouldDecay
? resolveDecayedAgentRowState(rowEntry, hasLivePty)
: rowEntry.state
rows.push({
paneKey: rowEntry.paneKey,
entry: rowEntry,
tab,
agentType: resolveRowAgentType(rowEntry, tab),
rowSource: 'live',
state: shouldDecay ? resolveDecayedAgentRowState(rowEntry, hasLivePty) : rowEntry.state,
state: projectedState,
startedAt
})
rows.push(...buildSubagentChildRows({ parentEntry: rowEntry, tab, parentIsFresh: isFresh }))
@@ -222,6 +228,11 @@ export function buildWorktreeAgentRows(args: {
const shouldDecay =
!isFresh &&
(rowEntry.state === 'working' || rowEntry.state === 'blocked' || rowEntry.state === 'waiting')
const projectedState = rowEntry.executionObservation
? resolveAgentStatusPresentation(rowEntry, args.now, AGENT_STATUS_STALE_AFTER_MS).state
: shouldDecay
? resolveDecayedAgentRowState(rowEntry, tabHasLivePty(ptyIdsByTabId, tab.id))
: rowEntry.state
rows.push({
paneKey: rowEntry.paneKey,
entry: rowEntry,
@@ -230,9 +241,7 @@ export function buildWorktreeAgentRows(args: {
rowSource: 'live',
// Why: this row's tab is synthesized because no tab for it exists in this renderer,
// so there is no live-PTY evidence to hold — the decay destination is always `idle`.
state: shouldDecay
? resolveDecayedAgentRowState(rowEntry, tabHasLivePty(ptyIdsByTabId, tab.id))
: rowEntry.state,
state: projectedState,
startedAt
})
rows.push(...buildSubagentChildRows({ parentEntry: rowEntry, tab, parentIsFresh: isFresh }))
@@ -13,6 +13,7 @@ import type {
WorkspaceSpaceWorktree
} from '../../../../shared/workspace-space-types'
import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
export type WorkspaceSpaceSortKey = 'size' | 'name' | 'repo' | 'activity'
export type WorkspaceSpaceSortDirection = 'asc' | 'desc'
@@ -67,10 +68,6 @@ function getPaneKeyTabId(paneKey: string): string | null {
return paneKey.slice(0, separatorIndex)
}
function isActiveAgentState(entry: Pick<AgentStatusEntry, 'state'>): boolean {
return entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting'
}
function countTitleActiveAgentsForTab(
tab: Pick<TerminalTab, 'id' | 'title'>,
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
@@ -106,10 +103,20 @@ export function countWorkspaceSpaceActiveAgents({
let count = 0
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
if (!isActiveAgentState(entry)) {
const effectiveState = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS).state
: entry.state
if (
effectiveState !== 'working' &&
effectiveState !== 'blocked' &&
effectiveState !== 'waiting'
) {
continue
}
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
if (
!entry.executionObservation &&
!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
) {
continue
}
const tabId = getPaneKeyTabId(entry.paneKey || paneKey)
@@ -86,6 +86,27 @@ describe('resolveTerminalTabActivityStatus', () => {
).toBe('working')
})
it('does not keep a Working glyph after exact host evidence says the attachment exited', () => {
const working = entry(FIRST_LEAF_ID, 'working', {
executionObservation: {
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: NOW,
inventoryCoverage: 'complete',
verdict: 'exited'
}
})
expect(
resolveTerminalTabActivityStatus({
tab: TAB,
agentStatusByPaneKey: { [working.paneKey]: working },
ptyIdsByTabId: LIVE_PTY
})
).toBe('active')
})
it.each(['tab', 'pane'] as const)(
'keeps native permission %s titles after hook freshness expires',
(surface) => {
@@ -8,6 +8,7 @@ import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
@@ -72,9 +73,17 @@ function getTerminalTabActivityFlags(
flags.paneIds.add(identity.paneId)
continue
}
const presentation = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS)
: null
// Why: stale hook entries (>30m) are not authority; a slept/abandoned pane
// must not keep a tab spinning. Same freshness gate as the sidebar.
if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
// must not keep a tab spinning. Same freshness gate as the sidebar. Host
// evidence can retain pending attention, but never turns stale Working back
// into a spinner.
if (
(presentation && presentation.state === 'unverifiable') ||
(!presentation && !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS))
) {
// Stale identity suppresses Orca's one-shot permission label without suppressing native titles.
getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId).stalePaneIds.add(
identity.paneId
@@ -84,9 +93,10 @@ function getTerminalTabActivityFlags(
const flags = getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId)
flags.paneIds.add(identity.paneId)
if (entry.state === 'blocked' || entry.state === 'waiting') {
const effectiveState = presentation?.state ?? entry.state
if (effectiveState === 'blocked' || effectiveState === 'waiting') {
flags.hasPermission = true
} else if (entry.state === 'working') {
} else if (effectiveState === 'working') {
if (entry.workingMode === 'monitoring') {
flags.hasLiveMonitoring = true
} else {
@@ -95,7 +105,7 @@ function getTerminalTabActivityFlags(
} else if (entry.interrupted === true) {
// Interrupted is encoded as done, so it must be checked first.
flags.hasInterrupted = true
} else if (entry.state === 'done') {
} else if (effectiveState === 'done') {
flags.hasLiveDone = true
}
}
@@ -187,6 +187,9 @@ export function createAgentStatusEventApplicator(args: {
const statusPayloadWithObservation = data.observation
? { ...statusPayloadWithProvenance, observation: data.observation }
: statusPayloadWithProvenance
const statusPayloadWithExecutionObservation = data.executionObservation
? { ...statusPayloadWithObservation, executionObservation: data.executionObservation }
: statusPayloadWithObservation
const identity = resolveAgentStatusIdentity({
existing: existingStatus
? {
@@ -224,7 +227,7 @@ export function createAgentStatusEventApplicator(args: {
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
const update: AgentStatusUpdate = {
paneKey,
payload: statusPayloadWithObservation,
payload: statusPayloadWithExecutionObservation,
terminalTitle,
timing: {
updatedAt: data.receivedAt,
@@ -19,6 +19,7 @@ import {
resolveTitleActivityLabel
} from '@/lib/pane-agent-evidence'
import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id'
import { resolveAgentStatusPresentation } from '../../../shared/agent-execution-observation'
const ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS = 3000
const ACTIVE_AGENT_TERMINAL_LIST_LIMIT = 200
@@ -95,8 +96,15 @@ export function getActiveAgentNoteTarget(
}
const entry = state.agentStatusByPaneKey?.[makePaneKey(noteTarget.tabId, noteTarget.leafId)]
if (entry && isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
return noteTarget
if (entry) {
if (entry.executionObservation) {
const presentation = resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS)
if (presentation.routeUsability === 'usable') {
return noteTarget
}
} else if (isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) {
return noteTarget
}
}
// Why: freshly opened agents can be idle before their first hook event. Use
// renderer title/launch hints only to show the option; runtime still verifies
@@ -3,11 +3,14 @@ import {
type AgentStatusEntry,
type AgentStatusState
} from '../../../shared/agent-status-types'
import type { AgentExecutionObservation } from '../../../shared/agent-execution-observation'
/** Row states: the hook-reported statuses plus the two Orca derives when an entry goes stale. */
export type AgentRowState = AgentStatusState | 'idle' | 'unverifiable'
type DecayInput = Pick<AgentStatusEntry, 'state' | 'restoredUnconfirmed'>
type DecayInput = Pick<AgentStatusEntry, 'state' | 'restoredUnconfirmed'> & {
executionObservation?: AgentExecutionObservation
}
/**
* Where a stale non-`done` entry decays to.
@@ -25,7 +28,9 @@ export function resolveDecayedAgentRowState(
entry: DecayInput,
hasLivePty: boolean
): 'idle' | 'unverifiable' {
return hasLivePty && entry.state !== 'done' && entry.restoredUnconfirmed !== true
return (entry.executionObservation || hasLivePty) &&
entry.state !== 'done' &&
entry.restoredUnconfirmed !== true
? 'unverifiable'
: 'idle'
}
@@ -24,6 +24,7 @@ export type NotesSendAgentTarget = {
tabTitle: string
status: 'eligible' | 'disabled'
disabledReason?: string
executionCaveat?: string
}
type AgentTitleEvidence = {
@@ -75,7 +76,8 @@ export function deriveNotesSendAgentTargets(
agentType: resolveNotesTargetAgentType(target.entry.agentType, target.tab.launchAgent),
tabTitle: target.tab.title,
status: target.status,
...(target.disabledReason ? { disabledReason: target.disabledReason } : {})
...(target.disabledReason ? { disabledReason: target.disabledReason } : {}),
...(target.executionCaveat ? { executionCaveat: target.executionCaveat } : {})
})
)
@@ -75,6 +75,72 @@ describe('title agent identity facets', () => {
})
describe('resolvePaneAgentActivity', () => {
const liveExecutionObservation = {
executionId: 'exec-1',
hostId: 'local' as const,
hostEpoch: 'epoch-1',
captureRevision: 4,
observedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
inventoryCoverage: 'complete' as const,
verdict: 'live' as const
}
it('retains a pending interaction after the display window when the attachment is observed', () => {
const decision = resolvePaneAgentActivity({
explicitEntry: entry({
state: 'waiting',
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
executionObservation: liveExecutionObservation
}),
liveTitle: null,
hasLivePty: true,
now: NOW
})
expect(decision).toMatchObject({
hookState: 'waiting',
source: 'hook',
executionVerdict: 'live',
pendingInteraction: true,
executionConfidence: 'authoritative'
})
})
it('does not replace a stale observed working row with a title guess', () => {
const decision = resolvePaneAgentActivity({
explicitEntry: entry({
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
executionObservation: liveExecutionObservation
}),
liveTitle: '⠋ running the tests',
hasLivePty: true,
now: NOW
})
expect(decision).toMatchObject({
hookState: null,
source: 'none',
titleStatus: null,
executionVerdict: 'live',
executionConfidence: 'uncertain'
})
})
it('does not expose Working when host evidence says the attachment exited', () => {
const decision = resolvePaneAgentActivity({
explicitEntry: entry({
executionObservation: { ...liveExecutionObservation, verdict: 'exited' }
}),
liveTitle: null,
hasLivePty: true,
now: NOW
})
expect(decision.hookState).toBeNull()
expect(decision.source).toBe('none')
expect(decision.executionVerdict).toBe('exited')
})
it('reports a fresh hook row as the authoritative source and keeps the title layer visible', () => {
const decision = resolvePaneAgentActivity({
explicitEntry: entry({ state: 'waiting' }),
+37 -4
View File
@@ -9,6 +9,7 @@ import {
type AgentStatusState,
type AgentType
} from '../../../shared/agent-status-types'
import { resolveAgentStatusPresentation } from '../../../shared/agent-execution-observation'
// Why: explicit agent status entries (from hook-based reports) can go stale if
// the agent process exits without sending a final update. This helper lets
@@ -23,14 +24,17 @@ export function isExplicitAgentStatusFresh(
| 'mirroredEvidenceReceivedAt'
| 'restoredUnconfirmed'
| 'structuredHostOwned'
>,
| 'executionObservation'
> & { state?: AgentStatusEntry['state'] },
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 &&
(entry.structuredHostOwned === true ||
(entry.state === 'blocked' ||
entry.state === 'waiting' ||
entry.structuredHostOwned === true ||
now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs)
)
}
@@ -80,6 +84,9 @@ export type AgentActivityDecision = {
confidence: 'authoritative' | 'fallback'
/** True when the only claim is a title without live-PTY proof — liveness-gated consumers must treat it as absent. */
livePtyRequired: boolean
executionVerdict?: 'live' | 'unverifiable' | 'exited'
pendingInteraction?: boolean
executionConfidence?: 'authoritative' | 'uncertain' | 'legacy'
}
export type ResolvePaneAgentActivityInput = {
@@ -92,9 +99,16 @@ export type ResolvePaneAgentActivityInput = {
export function resolvePaneAgentActivity(
input: ResolvePaneAgentActivityInput
): AgentActivityDecision {
const presentation = input.explicitEntry
? resolveAgentStatusPresentation(input.explicitEntry, input.now, AGENT_STATUS_STALE_AFTER_MS)
: null
const freshEntry =
input.explicitEntry &&
isExplicitAgentStatusFresh(input.explicitEntry, input.now, AGENT_STATUS_STALE_AFTER_MS)
(input.explicitEntry.executionObservation
? presentation?.pendingInteraction ||
(presentation?.state !== 'unverifiable' &&
isExplicitAgentStatusFresh(input.explicitEntry, input.now, AGENT_STATUS_STALE_AFTER_MS))
: isExplicitAgentStatusFresh(input.explicitEntry, input.now, AGENT_STATUS_STALE_AFTER_MS))
? input.explicitEntry
: null
const titleStatus = input.liveTitle !== null ? detectAgentStatusFromTitle(input.liveTitle) : null
@@ -105,7 +119,26 @@ export function resolvePaneAgentActivity(
titleStatus,
source: 'hook',
confidence: 'authoritative',
livePtyRequired: false
livePtyRequired: false,
...(presentation?.executionVerdict
? { executionVerdict: presentation.executionVerdict }
: {}),
...(presentation?.pendingInteraction ? { pendingInteraction: true } : {}),
...(presentation?.confidence && presentation.confidence !== 'legacy'
? { executionConfidence: presentation.confidence }
: {})
}
}
if (input.explicitEntry?.executionObservation) {
return {
hookState: null,
hookAgentType: undefined,
titleStatus: null,
source: 'none',
confidence: 'authoritative',
livePtyRequired: false,
executionVerdict: input.explicitEntry.executionObservation.verdict,
executionConfidence: 'uncertain'
}
}
if (titleStatus !== null) {
@@ -95,6 +95,39 @@ function deriveLivePtyIdsByTabId(
}
describe('running agent send targets', () => {
it('keeps a stale row targetable from an exact live attachment', () => {
const paneKey = makePaneKey(TAB_ID, LEFT_LEAF_ID)
const targets = deriveRunningAgentSendTargets(
state({
agentStatusByPaneKey: {
[paneKey]: {
...entry(paneKey, 'working', NOW - 31 * 60 * 1000),
executionObservation: {
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: NOW - 31 * 60 * 1000,
inventoryCoverage: 'complete',
verdict: 'live'
}
}
},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEFT_LEAF_ID },
activeLeafId: LEFT_LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEFT_LEAF_ID]: 'pty-left' }
}
}
}),
WORKTREE_ID,
NOW
)
expect(targets[0]).toMatchObject({ status: 'eligible', ptyId: 'pty-left' })
})
it('marks fresh done agents with a leaf PTY as eligible', () => {
const paneKey = makePaneKey(TAB_ID, LEFT_LEAF_ID)
const targets = deriveRunningAgentSendTargets(
@@ -205,7 +238,7 @@ describe('running agent send targets', () => {
])
})
it('keeps stale agent status rows disabled when no live title proves the agent is sendable', () => {
it('retains a stale pending question while keeping notes-send disabled', () => {
const stalePaneKey = makePaneKey(TAB_ID, RIGHT_LEAF_ID)
const target = resolveRunningAgentSendTarget(
state({
@@ -230,7 +263,7 @@ describe('running agent send targets', () => {
paneKey: stalePaneKey,
ptyId: 'pty-right',
status: 'disabled',
disabledReason: 'Agent status is stale'
disabledReason: 'Agent needs permission'
})
})
+14 -2
View File
@@ -21,6 +21,8 @@ export type RunningAgentSendTarget = {
ptyId: string | null
status: 'eligible' | 'disabled'
disabledReason?: string
/** The route remains usable, but host observation could not certify residency. */
executionCaveat?: string
}
export function deriveRunningAgentSendTargets(
@@ -54,6 +56,7 @@ export function deriveRunningAgentSendTargets(
? layoutPtyId
: null
let disabledReason: string | undefined
let executionCaveat: string | undefined
// Why: the shared resolver gates hook freshness; a null hookState means the
// entry is stale (entries here always exist), and otherwise carries the
@@ -71,11 +74,19 @@ export function deriveRunningAgentSendTargets(
const liveTitleStatus = ptyId
? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title)
: null
if (entry.restoredUnconfirmed) {
const executionObservation = entry.executionObservation
if (executionObservation?.verdict === 'exited') {
disabledReason = 'Agent process exited'
} else if (entry.restoredUnconfirmed && !executionObservation) {
disabledReason = 'Agent status is stale'
} else if (decision.hookState === null) {
if (liveTitleStatus === 'permission') {
disabledReason = 'Agent needs permission'
} else if (executionObservation && ptyId) {
executionCaveat =
executionObservation.verdict === 'unverifiable'
? 'Execution could not be verified'
: undefined
} else if (liveTitleStatus === null) {
disabledReason = 'Agent status is stale'
}
@@ -95,7 +106,8 @@ export function deriveRunningAgentSendTargets(
entry,
ptyId,
status: disabledReason ? 'disabled' : 'eligible',
...(disabledReason ? { disabledReason } : {})
...(disabledReason ? { disabledReason } : {}),
...(executionCaveat ? { executionCaveat } : {})
})
}
@@ -1,10 +1,12 @@
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus,
type AgentStatusEntry
} from '../../../shared/agent-status-types'
import { resolveAgentStatusWorktreeId } from './agent-status-worktree-attribution'
import { resolveAgentStatusPresentation } from '../../../shared/agent-execution-observation'
type TerminalLikeTab = Pick<TerminalTab, 'id'>
type BrowserLikeTab = { id: string }
@@ -34,9 +36,13 @@ export function getLiveAgentStatusByWorktreeId(
tabsByWorktree: TabsByWorktree | null | undefined,
now: number
): Map<string, LiveAgentWorktreeStatus> {
const entries = Object.values(agentStatusByPaneKey ?? {}).filter((entry) =>
isFreshNonDoneAgentStatus(entry, now)
)
const entries = Object.values(agentStatusByPaneKey ?? {}).filter((entry) => {
if (entry.executionObservation) {
const state = resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS).state
return state === 'working' || state === 'blocked' || state === 'waiting'
}
return isFreshNonDoneAgentStatus(entry, now)
})
if (entries.length === 0) {
return new Map()
}
@@ -50,8 +56,11 @@ export function getLiveAgentStatusByWorktreeId(
for (const entry of entries) {
const worktreeId = resolveAgentStatusWorktreeId(entry, worktreeIdByTabId)
if (worktreeId) {
const effectiveState = entry.executionObservation
? resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS).state
: entry.state
const status =
entry.state === 'working'
effectiveState === 'working'
? entry.workingMode === 'monitoring'
? 'monitoring'
: 'working'
@@ -28,7 +28,8 @@ function serializeAgentStatusEntry(
interactivePrompt: entry.interactivePrompt ?? null,
lastAssistantMessage: entry.lastAssistantMessage ?? null,
lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null,
interrupted: entry.interrupted ?? null
interrupted: entry.interrupted ?? null,
executionObservation: entry.executionObservation ?? null
})
}
@@ -47,9 +47,17 @@ function withMirroredEvidenceReceipt(
existing: AgentStatusEntry | undefined,
now: number
): AgentStatusEntry {
const sameExecutionObservation =
existing?.executionObservation !== undefined &&
entry.executionObservation !== undefined &&
existing.executionObservation.executionId === entry.executionObservation.executionId &&
existing.executionObservation.hostEpoch === entry.executionObservation.hostEpoch &&
existing.executionObservation.captureRevision === entry.executionObservation.captureRevision
const receivedAt =
existing?.mirroredEvidenceReceivedAt !== undefined &&
agentStatusAuthorityObservedAt(existing) === agentStatusAuthorityObservedAt(entry)
agentStatusAuthorityObservedAt(existing) === agentStatusAuthorityObservedAt(entry) &&
(sameExecutionObservation ||
(existing?.executionObservation === undefined && entry.executionObservation === undefined))
? existing.mirroredEvidenceReceivedAt
: now
return { ...entry, mirroredEvidenceReceivedAt: receivedAt }
@@ -4,6 +4,7 @@ import {
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume'
import { agentExecutionObservationsEqual } from '../../../../shared/agent-execution-observation'
import type {
WebSessionTabsBatchContext,
WebSessionTabsBatchRecordKey,
@@ -59,6 +60,7 @@ export function agentStatusEntryEqual(
a.interrupted === b.interrupted &&
a.promptInteractionKey === b.promptInteractionKey &&
a.restoredUnconfirmed === b.restoredUnconfirmed &&
agentExecutionObservationsEqual(a.executionObservation, b.executionObservation) &&
agentProviderSessionsEqual(a.agentType, a.providerSession, b.providerSession) &&
sameAgentStateHistory(a.stateHistory, b.stateHistory)
)
@@ -8,6 +8,7 @@ import type {
ParsedAgentStatusPayload
} from '../../../../shared/agent-status-types'
import type { AgentStatusObservation } from '../../../../shared/agent-status-observation'
import type { AgentExecutionObservation } from '../../../../shared/agent-execution-observation'
import type {
AgentProviderSessionMetadata,
ResumableTuiAgent,
@@ -83,6 +84,8 @@ export type AgentLaunchConfigRegistryEntry = {
}
export type AgentStatusPayload = ParsedAgentStatusPayload & {
/** Host-owned evidence for the row's exact committed execution attachment. */
executionObservation?: AgentExecutionObservation
subagentObservation?: AgentStatusEntry['subagentObservation']
orchestration?: AgentStatusOrchestrationContext
promptInteractionKey?: string
@@ -264,6 +264,7 @@ export function buildAgentStatusLiveEntry(
...(metadata?.terminalResumeEligible === false
? { terminalResumeEligible: false as const }
: {}),
...(payload.executionObservation ? { executionObservation: payload.executionObservation } : {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
acceptedStatusSeq: (existing?.acceptedStatusSeq ?? 0) + 1,
@@ -8,6 +8,7 @@ import type {
SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { agentExecutionObservationsEqual } from '../../../../shared/agent-execution-observation'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import { recordHibernationBoundaryResolved } from '@/lib/agent-hibernation-pane-age'
import {
@@ -71,6 +72,10 @@ export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): Agen
existing?.state === 'done' &&
entry.state === 'done' &&
agentEntryCompletionAt(existing) !== agentEntryCompletionAt(entry)
const executionObservationChanged = !agentExecutionObservationsEqual(
existing?.executionObservation,
entry.executionObservation
)
const sortRelevantChange =
!existing ||
existing.state !== entry.state ||
@@ -78,7 +83,8 @@ export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): Agen
attributionChanged ||
commandCodeNewTurn ||
sameStateStateStartedAtChanged ||
sameStateDoneAttentionChanged
sameStateDoneAttentionChanged ||
executionObservationChanged
const doneRetentionFieldsChanged =
existing?.state === 'done' &&
entry.state === 'done' &&
@@ -4,6 +4,7 @@ import {
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { resolveAgentStatusPresentation } from '../../../../shared/agent-execution-observation'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
@@ -123,6 +124,13 @@ export function hasFreshIndexedLiveAgent(
const now = Date.now()
for (const tabId of tabIds) {
for (const entry of agentStatusesByTabId.get(tabId) ?? []) {
if (entry.executionObservation) {
const state = resolveAgentStatusPresentation(entry, now, AGENT_STATUS_STALE_AFTER_MS).state
if (state === 'working' || state === 'blocked' || state === 'waiting') {
return true
}
continue
}
if (
isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) &&
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
@@ -0,0 +1,351 @@
import type { ExecutionHostId } from './execution-host'
import type { AgentStatusRunVerdict } from './agent-status-run'
import type {
AgentExecutionAttachment,
AgentExecutionHostInventory,
AgentExecutionObservation,
AgentExecutionObservationSchedulerOptions
} from './agent-execution-observation'
type ActiveAttachment = AgentExecutionAttachment & { generation: number }
type PendingRequest = { resolve: (observation: AgentExecutionObservation) => void }
type HostScan = { controller: AbortController; promise: Promise<void> }
const DEFAULT_COALESCE_MS = 25
const DEFAULT_MAX_CONCURRENT_SCANS = 2
const DEFAULT_RETRY_BASE_MS = 250
const DEFAULT_RETRY_MAX_MS = 5_000
/** Coalesces attachment requests into bounded, host-scoped inventories. */
export class AgentExecutionObservationScheduler {
private readonly attachments = new Map<string, ActiveAttachment>()
private readonly pending = new Map<string, PendingRequest[]>()
private readonly scans = new Map<ExecutionHostId, HostScan>()
private readonly retryCountByHost = new Map<ExecutionHostId, number>()
private readonly captureRevisionByHost = new Map<ExecutionHostId, number>()
private readonly published = new Map<string, AgentExecutionObservation>()
private timer: ReturnType<typeof setTimeout> | null = null
private stopped = false
private readonly coalesceMs: number
private readonly maxConcurrentHostScans: number
private readonly retryBaseMs: number
private readonly retryMaxMs: number
private readonly now: () => number
private readonly schedule: (
callback: () => void,
delayMs: number
) => ReturnType<typeof setTimeout>
private readonly cancelSchedule: (handle: ReturnType<typeof setTimeout>) => void
constructor(
private readonly scanHost: (
hostId: ExecutionHostId,
signal: AbortSignal,
attachments: readonly ActiveAttachment[]
) => Promise<AgentExecutionHostInventory>,
private readonly publish: (observation: AgentExecutionObservation) => void,
options: AgentExecutionObservationSchedulerOptions = {}
) {
this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS
this.maxConcurrentHostScans = Math.max(
1,
Math.floor(options.maxConcurrentHostScans ?? DEFAULT_MAX_CONCURRENT_SCANS)
)
this.retryBaseMs = Math.max(1, options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS)
this.retryMaxMs = Math.max(this.retryBaseMs, options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS)
this.now = options.now ?? Date.now
this.schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs))
this.cancelSchedule = options.cancelSchedule ?? ((handle) => clearTimeout(handle))
}
register(attachment: AgentExecutionAttachment): void {
if (this.stopped) {
return
}
const previous = this.attachments.get(attachment.executionId)
const same =
previous &&
previous.hostId === attachment.hostId &&
previous.paneKey === attachment.paneKey &&
previous.runId === attachment.runId &&
previous.role === attachment.role &&
previous.continuityOf === attachment.continuityOf &&
previous.hostEpoch === attachment.hostEpoch &&
previous.processIncarnation === attachment.processIncarnation &&
previous.providerInvocation === attachment.providerInvocation
if (same) {
return
}
const requests = this.pending.get(attachment.executionId)
if (requests && previous) {
const superseded = this.buildUnverifiable(attachment.executionId, previous)
for (const request of requests) {
request.resolve(superseded)
}
this.pending.delete(attachment.executionId)
}
this.attachments.set(attachment.executionId, {
...attachment,
generation: (previous?.generation ?? 0) + 1
})
this.request(attachment.executionId)
}
unregister(executionId: string): void {
const attachment = this.attachments.get(executionId)
if (!attachment) {
return
}
this.attachments.delete(executionId)
const requests = this.pending.get(executionId)
if (requests) {
const observation = this.buildUnverifiable(executionId, attachment)
for (const request of requests) {
request.resolve(observation)
}
this.pending.delete(executionId)
}
this.published.delete(executionId)
this.cancelObsoleteHostScan(attachment.hostId)
}
request(executionId: string): Promise<AgentExecutionObservation> {
const attachment = this.attachments.get(executionId)
if (!attachment || this.stopped) {
return Promise.resolve(this.buildUnverifiable(executionId, attachment))
}
const request = new Promise<AgentExecutionObservation>((resolve) => {
const requests = this.pending.get(executionId)
if (requests) {
requests.push({ resolve })
} else {
this.pending.set(executionId, [{ resolve }])
}
})
this.armTimer()
return request
}
getPublished(executionId: string): AgentExecutionObservation | undefined {
return this.published.get(executionId)
}
getRegisteredExecutionIds(): readonly string[] {
return [...this.attachments.keys()]
}
stop(): void {
this.stopped = true
if (this.timer) {
this.cancelSchedule(this.timer)
this.timer = null
}
for (const scan of this.scans.values()) {
scan.controller.abort()
}
this.scans.clear()
for (const [executionId, requests] of this.pending) {
const observation = this.buildUnverifiable(executionId, this.attachments.get(executionId))
for (const request of requests) {
request.resolve(observation)
}
}
this.pending.clear()
}
private armTimer(): void {
if (this.timer || this.stopped) {
return
}
this.timer = this.schedule(() => {
this.timer = null
void this.flush()
}, this.coalesceMs)
}
private async flush(): Promise<void> {
if (this.stopped || this.pending.size === 0) {
return
}
const byHost = new Map<ExecutionHostId, ActiveAttachment[]>()
for (const executionId of this.pending.keys()) {
const attachment = this.attachments.get(executionId)
if (!attachment) {
this.pending.delete(executionId)
continue
}
const hostAttachments = byHost.get(attachment.hostId)
if (hostAttachments) {
hostAttachments.push(attachment)
} else {
byHost.set(attachment.hostId, [attachment])
}
}
let started = false
for (const [hostId, attachments] of byHost) {
if (this.scans.has(hostId) || this.scans.size >= this.maxConcurrentHostScans) {
continue
}
this.startHostScan(hostId, attachments)
started = true
}
if (this.pending.size > 0 && !started && this.scans.size === 0 && !this.timer) {
this.armTimer()
}
}
private startHostScan(hostId: ExecutionHostId, attachments: ActiveAttachment[]): void {
const controller = new AbortController()
const promise = this.scanHost(hostId, controller.signal, attachments)
.then((inventory) => {
if (inventory.hostId !== hostId) {
throw new Error('execution_observation_host_mismatch')
}
this.retryCountByHost.delete(hostId)
this.resolveHostScan(hostId, attachments, inventory)
})
.catch(() => {
this.resolveHostFailure(hostId, attachments)
if (!this.stopped && this.hasCurrentHostAttachment(hostId)) {
const retry = (this.retryCountByHost.get(hostId) ?? 0) + 1
this.retryCountByHost.set(hostId, retry)
const delay = Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** (retry - 1))
this.schedule(() => {
if (this.stopped) {
return
}
for (const attachment of this.attachments.values()) {
if (attachment.hostId === hostId && !this.pending.has(attachment.executionId)) {
this.pending.set(attachment.executionId, [])
}
}
this.armTimer()
}, delay)
}
})
.finally(() => {
if (this.scans.get(hostId)?.promise === promise) {
this.scans.delete(hostId)
}
if (this.pending.size > 0) {
this.armTimer()
}
})
this.scans.set(hostId, { controller, promise })
}
private resolveHostScan(
hostId: ExecutionHostId,
requested: ActiveAttachment[],
inventory: AgentExecutionHostInventory
): void {
const revision = (this.captureRevisionByHost.get(hostId) ?? 0) + 1
this.captureRevisionByHost.set(hostId, revision)
for (const requestedAttachment of requested) {
const current = this.attachments.get(requestedAttachment.executionId)
if (!current || current.generation !== requestedAttachment.generation) {
continue
}
this.resolveAttachment(current, {
executionId: current.executionId,
...(current.runId !== undefined ? { runId: current.runId } : {}),
...(current.role !== undefined ? { role: current.role } : {}),
...(current.continuityOf !== undefined ? { continuityOf: current.continuityOf } : {}),
hostId,
hostEpoch: inventory.hostEpoch,
captureRevision: revision,
observedAt: inventory.capturedAt,
inventoryCoverage: inventory.inventoryCoverage,
verdict: this.resolveVerdict(current, inventory)
})
}
}
private resolveHostFailure(hostId: ExecutionHostId, requested: ActiveAttachment[]): void {
const revision = (this.captureRevisionByHost.get(hostId) ?? 0) + 1
this.captureRevisionByHost.set(hostId, revision)
for (const requestedAttachment of requested) {
const current = this.attachments.get(requestedAttachment.executionId)
if (!current || current.generation !== requestedAttachment.generation) {
continue
}
this.resolveAttachment(current, {
executionId: current.executionId,
...(current.runId !== undefined ? { runId: current.runId } : {}),
...(current.role !== undefined ? { role: current.role } : {}),
...(current.continuityOf !== undefined ? { continuityOf: current.continuityOf } : {}),
hostId,
hostEpoch: current.hostEpoch,
captureRevision: revision,
observedAt: this.now(),
inventoryCoverage: 'partial',
verdict: 'unverifiable'
})
}
}
private resolveAttachment(
attachment: ActiveAttachment,
observation: AgentExecutionObservation
): void {
this.published.set(attachment.executionId, observation)
const requests = this.pending.get(attachment.executionId) ?? []
this.pending.delete(attachment.executionId)
this.publish(observation)
for (const request of requests) {
request.resolve(observation)
}
}
private resolveVerdict(
attachment: AgentExecutionAttachment,
inventory: AgentExecutionHostInventory
): AgentStatusRunVerdict {
if (inventory.inventoryCoverage !== 'complete') {
return 'unverifiable'
}
const observed = inventory.verdictByProcessIncarnation?.get(attachment.processIncarnation ?? '')
if (observed) {
return observed
}
if (attachment.processIncarnation) {
return inventory.processIncarnations.has(attachment.processIncarnation) ? 'live' : 'exited'
}
if (attachment.providerInvocation) {
return inventory.providerInvocations?.has(attachment.providerInvocation) ? 'live' : 'exited'
}
return 'unverifiable'
}
private buildUnverifiable(
executionId: string,
attachment: ActiveAttachment | undefined
): AgentExecutionObservation {
const hostId = attachment?.hostId ?? 'local'
const revision = (this.captureRevisionByHost.get(hostId) ?? 0) + 1
this.captureRevisionByHost.set(hostId, revision)
return {
executionId,
...(attachment?.runId !== undefined ? { runId: attachment.runId } : {}),
...(attachment?.role !== undefined ? { role: attachment.role } : {}),
...(attachment?.continuityOf !== undefined ? { continuityOf: attachment.continuityOf } : {}),
hostId,
hostEpoch: attachment?.hostEpoch ?? 'unknown',
captureRevision: revision,
observedAt: this.now(),
inventoryCoverage: 'partial',
verdict: 'unverifiable'
}
}
private hasCurrentHostAttachment(hostId: ExecutionHostId): boolean {
return [...this.attachments.values()].some((attachment) => attachment.hostId === hostId)
}
private cancelObsoleteHostScan(hostId: ExecutionHostId): void {
const hasPending = [...this.pending.keys()].some(
(executionId) => this.attachments.get(executionId)?.hostId === hostId
)
if (!hasPending) {
this.scans.get(hostId)?.controller.abort()
}
}
}
@@ -0,0 +1,277 @@
import { describe, expect, it, vi } from 'vitest'
import {
AgentExecutionObservationScheduler,
parseAgentExecutionObservation,
resolveAgentStatusPresentation,
type AgentExecutionHostInventory
} from './agent-execution-observation'
const HOST = 'local' as const
function attachment(executionId: string, processIncarnation: string) {
return {
executionId,
hostId: HOST,
hostEpoch: 'epoch-1',
processIncarnation
}
}
function inventory(
processIncarnations: readonly string[],
coverage: AgentExecutionHostInventory['inventoryCoverage'] = 'complete'
): AgentExecutionHostInventory {
return {
hostId: HOST,
hostEpoch: 'epoch-1',
capturedAt: 1_000,
inventoryCoverage: coverage,
processIncarnations: new Set(processIncarnations)
}
}
describe('agent execution observation decoder', () => {
it('accepts complete host evidence and rejects malformed or unknown fields', () => {
const value = {
executionId: 'exec-1',
hostId: 'local',
hostEpoch: 'epoch-1',
captureRevision: 1,
observedAt: 1_000,
inventoryCoverage: 'complete',
verdict: 'live'
}
expect(parseAgentExecutionObservation(value)).toEqual(value)
expect(parseAgentExecutionObservation({ ...value, extra: true })).toBeNull()
expect(
parseAgentExecutionObservation({ ...value, verdict: 'exited', captureRevision: 0 })
).toBeNull()
expect(parseAgentExecutionObservation({ ...value, hostId: 'ssh:' })).toBeNull()
})
})
describe('AgentExecutionObservationScheduler', () => {
it('coalesces same-host requests and publishes a revision for unchanged rows', async () => {
vi.useFakeTimers()
try {
let resolveScan!: (value: AgentExecutionHostInventory) => void
const scanHost = vi.fn(
() =>
new Promise<AgentExecutionHostInventory>((resolve) => {
resolveScan = resolve
})
)
const published: unknown[] = []
const scheduler = new AgentExecutionObservationScheduler(scanHost, (observation) => {
published.push(observation)
})
scheduler.register(attachment('exec-a', 'pty-a:1'))
scheduler.register(attachment('exec-b', 'pty-b:1'))
const first = scheduler.request('exec-a')
const second = scheduler.request('exec-b')
await vi.advanceTimersByTimeAsync(25)
expect(scanHost).toHaveBeenCalledTimes(1)
resolveScan(inventory(['pty-a:1']))
await vi.runAllTicks()
await expect(first).resolves.toMatchObject({
executionId: 'exec-a',
verdict: 'live',
captureRevision: 1
})
await expect(second).resolves.toMatchObject({
executionId: 'exec-b',
verdict: 'exited',
captureRevision: 1
})
expect(published).toHaveLength(2)
const repeat = scheduler.request('exec-a')
await vi.advanceTimersByTimeAsync(25)
resolveScan(inventory(['pty-a:1']))
await vi.runAllTicks()
await expect(repeat).resolves.toMatchObject({
executionId: 'exec-a',
verdict: 'live',
captureRevision: 2
})
expect(scanHost).toHaveBeenCalledTimes(2)
scheduler.stop()
} finally {
vi.useRealTimers()
}
})
it('never converts incomplete inventory or pid reuse into a live verdict', async () => {
vi.useFakeTimers()
try {
let resolveScan!: (value: AgentExecutionHostInventory) => void
const scheduler = new AgentExecutionObservationScheduler(
() =>
new Promise<AgentExecutionHostInventory>((resolve) => {
resolveScan = resolve
}),
() => undefined
)
scheduler.register(attachment('exec-a', 'pty-a:1'))
const result = scheduler.request('exec-a')
await vi.advanceTimersByTimeAsync(25)
resolveScan(inventory(['pty-a:2']))
await vi.runAllTicks()
await expect(result).resolves.toMatchObject({ verdict: 'exited' })
scheduler.register(attachment('exec-a', 'pty-a:3'))
const incomplete = scheduler.request('exec-a')
await vi.advanceTimersByTimeAsync(25)
resolveScan(inventory([], 'partial'))
await vi.runAllTicks()
await expect(incomplete).resolves.toMatchObject({ verdict: 'unverifiable' })
scheduler.stop()
} finally {
vi.useRealTimers()
}
})
it('settles pending requests for a replaced attachment and ignores its delayed scan', async () => {
vi.useFakeTimers()
try {
let resolveScan!: (value: AgentExecutionHostInventory) => void
const scheduler = new AgentExecutionObservationScheduler(
() =>
new Promise<AgentExecutionHostInventory>((resolve) => {
resolveScan = resolve
}),
() => undefined
)
scheduler.register(attachment('exec-a', 'pty-a:1'))
const oldRequest = scheduler.request('exec-a')
await vi.advanceTimersByTimeAsync(25)
scheduler.register(attachment('exec-a', 'pty-a:2'))
await expect(oldRequest).resolves.toMatchObject({ verdict: 'unverifiable' })
resolveScan(inventory(['pty-a:1']))
await vi.runAllTicks()
expect(scheduler.getPublished('exec-a')).toBeUndefined()
scheduler.stop()
} finally {
vi.useRealTimers()
}
})
it('settles a pending request when its attachment is unregistered', async () => {
vi.useFakeTimers()
try {
const scheduler = new AgentExecutionObservationScheduler(
() => new Promise<AgentExecutionHostInventory>(() => {}),
() => undefined
)
scheduler.register(attachment('exec-a', 'pty-a:1'))
const result = scheduler.request('exec-a')
scheduler.unregister('exec-a')
await expect(result).resolves.toMatchObject({
executionId: 'exec-a',
verdict: 'unverifiable'
})
scheduler.stop()
} finally {
vi.useRealTimers()
}
})
it('retries a failed host scan with bounded backoff after returning uncertainty', async () => {
vi.useFakeTimers()
try {
let calls = 0
const scanHost = vi.fn(async () => {
calls += 1
if (calls === 1) {
throw new Error('relay_unavailable')
}
return inventory(['pty-a:1'])
})
const published: unknown[] = []
const scheduler = new AgentExecutionObservationScheduler(
scanHost,
(observation) => {
published.push(observation)
},
{ retryBaseMs: 10, retryMaxMs: 20 }
)
scheduler.register(attachment('exec-a', 'pty-a:1'))
const result = scheduler.request('exec-a')
await vi.advanceTimersByTimeAsync(25)
await expect(result).resolves.toMatchObject({ verdict: 'unverifiable' })
expect(calls).toBe(1)
await vi.advanceTimersByTimeAsync(10)
await vi.advanceTimersByTimeAsync(25)
expect(calls).toBe(2)
expect(published.toReversed()[0]).toMatchObject({ verdict: 'live', captureRevision: 2 })
scheduler.stop()
} finally {
vi.useRealTimers()
}
})
})
describe('resolveAgentStatusPresentation', () => {
it('retains a legacy pending question without allowing the clock to resolve it', () => {
expect(
resolveAgentStatusPresentation(
{ state: 'blocked', updatedAt: 0 },
31 * 60 * 1_000,
30 * 60 * 1_000
)
).toEqual({
state: 'blocked',
executionVerdict: null,
pendingInteraction: true,
confidence: 'legacy',
routeUsability: 'unverifiable'
})
})
it('retains a pending question through contact uncertainty and never treats liveness as Working', () => {
const pending = resolveAgentStatusPresentation(
{
state: 'waiting',
updatedAt: 0,
executionObservation: {
executionId: 'exec-1',
hostId: HOST,
hostEpoch: 'epoch-1',
captureRevision: 3,
observedAt: 0,
inventoryCoverage: 'partial',
verdict: 'unverifiable'
}
},
31 * 60 * 1_000,
30 * 60 * 1_000
)
expect(pending).toMatchObject({
state: 'waiting',
pendingInteraction: true,
confidence: 'uncertain',
routeUsability: 'usable'
})
const working = resolveAgentStatusPresentation(
{
state: 'working',
updatedAt: 0,
executionObservation: {
executionId: 'exec-1',
hostId: HOST,
hostEpoch: 'epoch-1',
captureRevision: 3,
observedAt: 0,
inventoryCoverage: 'complete',
verdict: 'live'
}
},
31 * 60 * 1_000,
30 * 60 * 1_000
)
expect(working).toMatchObject({ state: 'unverifiable', executionVerdict: 'live' })
})
})
+255
View File
@@ -0,0 +1,255 @@
import { normalizeExecutionHostId, type ExecutionHostId } from './execution-host'
import type {
AgentStatusRunId,
AgentStatusRunRole,
AgentStatusRunVerdict
} from './agent-status-run'
import type { AgentStatusEntry, AgentStatusState } from './agent-status-types'
/** The only execution fact that crosses the host boundary; process details remain host-private. */
export type AgentExecutionObservation = {
executionId: string
runId?: AgentStatusRunId
role?: Exclude<AgentStatusRunRole, 'unresolved'>
continuityOf?: AgentStatusRunId
hostId: ExecutionHostId
hostEpoch: string
captureRevision: number
observedAt: number
inventoryCoverage: 'complete' | 'partial'
verdict: AgentStatusRunVerdict
}
export function agentExecutionObservationsEqual(
left: AgentExecutionObservation | undefined,
right: AgentExecutionObservation | undefined
): boolean {
if (left === right) {
return true
}
return Boolean(
left &&
right &&
left.executionId === right.executionId &&
left.runId === right.runId &&
left.role === right.role &&
left.continuityOf === right.continuityOf &&
left.hostId === right.hostId &&
left.hostEpoch === right.hostEpoch &&
left.captureRevision === right.captureRevision &&
left.observedAt === right.observedAt &&
left.inventoryCoverage === right.inventoryCoverage &&
left.verdict === right.verdict
)
}
type RecordValue = Record<string, unknown>
const isRecord = (value: unknown): value is RecordValue =>
typeof value === 'object' && value !== null && !Array.isArray(value)
function isBoundedString(value: unknown, maxLength: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maxLength &&
value === value.trim() &&
[...value].every((character) => {
const code = character.charCodeAt(0)
return code > 0x1f && code !== 0x7f
})
)
}
function hasExactKeys(
value: RecordValue,
required: readonly string[],
optional: readonly string[] = []
): boolean {
const keys = Object.keys(value)
return (
required.every((key) => Object.hasOwn(value, key)) &&
keys.every((key) => required.includes(key) || optional.includes(key))
)
}
/** Fail-closed decoder for host-published evidence. */
export function parseAgentExecutionObservation(value: unknown): AgentExecutionObservation | null {
const hostId =
isRecord(value) && typeof value.hostId === 'string'
? normalizeExecutionHostId(value.hostId)
: null
const captureRevision =
isRecord(value) &&
typeof value.captureRevision === 'number' &&
Number.isSafeInteger(value.captureRevision)
? value.captureRevision
: null
const observedAt =
isRecord(value) && typeof value.observedAt === 'number' && Number.isFinite(value.observedAt)
? value.observedAt
: null
if (
!isRecord(value) ||
!hasExactKeys(
value,
[
'executionId',
'hostId',
'hostEpoch',
'captureRevision',
'observedAt',
'inventoryCoverage',
'verdict'
],
['runId', 'role', 'continuityOf']
) ||
!isBoundedString(value.executionId, 128) ||
!isBoundedString(value.hostId, 1024) ||
!hostId ||
!isBoundedString(value.hostEpoch, 256) ||
captureRevision === null ||
captureRevision <= 0 ||
observedAt === null ||
observedAt < 0 ||
(value.inventoryCoverage !== 'complete' && value.inventoryCoverage !== 'partial') ||
(value.verdict !== 'live' && value.verdict !== 'unverifiable' && value.verdict !== 'exited') ||
(value.runId !== undefined && !isBoundedString(value.runId, 128)) ||
(value.role !== undefined && value.role !== 'root' && value.role !== 'child') ||
(value.continuityOf !== undefined && !isBoundedString(value.continuityOf, 128)) ||
(value.continuityOf !== undefined && value.continuityOf === value.runId) ||
(value.role !== undefined && value.runId === undefined)
) {
return null
}
return {
executionId: value.executionId,
...(value.runId !== undefined ? { runId: value.runId } : {}),
...(value.role !== undefined ? { role: value.role } : {}),
...(value.continuityOf !== undefined ? { continuityOf: value.continuityOf } : {}),
hostId,
hostEpoch: value.hostEpoch,
captureRevision,
observedAt,
inventoryCoverage: value.inventoryCoverage,
verdict: value.verdict
}
}
export type AgentExecutionDisplayState = AgentStatusState | 'idle' | 'unverifiable'
export type AgentStatusPresentation = {
state: AgentExecutionDisplayState
executionVerdict: AgentStatusRunVerdict | null
pendingInteraction: boolean
confidence: 'authoritative' | 'uncertain' | 'legacy'
routeUsability: 'usable' | 'unverifiable' | 'unavailable'
}
/** Projects status without allowing host liveness to become turn truth. */
export function resolveAgentStatusPresentation(
entry: Pick<
AgentStatusEntry,
| 'state'
| 'updatedAt'
| 'evidenceObservedAt'
| 'mirroredEvidenceReceivedAt'
| 'restoredUnconfirmed'
| 'executionObservation'
>,
now: number,
staleAfterMs: number
): AgentStatusPresentation {
const observation = entry.executionObservation
const pendingInteraction = entry.state === 'blocked' || entry.state === 'waiting'
const isFresh =
entry.restoredUnconfirmed !== true &&
now - (entry.mirroredEvidenceReceivedAt ?? entry.evidenceObservedAt ?? entry.updatedAt) <=
staleAfterMs
if (pendingInteraction) {
return {
state: entry.state,
executionVerdict: observation?.verdict ?? null,
pendingInteraction: true,
confidence: observation
? observation.verdict === 'live'
? 'authoritative'
: 'uncertain'
: 'legacy',
routeUsability: observation
? observation.verdict === 'exited'
? 'unavailable'
: 'usable'
: 'unverifiable'
}
}
if (entry.state === 'done') {
return {
state: 'done',
executionVerdict: observation?.verdict ?? null,
pendingInteraction: false,
confidence: observation ? 'authoritative' : 'legacy',
routeUsability: observation?.verdict === 'exited' ? 'unavailable' : 'usable'
}
}
if (observation) {
const working = entry.state === 'working' && isFresh && observation.verdict === 'live'
return {
state: working ? 'working' : 'unverifiable',
executionVerdict: observation.verdict,
pendingInteraction: false,
confidence: working ? 'authoritative' : 'uncertain',
routeUsability: observation.verdict === 'exited' ? 'unavailable' : 'usable'
}
}
return {
state: isFresh ? entry.state : 'idle',
executionVerdict: null,
pendingInteraction: false,
confidence: 'legacy',
routeUsability: 'usable'
}
}
export type AgentExecutionAttachment = {
executionId: string
runId?: AgentStatusRunId
role?: Exclude<AgentStatusRunRole, 'unresolved'>
continuityOf?: AgentStatusRunId
hostId: ExecutionHostId
paneKey?: string
hostEpoch: string
processIncarnation?: string
processId?: string
processIncarnationId?: string
providerInvocation?: string
}
export type AgentExecutionHostInventory = {
hostId: ExecutionHostId
hostEpoch: string
capturedAt: number
inventoryCoverage: 'complete' | 'partial'
processIncarnations: ReadonlySet<string>
verdictByProcessIncarnation?: ReadonlyMap<string, AgentStatusRunVerdict>
providerInvocations?: ReadonlySet<string>
}
export type AgentExecutionObservationSchedulerOptions = {
coalesceMs?: number
maxConcurrentHostScans?: number
retryBaseMs?: number
retryMaxMs?: number
now?: () => number
schedule?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
cancelSchedule?: (handle: ReturnType<typeof setTimeout>) => void
}
export function isExecutionObservationCurrent(
observation: AgentExecutionObservation | undefined,
executionId: string,
hostEpoch: string
): boolean {
return Boolean(
observation &&
observation.executionId === executionId &&
observation.hostEpoch === hostEpoch &&
observation.captureRevision > 0
)
}
export { AgentExecutionObservationScheduler } from './agent-execution-observation-scheduler'
+6 -4
View File
@@ -30,15 +30,15 @@ export function agentStatusEvidenceObservedAt(
export function isFreshNonDoneAgentStatus(
entry:
| Pick<
| (Pick<
AgentStatusEntry,
| 'state'
| 'updatedAt'
| 'evidenceObservedAt'
| 'mirroredEvidenceReceivedAt'
| 'restoredUnconfirmed'
| 'structuredHostOwned'
>
| 'executionObservation'
> & { state?: AgentStatusEntry['state'] })
| undefined,
now = Date.now(),
staleAfterMs = AGENT_STATUS_STALE_AFTER_MS
@@ -48,7 +48,9 @@ export function isFreshNonDoneAgentStatus(
entry &&
entry.state !== 'done' &&
entry.restoredUnconfirmed !== true &&
(entry.structuredHostOwned === true ||
(entry.state === 'blocked' ||
entry.state === 'waiting' ||
entry.structuredHostOwned === true ||
now - agentStatusEvidenceObservedAt(entry) <= staleAfterMs)
)
}
+3
View File
@@ -6,6 +6,7 @@
import type { StructuredHostStatus } from './agent-hook-listener/listener-event'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { WithAgentStatusObservation } from './agent-status-observation'
import type { AgentExecutionObservation } from './agent-execution-observation'
import type {
AgentStatusExecutionId,
AgentStatusProviderAlias,
@@ -34,6 +35,8 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
runId?: AgentStatusRunId
/** Host-owned process-incarnation attachment for the run-aware row. */
executionId?: AgentStatusExecutionId
/** Host evidence for this exact execution attachment; never persisted by the hook store. */
executionObservation?: AgentExecutionObservation
/** Fully qualified provider identity; never a credential or mailbox lookup key. */
providerAlias?: AgentStatusProviderAlias
paneKey: string
+5
View File
@@ -40,6 +40,11 @@ describe('isFreshNonDoneAgentStatus', () => {
expect(isFreshNonDoneAgentStatus({ state: 'done', updatedAt: 2_000 }, 2_000)).toBe(false)
expect(isFreshNonDoneAgentStatus({ state: 'working', updatedAt: 0 }, 10_000, 5_000)).toBe(false)
})
it('retains blocked and waiting questions beyond the report TTL', () => {
expect(isFreshNonDoneAgentStatus({ state: 'blocked', updatedAt: 0 }, 10_000, 5_000)).toBe(true)
expect(isFreshNonDoneAgentStatus({ state: 'waiting', updatedAt: 0 }, 10_000, 5_000)).toBe(true)
})
})
describe('parseAgentStatusPayload', () => {
+3
View File
@@ -5,6 +5,7 @@
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { OrchestrationFleetAttention } from './orchestration-fleet-attention'
import type { AgentStatusRowFacets } from './agent-status-observation'
import type { AgentExecutionObservation } from './agent-execution-observation'
import type { TuiAgent } from './tui-agent'
import {
normalizeInteractivePromptField,
@@ -152,6 +153,8 @@ export type AgentStatusEntry = {
providerSession?: AgentProviderSessionMetadata
/** False when the status belongs to a non-terminal owner that restores itself. */
terminalResumeEligible?: false
/** Exact host observation for this row's execution attachment. */
executionObservation?: AgentExecutionObservation
/** Live-only Command Code turn boundary key; not persisted to last-status.json. */
promptInteractionKey?: string
/** True for a nonterminal state hydrated from last-status.json with no live hook since:
@@ -79,6 +79,8 @@ export type RemoteForegroundEvidence =
verdict: 'live'
processName: string | null
fence: PosixFence | WindowsFence
/** True when the host proved the PTY contains only its shell. */
shellOwnsEveryTtyProcessGroup?: boolean
} & HostObservation)
| ({ verdict: 'unverifiable'; reason: string } & HostObservation)
| ({ verdict: 'exited'; reason: string } & HostObservation)
@@ -169,6 +171,8 @@ export function isRemoteForegroundEvidence(value: unknown): value is RemoteForeg
if (input.verdict === 'live') {
return (
(input.processName === null || typeof input.processName === 'string') &&
(input.shellOwnsEveryTtyProcessGroup === undefined ||
typeof input.shellOwnsEveryTtyProcessGroup === 'boolean') &&
(isPosixFence(input.fence) || isWindowsFence(input.fence))
)
}
+3
View File
@@ -7,6 +7,7 @@ import type {
WorktreeLineageWarning
} from './worktree/lineage-types'
import type { RuntimeListingHostScope } from './runtime-listing-host-scope'
import type { AgentExecutionObservation } from './agent-execution-observation'
import type { GitWorktreeInfo, Worktree } from './worktree/types'
export type RuntimeWorktreeAgentRow = {
@@ -28,6 +29,8 @@ export type RuntimeWorktreeAgentRow = {
/** The structured session host still runs this row's provider child, so it is fresh regardless
* of age. Optional on the wire: old hosts never send it. */
structuredHostOwned?: true
/** Exact host observation for this row's execution attachment. */
executionObservation?: AgentExecutionObservation
}
export type RuntimeWorktreePsSummary = {