From 969b2ef40c24dea081882ddad94cd529e9cc0c2a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 5 Sep 2026 03:31:25 -0400 Subject: [PATCH] fix(orchestration): restore terminal identity on fleet status rows The hook server publishes status rows carrying only a pane key, but the fleet matcher compares terminal handle and dispatch id. Every local worker therefore failed identity and projected unverifiable/missing_status while running. Apply the same enrichment the renderer IPC boundary already applies, so the shared matcher sees the identity it was written against. --- ...me-get-orchestration-dispatch-authority.ts | 8 +- .../fleet-status-terminal-identity.test.ts | 161 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 30bc36b5018..df30efda768 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -15,6 +15,7 @@ import { } from './orchestration/cli-command' import { getAppEnvironment } from '../../shared/app-environment' import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload' +import { enrichAgentStatusIpcPayload } from '../ipc/agent-status-ipc-boundary' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -195,7 +196,12 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim /** Push-fed hook rows for local read-only fleet projection; callers must redact payload text. */ getOrchestrationFleetAgentStatusSnapshot(): readonly AgentStatusIpcPayload[] { - return this.getAgentStatusSnapshotFn?.() ?? [] + // Why: hook rows carry only a pane key, but the fleet matcher compares terminal handle and + // dispatch id. Without the same enrichment the renderer boundary applies, every local worker + // fails identity and projects `missing_status` while it is demonstrably running. + return (this.getAgentStatusSnapshotFn?.() ?? []).map((entry) => + enrichAgentStatusIpcPayload(entry, this) + ) } getTerminalOrchestrationCliCommand(handle: string): OrchestrationCliCommand { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts new file mode 100644 index 00000000000..6e490ddc53e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/fleet-status-terminal-identity.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import { OrcaRuntimeWithGetOrchestrationDispatchAuthority } from '../../../../orca-runtime-get-orchestration-dispatch-authority' +import { toAgentStatusIpcPayload } from '../../../../../agent-hooks/server/server-status-identity' +import type { EnrichedAgentHookEventPayload } from '../../../../../agent-hooks/server/server-types' +import type { AgentStatusOrchestrationContext } from '../../../../../../shared/agent-status-types' +import { projectFleetWorkerPage } from './worker-observation' + +const PANE_KEY = 'tab-fleet:leaf-fleet' +const TERMINAL_HANDLE = 'term_fleet' +const DISPATCH_ID = 'disp-fleet' +/** `projectFleetWorkerPage` stamps `Date.now()` itself, so the fixture must ride the wall clock. */ +const observedAt = (): number => Date.now() - 1_000 + +/** Exactly what `agentHookServer.getStatusSnapshot()` publishes: pane identity, no terminal identity. */ +function hookRowAsPublished(): ReturnType { + return toAgentStatusIpcPayload({ + paneKey: PANE_KEY, + tabId: 'tab-fleet', + worktreeId: 'wt-fleet', + connectionId: null, + receivedAt: observedAt(), + stateStartedAt: observedAt(), + payload: { state: 'working', agentType: 'claude' } + } as unknown as EnrichedAgentHookEventPayload) +} + +function createRuntime(args: { + handleForPane?: string + orchestration?: AgentStatusOrchestrationContext +}): OrcaRuntimeService { + const host = { + getAgentStatusSnapshotFn: () => [hookRowAsPublished()], + getAgentStatusTerminalHandleForPaneKey: (paneKey: string) => + paneKey === PANE_KEY ? args.handleForPane : undefined, + getAgentStatusOrchestrationContextForPaneKey: (paneKey: string) => + paneKey === PANE_KEY ? args.orchestration : undefined + } + return { + // Drive the shipping accessor, not a copy of it: the identity loss was in this method. + getOrchestrationFleetAgentStatusSnapshot: () => + OrcaRuntimeWithGetOrchestrationDispatchAuthority.prototype.getOrchestrationFleetAgentStatusSnapshot.call( + host as never + ) + } as unknown as OrcaRuntimeService +} + +function createDb(): OrchestrationDb { + return { + listWorkerTerminalResources: () => [ + { + dispatchId: DISPATCH_ID, + taskId: 'task-fleet', + runId: 'run-fleet', + parentTaskId: 'task-parent', + workerState: 'ready', + dispatchStatus: 'dispatched', + workerStage: 'input_accepted', + agentTerminalHandle: TERMINAL_HANDLE, + paneKey: PANE_KEY, + worktreeId: 'wt-fleet', + terminalState: 'active', + pendingInput: false, + pendingApproval: false, + terminationReason: null, + resource: { + id: 'res-fleet', + owner_dispatch_id: DISPATCH_ID, + worktree_id: 'wt-fleet', + pane_key: PANE_KEY, + process_incarnation: 'pty-fleet:inc-1', + endpoint_id: null, + endpoint_incarnation: null, + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }), + ownership_state: 'owned', + release_state: 'none', + updated_at: new Date().toISOString() + }, + createdAt: new Date(Date.now() - 60_000).toISOString(), + databaseId: 1 + } + ], + getWorkerAttentionFactsForDispatches: () => new Map() + } as unknown as OrchestrationDb +} + +describe('local fleet liveness from a hook row that carries only a pane key', () => { + it('publishes hook rows without terminal identity', () => { + // Guards the premise: the fix must add identity, not assume the hook server already does. + expect(hookRowAsPublished().terminalHandle).toBeUndefined() + expect(hookRowAsPublished().orchestration).toBeUndefined() + }) + + it('reads live for a running local worker whose pane still owns its handle', () => { + const page = projectFleetWorkerPage( + createRuntime({ handleForPane: TERMINAL_HANDLE }), + createDb(), + DISPATCH_ID + ) + + expect(page?.workers[0]).toMatchObject({ + liveness: { verdict: 'live', source: 'agent_status' }, + evidence: { liveStatus: 'fresh' }, + stage: { activity: 'working' }, + nextAction: { kind: 'none' }, + attention: { requiresAction: false } + }) + }) + + it('carries the dispatch context the renderer boundary attaches', () => { + const page = projectFleetWorkerPage( + createRuntime({ + handleForPane: TERMINAL_HANDLE, + orchestration: { dispatchId: DISPATCH_ID } as AgentStatusOrchestrationContext + }), + createDb(), + DISPATCH_ID + ) + + expect(page?.workers[0]?.liveness.verdict).toBe('live') + }) + + it('refuses a pane whose handle now belongs to another terminal', () => { + const page = projectFleetWorkerPage( + createRuntime({ handleForPane: 'term_reused' }), + createDb(), + DISPATCH_ID + ) + + expect(page?.workers[0]).toMatchObject({ + liveness: { verdict: 'unverifiable', reason: 'missing_status' }, + evidence: { liveStatus: 'unavailable' } + }) + }) + + it('refuses a pane whose handle now belongs to another dispatch', () => { + const page = projectFleetWorkerPage( + createRuntime({ + handleForPane: TERMINAL_HANDLE, + orchestration: { dispatchId: 'disp-other' } as AgentStatusOrchestrationContext + }), + createDb(), + DISPATCH_ID + ) + + expect(page?.workers[0]?.liveness).toMatchObject({ + verdict: 'unverifiable', + reason: 'missing_status' + }) + }) + + it('refuses a pane that no longer resolves to a terminal', () => { + const page = projectFleetWorkerPage(createRuntime({}), createDb(), DISPATCH_ID) + + expect(page?.workers[0]?.liveness).toMatchObject({ + verdict: 'unverifiable', + reason: 'missing_status' + }) + }) +})