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