From f4cf7e0d9cbef117d233d224b5052eb372a0c1bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:52:48 -0700 Subject: [PATCH] feat(agent-status): add bounded execution evidence --- mobile/src/components/AgentStateDot.tsx | 3 +- mobile/src/worktree/agent-row-display.test.ts | 26 ++ mobile/src/worktree/agent-row-display.ts | 13 +- .../server-execution-observation.test.ts | 157 ++++++++ .../server/server-execution-observation.ts | 109 ++++++ src/main/agent-hooks/server/server-state.ts | 32 ++ .../server/server-status-application.ts | 8 + .../server/server-status-identity.ts | 1 + src/main/agent-hooks/server/server-types.ts | 4 + src/main/ipc/pty/register-handlers.ts | 7 +- src/main/ipc/pty/runtime/controller-deps.ts | 10 + src/main/ipc/pty/runtime/controller.ts | 56 ++- .../ipc/pty/runtime/inventory-operations.ts | 4 +- src/main/ipc/pty/runtime/operations.ts | 2 +- ...agent-execution-observation-attachments.ts | 64 ++++ .../agent-execution-observation-process.ts | 116 ++++++ ...gent-execution-observation-service.test.ts | 295 +++++++++++++++ .../agent-execution-observation-service.ts | 234 ++++++++++++ .../runtime-hook-agent-row-selection.test.ts | 27 ++ .../runtime-hook-agent-row-selection.ts | 11 +- .../runtime-mobile-agent-status-builder.ts | 5 +- .../runtime-mobile-agent-status-projection.ts | 14 +- .../runtime-mobile-session-projection.ts | 4 +- .../runtime/runtime-terminal-contracts.ts | 7 +- .../runtime/runtime-worktree-agent-rows.ts | 23 +- .../runtime/runtime-worktree-agent-source.ts | 2 + .../runtime-worktree-pty-agent-sources.ts | 3 +- .../activity/activity-event-state.ts | 14 + .../components/cmd-j/palette-live-status.tsx | 9 + .../components/dashboard/useRetainedAgents.ts | 8 +- .../ReviewNotesSendMenuContent.test.tsx | 44 ++- .../editor/ReviewNotesSendMenuContent.tsx | 6 +- .../src/components/pet/pet-agent-state.ts | 13 +- .../sidebar/smart-attention-pane-inputs.ts | 136 +++++++ .../sidebar/smart-attention.test.ts | 53 ++- .../src/components/sidebar/smart-attention.ts | 204 +++------- .../stale-agent-row-unverifiable.test.ts | 30 ++ .../worktree-agent-activity-summary.ts | 21 +- .../components/sidebar/worktree-agent-rows.ts | 17 +- .../workspace-space-presentation.ts | 19 +- .../terminal-tab-activity-status.test.ts | 21 ++ .../tab-bar/terminal-tab-activity-status.ts | 20 +- .../agent-status-event-applicator.ts | 5 +- .../src/lib/active-agent-note-target.ts | 12 +- src/renderer/src/lib/agent-row-decay-state.ts | 9 +- .../src/lib/notes-send-agent-targets.ts | 4 +- .../src/lib/pane-agent-evidence.test.ts | 66 ++++ src/renderer/src/lib/pane-agent-evidence.ts | 41 +- .../src/lib/running-agent-targets.test.ts | 37 +- src/renderer/src/lib/running-agent-targets.ts | 16 +- .../src/lib/worktree-activity-state.ts | 17 +- .../agent-status-projection.ts | 3 +- .../agent-status-patch.ts | 10 +- .../state-equality-core.ts | 2 + .../src/store/slices/agent-status-contract.ts | 3 + .../slices/agent-status-live-entry-builder.ts | 1 + .../store/slices/agent-status-live-facts.ts | 8 +- .../workspace-cleanup-local-evidence.ts | 8 + .../agent-execution-observation-scheduler.ts | 351 ++++++++++++++++++ .../agent-execution-observation.test.ts | 277 ++++++++++++++ src/shared/agent-execution-observation.ts | 255 +++++++++++++ src/shared/agent-status-freshness.ts | 10 +- src/shared/agent-status-ipc-payload.ts | 3 + src/shared/agent-status-types.test.ts | 5 + src/shared/agent-status-types.ts | 3 + src/shared/foreground-process-evidence.ts | 4 + src/shared/runtime-worktree-contracts.ts | 3 + 67 files changed, 2767 insertions(+), 238 deletions(-) create mode 100644 src/main/agent-hooks/server-execution-observation.test.ts create mode 100644 src/main/agent-hooks/server/server-execution-observation.ts create mode 100644 src/main/runtime/agent-execution-observation-attachments.ts create mode 100644 src/main/runtime/agent-execution-observation-process.ts create mode 100644 src/main/runtime/agent-execution-observation-service.test.ts create mode 100644 src/main/runtime/agent-execution-observation-service.ts create mode 100644 src/renderer/src/components/sidebar/smart-attention-pane-inputs.ts create mode 100644 src/shared/agent-execution-observation-scheduler.ts create mode 100644 src/shared/agent-execution-observation.test.ts create mode 100644 src/shared/agent-execution-observation.ts diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 1637296d900..a26083e5c05 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -13,7 +13,8 @@ const DOT_COLORS: Record, 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' diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index ee872e40f26..a342daa1755 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -26,6 +26,16 @@ function row(overrides: Partial = {}): 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', () => { diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index c678b522a15..d9b08db5e2f 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -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, + 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' } } diff --git a/src/main/agent-hooks/server-execution-observation.test.ts b/src/main/agent-hooks/server-execution-observation.test.ts new file mode 100644 index 00000000000..9592b09c24b --- /dev/null +++ b/src/main/agent-hooks/server-execution-observation.test.ts @@ -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) + }) +}) diff --git a/src/main/agent-hooks/server/server-execution-observation.ts b/src/main/agent-hooks/server/server-execution-observation.ts new file mode 100644 index 00000000000..9881e9c4068 --- /dev/null +++ b/src/main/agent-hooks/server/server-execution-observation.ts @@ -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 + 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 +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 0df8ff70445..aff57bc1699 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -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(): { diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 025c431dff6..e20618578d5 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts index 1694c4b1b67..1bcb833e335 100644 --- a/src/main/agent-hooks/server/server-status-identity.ts +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -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 diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index b4cf176b176..c17f6b03c60 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -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' > & { diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9a6230ad82b..1e1e781980f 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -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 }) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 0d388599bc9..b5025cafa64 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -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 reversibleStopOwnersByPtyId: Map mainWindow: BrowserWindow + /** Host-owned status sink; C6 never constructs replica frames itself. */ + publishExecutionObservation?: ( + paneKey: string, + observation: AgentExecutionObservation, + attachment?: AgentExecutionAttachment + ) => boolean } export type { StablePaneOwner } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 633f4c91b4c..8611d460823 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -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() diff --git a/src/main/ipc/pty/runtime/inventory-operations.ts b/src/main/ipc/pty/runtime/inventory-operations.ts index 882908a926e..44f62c772fe 100644 --- a/src/main/ipc/pty/runtime/inventory-operations.ts +++ b/src/main/ipc/pty/runtime/inventory-operations.ts @@ -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 { diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 4bcc4f4e643..667e7fc45bd 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -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) } diff --git a/src/main/runtime/agent-execution-observation-attachments.ts b/src/main/runtime/agent-execution-observation-attachments.ts new file mode 100644 index 00000000000..e93e7eb390b --- /dev/null +++ b/src/main/runtime/agent-execution-observation-attachments.ts @@ -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 +type PtyIncarnations = ReadonlyMap + +/** + * 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 +} diff --git a/src/main/runtime/agent-execution-observation-process.ts b/src/main/runtime/agent-execution-observation-process.ts new file mode 100644 index 00000000000..c1dc2d6bd92 --- /dev/null +++ b/src/main/runtime/agent-execution-observation-process.ts @@ -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 { + const identities = new Set() + 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> { + 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() + 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 => { + 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>> +): 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' +} diff --git a/src/main/runtime/agent-execution-observation-service.test.ts b/src/main/runtime/agent-execution-observation-service.test.ts new file mode 100644 index 00000000000..dbd29e19712 --- /dev/null +++ b/src/main/runtime/agent-execution-observation-service.test.ts @@ -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([['pty-1', 'ssh-target']]) + const ptyIncarnationById = new Map([['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(() => {})) + } + 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() + }) +}) diff --git a/src/main/runtime/agent-execution-observation-service.ts b/src/main/runtime/agent-execution-observation-service.ts new file mode 100644 index 00000000000..9a0a5a8350a --- /dev/null +++ b/src/main/runtime/agent-execution-observation-service.ts @@ -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>() + private pollingHandle: ReturnType | 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() + 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 { + this.syncAttachments() + return this.scheduler.request(executionId) + } + + observeAll(): Promise { + 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 { + 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, + controller: RuntimePtyController, + signal: AbortSignal, + attachments: readonly AgentExecutionAttachment[], + deadlineMs: number + ): Promise { + 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) +} diff --git a/src/main/runtime/runtime-hook-agent-row-selection.test.ts b/src/main/runtime/runtime-hook-agent-row-selection.test.ts index 662fc5c160f..b2dd657dc45 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.test.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.test.ts @@ -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' } + }) + }) }) diff --git a/src/main/runtime/runtime-hook-agent-row-selection.ts b/src/main/runtime/runtime-hook-agent-row-selection.ts index 67c1b698a1d..559709600a1 100644 --- a/src/main/runtime/runtime-hook-agent-row-selection.ts +++ b/src/main/runtime/runtime-hook-agent-row-selection.ts @@ -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 } : {}) } } diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 5ce39d49d6a..dca09eb1419 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -114,7 +114,10 @@ export function buildRuntimeMobileAgentStatus( : {}), tabId: tab.parentTabId, terminalTitle, - ...providerSession + ...providerSession, + ...(liveRow.executionObservation + ? { executionObservation: liveRow.executionObservation } + : {}) }, ownerAgent, ownerOptions diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index b21fd8bb3ff..50a13e854a0 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -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 && diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index db1ef0619ca..dbc0d931723 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -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. diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 227788af7e1..28536ed225e 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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<{ diff --git a/src/main/runtime/runtime-worktree-agent-rows.ts b/src/main/runtime/runtime-worktree-agent-rows.ts index 20c17f9b01a..2b9bcd9e618 100644 --- a/src/main/runtime/runtime-worktree-agent-rows.ts +++ b/src/main/runtime/runtime-worktree-agent-rows.ts @@ -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) { diff --git a/src/main/runtime/runtime-worktree-agent-source.ts b/src/main/runtime/runtime-worktree-agent-source.ts index 984f20e0955..4344719c064 100644 --- a/src/main/runtime/runtime-worktree-agent-source.ts +++ b/src/main/runtime/runtime-worktree-agent-source.ts @@ -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 } diff --git a/src/main/runtime/runtime-worktree-pty-agent-sources.ts b/src/main/runtime/runtime-worktree-pty-agent-sources.ts index 058d378df11..c1b9710ef66 100644 --- a/src/main/runtime/runtime-worktree-pty-agent-sources.ts +++ b/src/main/runtime/runtime-worktree-pty-agent-sources.ts @@ -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[] = [] diff --git a/src/renderer/src/components/activity/activity-event-state.ts b/src/renderer/src/components/activity/activity-event-state.ts index 85f6af9c828..923d6bcbdb1 100644 --- a/src/renderer/src/components/activity/activity-event-state.ts +++ b/src/renderer/src/components/activity/activity-event-state.ts @@ -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) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 34840d50cc5..e3e2a2593b4 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -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) diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 2e39205a84c..ec5be9bdbef 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -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 diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index e9fc3434e9a..b50420302d0 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -11,6 +11,17 @@ type ReactElementLike = { props: Record } +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(), 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) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 60b5bcd70c6..7c0244ef3ff 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -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} /> diff --git a/src/renderer/src/components/pet/pet-agent-state.ts b/src/renderer/src/components/pet/pet-agent-state.ts index bc5a711ce62..71a1dd1a9cc 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -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 } } diff --git a/src/renderer/src/components/sidebar/smart-attention-pane-inputs.ts b/src/renderer/src/components/sidebar/smart-attention-pane-inputs.ts new file mode 100644 index 00000000000..cd630a5a022 --- /dev/null +++ b/src/renderer/src/components/sidebar/smart-attention-pane-inputs.ts @@ -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 + ptyIdsByTabId: Record + runtimePaneTitlesByTabId: Record> + terminalLayoutsByTabId?: Record +} + +export function collectTabPaneInputs( + tab: Pick, + worktreeLastActivityAt: number, + sources: TabPaneInputSources, + now: number +): PaneInput[] { + const panes: PaneInput[] = [] + const hasLivePty = tabHasLivePty(sources.ptyIdsByTabId, tab.id) + const hookLeafIds = new Set() + const permissionHookLeafIds = new Set() + 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 | undefined, + migrationUnsupportedByPtyId?: Record +): Map { + const byTab = new Map() + 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 | undefined +): Map { + const byWorktree = new Map() + 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 +} diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index 246670379b4..a7de03bc10a 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -63,7 +63,8 @@ function makeEntry(overrides: Partial & { 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', () => { diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 481dd0c2010..00247623e1b 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -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 | undefined, now: number, @@ -66,7 +74,15 @@ export function hasFreshAttributedAgentStatus( const freshUnstampedTabIds = new Set() 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 | undefined, - migrationUnsupportedByPtyId?: Record -): Map { - const byTab = new Map() - 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 | undefined -): Map { - const byWorktree = new Map() - 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 - ptyIdsByTabId: Record - runtimePaneTitlesByTabId: Record> - terminalLayoutsByTabId?: Record -} - -/** - * 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, - 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() - // Stale hooks still suppress one-shot permission titles, matching worktree and tab status dots. - const permissionHookLeafIds = new Set() - 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() - 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. diff --git a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts index 052adc752b9..967e3b03e85 100644 --- a/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts +++ b/src/renderer/src/components/sidebar/stale-agent-row-unverifiable.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index 589b7c46854..169b6c42703 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -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 ?? {})) { diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 009c562447f..db1680d4df6 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -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 })) diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index 5edad9ec739..852010dafa5 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -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): boolean { - return entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting' -} - function countTitleActiveAgentsForTab( tab: Pick, runtimePaneTitlesByTabId: Record>, @@ -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) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index 8511fff1913..c993f06b24d 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -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) => { diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index 7a31cdb8ace..1fba36abef8 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -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 } } diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index a4872822252..badf64bf634 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -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, diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index 2b114166d1f..191356578bf 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -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 diff --git a/src/renderer/src/lib/agent-row-decay-state.ts b/src/renderer/src/lib/agent-row-decay-state.ts index 10f62a34ca6..6e5197ca225 100644 --- a/src/renderer/src/lib/agent-row-decay-state.ts +++ b/src/renderer/src/lib/agent-row-decay-state.ts @@ -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 +type DecayInput = Pick & { + 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' } diff --git a/src/renderer/src/lib/notes-send-agent-targets.ts b/src/renderer/src/lib/notes-send-agent-targets.ts index ce4e00ae1ed..bf633e32c1c 100644 --- a/src/renderer/src/lib/notes-send-agent-targets.ts +++ b/src/renderer/src/lib/notes-send-agent-targets.ts @@ -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 } : {}) }) ) diff --git a/src/renderer/src/lib/pane-agent-evidence.test.ts b/src/renderer/src/lib/pane-agent-evidence.test.ts index 0bd618e2940..5102374872b 100644 --- a/src/renderer/src/lib/pane-agent-evidence.test.ts +++ b/src/renderer/src/lib/pane-agent-evidence.test.ts @@ -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' }), diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts index 67dbbf85c6f..aac51ace6c6 100644 --- a/src/renderer/src/lib/pane-agent-evidence.ts +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -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) { diff --git a/src/renderer/src/lib/running-agent-targets.test.ts b/src/renderer/src/lib/running-agent-targets.test.ts index 81126bdbda0..06160f2c969 100644 --- a/src/renderer/src/lib/running-agent-targets.test.ts +++ b/src/renderer/src/lib/running-agent-targets.test.ts @@ -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' }) }) diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 1eb3f0a227d..307580a7cf6 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -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 } : {}) }) } diff --git a/src/renderer/src/lib/worktree-activity-state.ts b/src/renderer/src/lib/worktree-activity-state.ts index a319b6a8dc3..f885621e359 100644 --- a/src/renderer/src/lib/worktree-activity-state.ts +++ b/src/renderer/src/lib/worktree-activity-state.ts @@ -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 type BrowserLikeTab = { id: string } @@ -34,9 +36,13 @@ export function getLiveAgentStatusByWorktreeId( tabsByWorktree: TabsByWorktree | null | undefined, now: number ): Map { - 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' diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts index 16c188edef5..05db68fe8ae 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -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 }) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index 4b29c800ced..db842667191 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -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 } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts index 42345db35d0..33fb20c0a75 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state-equality-core.ts @@ -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) ) diff --git a/src/renderer/src/store/slices/agent-status-contract.ts b/src/renderer/src/store/slices/agent-status-contract.ts index eaab5bb78ae..60bd95f765c 100644 --- a/src/renderer/src/store/slices/agent-status-contract.ts +++ b/src/renderer/src/store/slices/agent-status-contract.ts @@ -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 diff --git a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts index b161e51bc04..9a68040de85 100644 --- a/src/renderer/src/store/slices/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status-live-entry-builder.ts @@ -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, diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts index 485296e240d..330f46c6445 100644 --- a/src/renderer/src/store/slices/agent-status-live-facts.ts +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -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' && diff --git a/src/renderer/src/store/slices/workspace-cleanup-local-evidence.ts b/src/renderer/src/store/slices/workspace-cleanup-local-evidence.ts index 7508f59a4bf..d768b79c51b 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-local-evidence.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-local-evidence.ts @@ -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') diff --git a/src/shared/agent-execution-observation-scheduler.ts b/src/shared/agent-execution-observation-scheduler.ts new file mode 100644 index 00000000000..0d1cbbe36e7 --- /dev/null +++ b/src/shared/agent-execution-observation-scheduler.ts @@ -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 } + +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() + private readonly pending = new Map() + private readonly scans = new Map() + private readonly retryCountByHost = new Map() + private readonly captureRevisionByHost = new Map() + private readonly published = new Map() + private timer: ReturnType | 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 + private readonly cancelSchedule: (handle: ReturnType) => void + + constructor( + private readonly scanHost: ( + hostId: ExecutionHostId, + signal: AbortSignal, + attachments: readonly ActiveAttachment[] + ) => Promise, + 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 { + const attachment = this.attachments.get(executionId) + if (!attachment || this.stopped) { + return Promise.resolve(this.buildUnverifiable(executionId, attachment)) + } + const request = new Promise((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 { + if (this.stopped || this.pending.size === 0) { + return + } + const byHost = new Map() + 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() + } + } +} diff --git a/src/shared/agent-execution-observation.test.ts b/src/shared/agent-execution-observation.test.ts new file mode 100644 index 00000000000..45e3c015a6f --- /dev/null +++ b/src/shared/agent-execution-observation.test.ts @@ -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((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((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((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(() => {}), + () => 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' }) + }) +}) diff --git a/src/shared/agent-execution-observation.ts b/src/shared/agent-execution-observation.ts new file mode 100644 index 00000000000..b4532b72b68 --- /dev/null +++ b/src/shared/agent-execution-observation.ts @@ -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 + 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 +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 + 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 + verdictByProcessIncarnation?: ReadonlyMap + providerInvocations?: ReadonlySet +} +export type AgentExecutionObservationSchedulerOptions = { + coalesceMs?: number + maxConcurrentHostScans?: number + retryBaseMs?: number + retryMaxMs?: number + now?: () => number + schedule?: (callback: () => void, delayMs: number) => ReturnType + cancelSchedule?: (handle: ReturnType) => 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' diff --git a/src/shared/agent-status-freshness.ts b/src/shared/agent-status-freshness.ts index 616879b93e9..753a7b6f36a 100644 --- a/src/shared/agent-status-freshness.ts +++ b/src/shared/agent-status-freshness.ts @@ -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) ) } diff --git a/src/shared/agent-status-ipc-payload.ts b/src/shared/agent-status-ipc-payload.ts index 3bff35272c2..d29a391d9af 100644 --- a/src/shared/agent-status-ipc-payload.ts +++ b/src/shared/agent-status-ipc-payload.ts @@ -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 diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index f79f3c974f3..84f26192a63 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -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', () => { diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 3a062f7069d..3a3d282e95e 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -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: diff --git a/src/shared/foreground-process-evidence.ts b/src/shared/foreground-process-evidence.ts index 975fbe388ff..f86c6679aa4 100644 --- a/src/shared/foreground-process-evidence.ts +++ b/src/shared/foreground-process-evidence.ts @@ -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)) ) } diff --git a/src/shared/runtime-worktree-contracts.ts b/src/shared/runtime-worktree-contracts.ts index c56bc3a17e6..fd48c6a8b10 100644 --- a/src/shared/runtime-worktree-contracts.ts +++ b/src/shared/runtime-worktree-contracts.ts @@ -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 = {