From ca060f44e0597e7d4e8b93a625d4678291e9d4e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:50:23 -0700 Subject: [PATCH] feat(agent-status): bind runs to claimed execution owners --- .../daemon/daemon-protocol-version.test.ts | 6 +- src/main/daemon/daemon-protocol-version.ts | 8 +- .../ipc/pty-controller-owner-recovery.test.ts | 27 +++- .../pty-controller-ownership-routing.test.ts | 12 +- .../pty-controller-spawn-admission.test.ts | 18 ++- ...pty-session-liveness-and-ownership.test.ts | 10 +- .../providers/pty-process-list-admission.ts | 6 +- src/main/providers/pty-spawn-result.ts | 5 + ...der-agent-session-create-operation.test.ts | 7 +- ...ssh-pty-provider-claim-incarnation.test.ts | 7 +- .../ssh-pty-provider-process-events.test.ts | 10 ++ .../providers/ssh-pty-provider-spawn.test.ts | 28 +++- ...runtime-structured-tui-tab-binding.test.ts | 10 +- ...nal-creation-and-readiness-part-02.spec.ts | 9 +- src/relay/pty-handler-spawn-admission.test.ts | 6 +- src/relay/pty-handler.ts | 5 +- src/shared/agent-session-host-authority.ts | 10 +- src/shared/agent-status-run.test.ts | 53 +++++++- src/shared/agent-status-run.ts | 53 +++++++- .../claimed-agent-pty-owner-snapshot.ts | 60 ++++++++- src/shared/claimed-agent-pty-owner.test.ts | 120 ++++++++++++++++-- src/shared/claimed-agent-pty-owner.ts | 55 +++++--- 22 files changed, 454 insertions(+), 71 deletions(-) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index dfe84947ff5..b5ce4fb9ab5 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -19,7 +19,7 @@ import { describe('daemon protocol version', () => { it('ships bounded history transfer after the 2031-unsubscribe fact', () => { - expect(PROTOCOL_VERSION).toBe(36) + expect(PROTOCOL_VERSION).toBe(37) expect(CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION).toBe(36) expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35) expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34) @@ -30,10 +30,10 @@ describe('daemon protocol version', () => { expect(MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION).toBe(29) expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27) expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11) - expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) + expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(37) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( - Array.from({ length: 35 }, (_, index) => index + 1) + Array.from({ length: 36 }, (_, index) => index + 1) ) }) diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index bae2ae972dc..ef766f42daf 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. -// v36 launches shells from content-addressed wrapper trees; older owners stay attachable. -export const PROTOCOL_VERSION = 36 +// v37 carries execution run bindings in every claimed-owner listing and result. +export const PROTOCOL_VERSION = 37 +export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 37 export const CONTENT_ADDRESSED_SHELL_WRAPPER_DAEMON_PROTOCOL_VERSION = 36 export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35 export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34 @@ -13,7 +14,6 @@ export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11 // Why: `getSize` landed in v18; older daemons reject it as an unknown request type. export const GET_SIZE_PROTOCOL_VERSION = 18 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 -export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26 export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26 export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22 export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 @@ -30,7 +30,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24 export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, - 28, 29, 30, 31, 32, 33, 34, 35 + 28, 29, 30, 31, 32, 33, 34, 35, 36 ] as const export function supportsPtyStartupIngress(protocolVersion: number): boolean { diff --git a/src/main/ipc/pty-controller-owner-recovery.test.ts b/src/main/ipc/pty-controller-owner-recovery.test.ts index 23cca2fb45f..9c34702ffb3 100644 --- a/src/main/ipc/pty-controller-owner-recovery.test.ts +++ b/src/main/ipc/pty-controller-owner-recovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import type { AgentStatusExecutionBinding } from '../../shared/agent-status-run' import { registerSshPtyProvider, clearPtyOwnershipForConnection, @@ -53,6 +54,14 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) +function statusBinding(suffix: string): AgentStatusExecutionBinding { + return { + runId: `run-${suffix}`, + attachment: { executionId: `execution-${suffix}` }, + role: 'root' + } +} + describe('registerPtyHandlers', () => { const { createAgentClaimProvider, @@ -73,7 +82,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-remote', phase: 'live', ptyId: ownerPtyId, - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('remote') } const remoteProvider = createAgentClaimProvider({ sessions: [ @@ -125,7 +135,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-conflict', phase: 'live', ptyId: 'pty-conflict-local', - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('conflict') } const remoteOwner: AgentSessionOwnerBinding = { ...localOwner, @@ -186,7 +197,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-a', phase: 'live', ptyId: 'pty-conflict-a', - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('converge') } const ownerB: AgentSessionOwnerBinding = { ...ownerA, @@ -247,7 +259,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-old', phase: 'live', ptyId: 'pty-reused', - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('old') } const sessions = [ { @@ -271,7 +284,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-new', phase: 'live', ptyId: 'pty-new-owner', - surface: ensured.surface + surface: ensured.surface, + statusBinding: statusBinding('new') } sessions.push({ id: owner.ptyId, @@ -318,7 +332,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-reconnect', phase: 'live', ptyId: `ssh:${connectionId}@@pty-owner`, - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('reconnect') } const sessions = [ { diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index cf61b71c9e5..e080dd7617e 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import type { AgentStatusExecutionBinding } from '../../shared/agent-status-run' import { LocalPtyProvider } from '../providers/local-pty-provider' import { registerPtyHandlers, @@ -18,6 +19,14 @@ import { type WriteSettlement } from '../../shared/pty-write-settlement' +function statusBinding(suffix: string): AgentStatusExecutionBinding { + return { + runId: `run-${suffix}`, + attachment: { executionId: `execution-${suffix}` }, + role: 'root' + } +} + type SettledControllerDouble = { writeWithSettlement: (id: string, data: string) => WriteSettlement | Promise } @@ -308,7 +317,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-canonical-exited', phase: 'live', ptyId: 'pty-canonical-exited', - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('canonical-exited') } const physicalSpawn = vi.fn(async () => ({ id: canonicalOwner.ptyId, diff --git a/src/main/ipc/pty-controller-spawn-admission.test.ts b/src/main/ipc/pty-controller-spawn-admission.test.ts index 65f5ba565c9..9568a715f35 100644 --- a/src/main/ipc/pty-controller-spawn-admission.test.ts +++ b/src/main/ipc/pty-controller-spawn-admission.test.ts @@ -6,6 +6,7 @@ import { } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import type { AgentStatusExecutionBinding } from '../../shared/agent-status-run' import { OrcaRuntimeService } from '../runtime/orca-runtime' import { registerPtyHandlers, @@ -15,6 +16,14 @@ import { restorePtyIncarnation } from './pty' +function statusBinding(suffix: string): AgentStatusExecutionBinding { + return { + runId: `run-${suffix}`, + attachment: { executionId: `execution-${suffix}` }, + role: 'root' + } +} + vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) @@ -306,7 +315,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-recovered', phase: 'live', ptyId: 'pty-recovered-owner', - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('recovered') } const provider = createAgentClaimProvider({ sessions: [ @@ -348,7 +358,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-adopted-exit', phase: 'live', ptyId: 'pty-adopted-exit', - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('adopted-exit') } const runtime = new OrcaRuntimeService() const provider = createAgentClaimProvider({ @@ -416,7 +427,8 @@ describe('registerPtyHandlers', () => { generation: 'generation-no-incarnation', phase: 'live', ptyId: 'pty-owner-without-incarnation', - surface: recoveredAgentSurface + surface: recoveredAgentSurface, + statusBinding: statusBinding('without-incarnation') } const provider = createAgentClaimProvider({ sessions: [ diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index 574e83c1220..53afd3803e2 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { onMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' +import type { AgentStatusExecutionBinding } from '../../shared/agent-status-run' import { registerPtyHandlers, registerSshPtyProvider, @@ -10,6 +11,12 @@ import { unregisterSshPtyProvider } from './pty' +const statusBinding: AgentStatusExecutionBinding = { + runId: 'run-agent-pty', + attachment: { executionId: 'execution-agent-pty' }, + role: 'root' +} + vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) @@ -205,7 +212,8 @@ describe('registerPtyHandlers', () => { tabId: 'tab', leafId: '11111111-1111-4111-8111-111111111111', terminalHandle: 'term_claimed' - } + }, + statusBinding } setLocalPtyProvider({ spawn: vi.fn(), diff --git a/src/main/providers/pty-process-list-admission.ts b/src/main/providers/pty-process-list-admission.ts index feecf06744b..9548f63b927 100644 --- a/src/main/providers/pty-process-list-admission.ts +++ b/src/main/providers/pty-process-list-admission.ts @@ -35,7 +35,11 @@ function retainedOwnerBytes(owner: unknown, ptyId: string): number | null { owner.surface.worktreeId, owner.surface.tabId, owner.surface.leafId, - owner.surface.terminalHandle + owner.surface.terminalHandle, + owner.statusBinding.runId, + owner.statusBinding.attachment.executionId, + owner.statusBinding.role, + ...(owner.statusBinding.continuityOf ? [owner.statusBinding.continuityOf] : []) ].reduce((total, value) => total + Buffer.byteLength(value, 'utf8'), 0) } diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 90b41d9656a..dfe72b387b9 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -6,6 +6,11 @@ import type { PtySourceReceivingActivation } from '../../shared/pty-source-recei import type { TerminalOwner } from '../../shared/terminal-owner' export type PtySpawnResult = { + /** + * Committed or adopted execution ownership. Its owner.statusBinding is the + * only attributable launch identity; a bare create-operation response + * intentionally has no status binding. + */ agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts index e088b21f025..0cb9def8e95 100644 --- a/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts +++ b/src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts @@ -292,7 +292,12 @@ describe('SSH fresh agent-session create operations', () => { generation: 'generation-old', phase: 'live', ptyId: 'pty-1', - surface + surface, + statusBinding: { + runId: 'run-old', + attachment: { executionId: 'execution-old' }, + role: 'root' + } } } }, diff --git a/src/main/providers/ssh-pty-provider-claim-incarnation.test.ts b/src/main/providers/ssh-pty-provider-claim-incarnation.test.ts index ec0fe36527d..e0337e50d80 100644 --- a/src/main/providers/ssh-pty-provider-claim-incarnation.test.ts +++ b/src/main/providers/ssh-pty-provider-claim-incarnation.test.ts @@ -32,7 +32,12 @@ describe('SSH claimed PTY incarnation validation', () => { generation: 'generation-invalid-incarnation', phase: 'live', ptyId: 'pty-invalid-incarnation', - surface + surface, + statusBinding: { + runId: 'run-invalid-incarnation', + attachment: { executionId: 'execution-invalid-incarnation' }, + role: 'root' + } } } } diff --git a/src/main/providers/ssh-pty-provider-process-events.test.ts b/src/main/providers/ssh-pty-provider-process-events.test.ts index e152ef47fae..eb81f26609f 100644 --- a/src/main/providers/ssh-pty-provider-process-events.test.ts +++ b/src/main/providers/ssh-pty-provider-process-events.test.ts @@ -74,6 +74,11 @@ describe('SshPtyProvider process listings and events', () => { tabId: 'tab', leafId: '11111111-1111-4111-8111-111111111111', terminalHandle: 'term_claimed' + }, + statusBinding: { + runId: 'run-1', + attachment: { executionId: 'execution-1' }, + role: 'root' } } ] @@ -112,6 +117,11 @@ describe('SshPtyProvider process listings and events', () => { tabId: 'tab', leafId: '11111111-1111-4111-8111-111111111111', terminalHandle: 'term_claimed' + }, + statusBinding: { + runId: 'run-1', + attachment: { executionId: 'execution-1' }, + role: 'root' } } ] diff --git a/src/main/providers/ssh-pty-provider-spawn.test.ts b/src/main/providers/ssh-pty-provider-spawn.test.ts index 06eb32d586f..4ece811fdf2 100644 --- a/src/main/providers/ssh-pty-provider-spawn.test.ts +++ b/src/main/providers/ssh-pty-provider-spawn.test.ts @@ -55,7 +55,12 @@ describe('spawn', () => { generation: 'generation-1', phase: 'live', ptyId: 'pty-1', - surface + surface, + statusBinding: { + runId: 'run-1', + attachment: { executionId: 'execution-1' }, + role: 'root' + } } } } @@ -141,7 +146,12 @@ describe('spawn', () => { generation: 'generation-malformed', phase: 'live', ptyId: 'pty-malformed', - surface + surface, + statusBinding: { + runId: 'run-malformed', + attachment: { executionId: 'execution-malformed' }, + role: 'root' + } }) } } @@ -176,7 +186,12 @@ describe('spawn', () => { generation: 'generation-canonical', phase: 'live', ptyId: 'pty-canonical', - surface + surface, + statusBinding: { + runId: 'run-canonical', + attachment: { executionId: 'execution-canonical' }, + role: 'root' + } } } } @@ -208,7 +223,12 @@ describe('spawn', () => { generation: 'generation-malformed', phase: 'live', ptyId: 'other-pty', - surface + surface, + statusBinding: { + runId: 'run-malformed', + attachment: { executionId: 'execution-malformed' }, + role: 'root' + } } } } diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index d15f754b244..af6ef9ecd41 100644 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -1,10 +1,17 @@ import { createHash } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { AgentStatusExecutionBinding } from '../../shared/agent-status-run' import { createEphemeralAgentSessionClaimSigner } from './agent-session-claim-identity' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { OrcaRuntimeService } from './orca-runtime' +const statusBinding: AgentStatusExecutionBinding = { + runId: 'run-cold-owner', + attachment: { executionId: 'execution-cold-owner' }, + role: 'root' +} + const { probeAgentSessionProcessIdentity, proveCodexTuiRollout, @@ -95,7 +102,8 @@ describe('structured TUI launch tab binding', () => { tabId: 'tab-cold-owner', leafId, terminalHandle - } + }, + statusBinding } ] } diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index 7b0ec496333..ddff4bc693b 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -16,6 +16,7 @@ import type { AgentSessionExecutionClaim, AgentSessionSurfaceBinding } from '../orca-runtime-test-mocks.spec' +import type { AgentStatusExecutionBinding } from '../../../shared/agent-status-run' import { HEADLESS_LEAF_ID, RESTORED_AUTHORITY_TOKEN, @@ -249,6 +250,7 @@ describe('OrcaRuntimeService', () => { phase: 'live' ptyId: string surface: AgentSessionSurfaceBinding + statusBinding: AgentStatusExecutionBinding } | undefined const spawn = vi.fn(async (options) => { @@ -259,7 +261,12 @@ describe('OrcaRuntimeService', () => { generation: 'generation-1', phase: 'live', ptyId: 'pty-claimed', - surface: ensure!.surface + surface: ensure!.surface, + statusBinding: { + runId: 'run-claimed', + attachment: { executionId: 'execution-claimed' }, + role: 'root' + } } return { id: 'pty-claimed', diff --git a/src/relay/pty-handler-spawn-admission.test.ts b/src/relay/pty-handler-spawn-admission.test.ts index 6fef02a9cc0..5cbd7673eb4 100644 --- a/src/relay/pty-handler-spawn-admission.test.ts +++ b/src/relay/pty-handler-spawn-admission.test.ts @@ -188,7 +188,7 @@ describe('PtyHandler', () => { it('replays an operation-owned spawn after its first response becomes stale', async () => { const operationId = 'a'.repeat(43) - await dispatcher.callRequest( + const first = await dispatcher.callRequest( 'pty.spawn', { cols: 80, rows: 24, agentSessionCreateOperationId: operationId }, { isStale: () => mockPtySpawn.mock.calls.length > 0 } @@ -204,6 +204,10 @@ describe('PtyHandler', () => { incarnationId: expect.any(String), shellReadyArmed: false }) + // Create-operation replay proves one PTY, not an attributable agent run; + // membership must wait for an owner binding instead of seeding this pane. + expect(first).not.toHaveProperty('agentSessionEnsure') + expect(replayed).not.toHaveProperty('agentSessionEnsure') expect(mockPtySpawn).toHaveBeenCalledOnce() expect(mockPtyInstance.kill).not.toHaveBeenCalled() expect(handler.activePtyCount).toBe(1) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index cdf436bca2a..cae000bdc66 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1762,7 +1762,7 @@ export class PtyHandler { const result = await this.agentSessionOwners.ensure({ claim, surface, - spawn: async ({ generation }) => { + spawn: async ({ generation, statusBinding }) => { const created = await this.spawnAfterAdmission( params, context, @@ -1776,7 +1776,8 @@ export class PtyHandler { generation, phase: 'live', ptyId: created.id, - surface + surface, + statusBinding } ] } diff --git a/src/shared/agent-session-host-authority.ts b/src/shared/agent-session-host-authority.ts index e994c9f5a0e..5c4391e171e 100644 --- a/src/shared/agent-session-host-authority.ts +++ b/src/shared/agent-session-host-authority.ts @@ -8,6 +8,10 @@ import type { RuntimeTerminalCreate, RuntimeTerminalPresentation } from './runti import { isTerminalLeafId } from './stable-pane-id' import { isValidTerminalTabId } from './terminal-tab-id' import type { TuiAgent } from './tui-agent' +import { + parseAgentStatusExecutionBinding, + type AgentStatusExecutionBinding +} from './agent-status-run' export { AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY as AGENT_SESSION_HOST_AUTHORITY_CAPABILITY } from './protocol-version' @@ -30,7 +34,7 @@ export const AGENT_SESSION_RPC_ERROR_CODES = [ export const AGENT_SESSION_CLAIM_DIGEST_VERSION = 1 as const -export const AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION = 2 as const +export const AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION = 3 as const export const AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION = 1 as const export const AGENT_SESSION_OPERATION_FUTURE_SKEW_MS = 5 * 60 * 1000 @@ -83,6 +87,7 @@ export type AgentSessionOwnerBinding = { phase: 'reserved' | 'live' ptyId: string surface: AgentSessionSurfaceBinding + statusBinding: AgentStatusExecutionBinding } export type AgentSessionClaimedSpawnResult = { @@ -194,7 +199,8 @@ export function isAgentSessionOwnerBinding(value: unknown): value is AgentSessio isBoundedWireString(owner.generation, 128) && (owner.phase === 'reserved' || owner.phase === 'live') && isBoundedWireString(owner.ptyId, 4096) && - isAgentSessionSurfaceBinding(owner.surface) + isAgentSessionSurfaceBinding(owner.surface) && + parseAgentStatusExecutionBinding(owner.statusBinding) !== null ) } diff --git a/src/shared/agent-status-run.test.ts b/src/shared/agent-status-run.test.ts index 371cf4ec9a0..609c5ecf324 100644 --- a/src/shared/agent-status-run.test.ts +++ b/src/shared/agent-status-run.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { deserializeAgentStatusPtyRunRecord, + parseAgentStatusExecutionBinding, parseAgentStatusProviderAlias, parseAgentStatusPtyRunRecord, serializeAgentStatusPtyRunRecord, @@ -12,7 +13,7 @@ function runRecord(overrides: Partial = {}): AgentStatu runId: 'run-a', paneKey: 'tab-1:pane-1', attachment: { executionId: 'execution-a' }, - attribution: 'token', + attribution: 'execution-attachment', providerSessions: [ { provider: 'claude', @@ -42,9 +43,9 @@ describe('agent status PTY run records', () => { ) }) - it('supports an id-less pane-attributed run without inventing a provider alias', () => { + it('supports an unresolved run without inventing a provider alias', () => { const record = runRecord({ - attribution: 'pane', + attribution: 'unresolved', providerSessions: [], role: 'unresolved', verdict: 'unverifiable' @@ -56,6 +57,52 @@ describe('agent status PTY run records', () => { ) }) + it('accepts legacy attribution labels while keeping new emissions explicit', () => { + expect(parseAgentStatusPtyRunRecord(runRecord({ attribution: 'token' }))?.attribution).toBe( + 'token' + ) + expect(parseAgentStatusPtyRunRecord(runRecord({ attribution: 'pane' }))?.attribution).toBe( + 'pane' + ) + }) + + it('parses the execution binding committed with a live owner', () => { + expect( + parseAgentStatusExecutionBinding({ + runId: 'run-a', + attachment: { executionId: 'execution-a' }, + role: 'root', + continuityOf: 'run-before-a' + }) + ).toEqual({ + runId: 'run-a', + attachment: { executionId: 'execution-a' }, + role: 'root', + continuityOf: 'run-before-a' + }) + }) + + it.each([ + { runId: '', attachment: { executionId: 'execution-a' }, role: 'root' }, + { runId: 'run-a', attachment: { executionId: '' }, role: 'root' }, + { runId: 'run-a', attachment: { executionId: 'execution-a', pid: 123 }, role: 'root' }, + { runId: 'run-a', attachment: { executionId: 'execution-a' }, role: 'unresolved' }, + { + runId: 'run-a', + attachment: { executionId: 'execution-a' }, + role: 'root', + continuityOf: 'run-a' + }, + { + runId: 'run-a', + attachment: { executionId: 'execution-a' }, + role: 'root', + extra: true + } + ])('rejects malformed execution binding %#', (value) => { + expect(parseAgentStatusExecutionBinding(value)).toBeNull() + }) + it('preserves repeated provider ids when reset evidence reports them in order', () => { const alias = { provider: 'claude' as const, diff --git a/src/shared/agent-status-run.ts b/src/shared/agent-status-run.ts index 9068cd67592..996408e458a 100644 --- a/src/shared/agent-status-run.ts +++ b/src/shared/agent-status-run.ts @@ -16,6 +16,14 @@ export type AgentStatusExecutionAttachment = { executionId: AgentStatusExecutionId } +/** Identity minted atomically with one committed execution owner. */ +export type AgentStatusExecutionBinding = { + runId: AgentStatusRunId + attachment: AgentStatusExecutionAttachment + role: Exclude + continuityOf?: AgentStatusRunId +} + export type AgentStatusProviderAlias = { provider: AgentHookSource sessionKeyKind: AgentProviderSessionKey @@ -28,7 +36,14 @@ export type AgentStatusProviderSession = AgentStatusProviderAlias & { resetBoundary?: true } -export type AgentStatusRunAttribution = 'token' | 'pane' +export type AgentStatusRunAttribution = + | 'execution-attachment' + | 'provider-alias' + | 'unresolved' + /** @deprecated Legacy persisted records; never use for newly emitted rows. */ + | 'token' + /** @deprecated Legacy pane-key fallback; never use for newly emitted rows. */ + | 'pane' export type AgentStatusRunRole = 'root' | 'child' | 'unresolved' export type AgentStatusRunVerdict = 'live' | 'unverifiable' | 'exited' @@ -97,6 +112,36 @@ function parseExecutionAttachment(value: unknown): AgentStatusExecutionAttachmen return { executionId: value.executionId } } +export function parseAgentStatusExecutionBinding( + value: unknown +): AgentStatusExecutionBinding | null { + if ( + !isRecord(value) || + !hasExactKeys(value, ['runId', 'attachment', 'role'], ['continuityOf']) || + !isAgentStatusRunId(value.runId) || + (value.role !== 'root' && value.role !== 'child') + ) { + return null + } + const attachment = parseExecutionAttachment(value.attachment) + const hasContinuity = Object.hasOwn(value, 'continuityOf') + if ( + !attachment || + (hasContinuity && + (!isAgentStatusRunId(value.continuityOf) || value.continuityOf === value.runId)) + ) { + return null + } + return { + runId: value.runId, + attachment, + role: value.role, + ...(hasContinuity && isAgentStatusRunId(value.continuityOf) + ? { continuityOf: value.continuityOf } + : {}) + } +} + export function parseAgentStatusProviderAlias(value: unknown): AgentStatusProviderAlias | null { if ( !isRecord(value) || @@ -164,7 +209,11 @@ export function parseAgentStatusPtyRunRecord(value: unknown): AgentStatusPtyRunR ) || !isAgentStatusRunId(value.runId) || !isBoundedIdentity(value.paneKey, MAX_PANE_KEY_LENGTH) || - (value.attribution !== 'token' && value.attribution !== 'pane') || + (value.attribution !== 'execution-attachment' && + value.attribution !== 'provider-alias' && + value.attribution !== 'unresolved' && + value.attribution !== 'token' && + value.attribution !== 'pane') || (value.role !== 'root' && value.role !== 'child' && value.role !== 'unresolved') || (value.verdict !== 'live' && value.verdict !== 'unverifiable' && value.verdict !== 'exited') ) { diff --git a/src/shared/claimed-agent-pty-owner-snapshot.ts b/src/shared/claimed-agent-pty-owner-snapshot.ts index 67ea9967c67..c3e33fc8668 100644 --- a/src/shared/claimed-agent-pty-owner-snapshot.ts +++ b/src/shared/claimed-agent-pty-owner-snapshot.ts @@ -1,8 +1,10 @@ -import type { - AgentSessionExecutionClaim, - AgentSessionOwnerBinding, - AgentSessionSurfaceBinding +import { + isAgentSessionOwnerBinding, + type AgentSessionExecutionClaim, + type AgentSessionOwnerBinding, + type AgentSessionSurfaceBinding } from './agent-session-host-authority' +import type { AgentStatusExecutionBinding } from './agent-status-run' export type LiveAgentSessionOwner = AgentSessionOwnerBinding & { phase: 'live' } @@ -29,6 +31,29 @@ export function cloneAgentSessionSurface( } } +export function cloneAgentStatusExecutionBinding( + binding: AgentStatusExecutionBinding +): AgentStatusExecutionBinding { + return { + runId: binding.runId, + attachment: { executionId: binding.attachment.executionId }, + role: binding.role, + ...(binding.continuityOf ? { continuityOf: binding.continuityOf } : {}) + } +} + +export function agentStatusExecutionBindingsEqual( + left: AgentStatusExecutionBinding, + right: AgentStatusExecutionBinding +): boolean { + return ( + left.runId === right.runId && + left.attachment.executionId === right.attachment.executionId && + left.role === right.role && + left.continuityOf === right.continuityOf + ) +} + export function cloneAgentSessionOwnerBinding( owner: AgentSessionOwnerBinding ): AgentSessionOwnerBinding { @@ -37,7 +62,8 @@ export function cloneAgentSessionOwnerBinding( generation: owner.generation, phase: owner.phase, ptyId: owner.ptyId, - surface: cloneAgentSessionSurface(owner.surface) + surface: cloneAgentSessionSurface(owner.surface), + statusBinding: cloneAgentStatusExecutionBinding(owner.statusBinding) } } @@ -87,6 +113,7 @@ export function agentSessionOwnerBindingsEqual( right.phase === 'live' && left.generation === right.generation && left.ptyId === right.ptyId && + agentStatusExecutionBindingsEqual(left.statusBinding, right.statusBinding) && scopedAgentSessionClaimsEqual(left.claim, right.claim) && agentSessionSurfacesEqual(left.surface, right.surface) ) @@ -96,6 +123,29 @@ export function cloneAgentSessionOwner(owner: LiveAgentSessionOwner): LiveAgentS return cloneAgentSessionOwnerBinding(owner) as LiveAgentSessionOwner } +export function parseSpawnedAgentSessionOwner( + value: unknown +): AgentSessionOwnerBinding | undefined { + if (value === undefined) { + return undefined + } + if (!isAgentSessionOwnerBinding(value) || value.phase !== 'live') { + throw new Error('agent_session_ownership_unknown') + } + return value +} + +export function countClaimedAgentPtyOwners( + live: ReadonlyMap, + conflicts: ReadonlyMap +): number { + let count = live.size + for (const owners of conflicts.values()) { + count += owners.length + } + return count +} + export function prepareRegisteredAgentSessionOwner(args: { owner: AgentSessionOwnerBinding existing?: LiveAgentSessionOwner diff --git a/src/shared/claimed-agent-pty-owner.test.ts b/src/shared/claimed-agent-pty-owner.test.ts index af7e2480439..0da451b396f 100644 --- a/src/shared/claimed-agent-pty-owner.test.ts +++ b/src/shared/claimed-agent-pty-owner.test.ts @@ -3,6 +3,7 @@ import type { AgentSessionExecutionClaim, AgentSessionSurfaceBinding } from './agent-session-host-authority' +import type { AgentStatusExecutionBinding } from './agent-status-run' import { ClaimedAgentPtyOwnerRegistry, MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES @@ -28,6 +29,14 @@ const surface: AgentSessionSurfaceBinding = { terminalHandle: 'term_handle' } +function statusBinding(suffix: string): AgentStatusExecutionBinding { + return { + runId: `run-${suffix}`, + attachment: { executionId: `execution-${suffix}` }, + role: 'root' + } +} + describe('ClaimedAgentPtyOwnerRegistry', () => { it('joins concurrent exact ensures and spawns once', async () => { const registry = new ClaimedAgentPtyOwnerRegistry() @@ -46,6 +55,7 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { await expect(first).resolves.toMatchObject({ disposition: 'created' }) await expect(second).resolves.toMatchObject({ disposition: 'adopted' }) expect(spawn).toHaveBeenCalledTimes(1) + expect((await first).owner.statusBinding).toEqual((await second).owner.statusBinding) }) it('conflicts when the same identity is claimed by another worktree', async () => { @@ -103,25 +113,102 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { it('does not retain an owner when the spawned PTY already exited', async () => { const registry = new ClaimedAgentPtyOwnerRegistry() + let failedBinding: AgentStatusExecutionBinding | undefined await expect( registry.ensure({ claim: claim(), surface, - spawn: async () => ({ ptyId: 'pty-dead' }), + spawn: async ({ statusBinding: binding }) => { + failedBinding = binding + return { ptyId: 'pty-dead' } + }, isLive: () => false }) ).rejects.toThrow('agent_session_exited_during_start') expect(registry.find(claim())).toBeNull() + const retried = await registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-retry' }), + isLive: () => true + }) + expect(retried.owner.ptyId).toBe('pty-retry') + expect(retried.owner.statusBinding).not.toEqual(failedBinding) + }) + + it('mints a replacement binding with explicit run continuity', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const first = await registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-old' }) + }) + + const replacement = await registry.ensure({ + claim: claim(), + surface, + spawn: async () => ({ ptyId: 'pty-new' }), + isLive: (owner) => owner.ptyId !== 'pty-old' + }) + + expect(replacement.owner.statusBinding.runId).not.toBe(first.owner.statusBinding.runId) + expect(replacement.owner.statusBinding.attachment.executionId).not.toBe( + first.owner.statusBinding.attachment.executionId + ) + expect(replacement.owner.statusBinding.continuityOf).toBe(first.owner.statusBinding.runId) + }) + + it('uses the lower execution host binding when it adopts an owner', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + const canonicalBinding = statusBinding('host') + let provisionalBinding: AgentStatusExecutionBinding | undefined + + const result = await registry.ensure({ + claim: claim(), + surface, + spawn: async ({ statusBinding: binding }) => { + provisionalBinding = binding + return { + ptyId: 'pty-host', + disposition: 'adopted', + owner: { + claim: claim(), + generation: 'generation-host', + phase: 'live', + ptyId: 'pty-host', + surface, + statusBinding: canonicalBinding + } + } + } + }) + + expect(result.owner.statusBinding).toEqual(canonicalBinding) + expect(result.owner.statusBinding).not.toEqual(provisionalBinding) + }) + + it('rejects a lower owner that omits the execution binding', async () => { + const registry = new ClaimedAgentPtyOwnerRegistry() + await expect( registry.ensure({ claim: claim(), surface, - spawn: async () => ({ ptyId: 'pty-retry' }), - isLive: () => true + spawn: async () => ({ + ptyId: 'pty-unbound', + owner: { + claim: claim(), + generation: 'generation-unbound', + phase: 'live', + ptyId: 'pty-unbound', + surface + } + }) }) - ).resolves.toMatchObject({ owner: { ptyId: 'pty-retry' } }) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(registry.find(claim())).toBeNull() }) it('does not let a late liveness result adopt a released generation', async () => { @@ -160,7 +247,8 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { generation: 'generation-1', phase: 'live' as const, ptyId: 'pty-1', - surface + surface, + statusBinding: statusBinding('one') } registry.register(owner) @@ -168,6 +256,9 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { expect(() => registry.register({ ...owner, generation: 'generation-2' })).toThrow( 'agent_session_conflict' ) + expect(() => + registry.register({ ...owner, statusBinding: statusBinding('different') }) + ).toThrow('agent_session_ownership_unknown') }) it('retains only allowlisted owner fields', () => { @@ -178,6 +269,7 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { phase: 'live' as const, ptyId: 'pty-1', surface: { ...surface, unknownPayload: 'surface payload' }, + statusBinding: { ...statusBinding('one'), unknownPayload: 'status payload' }, unknownPayload: 'owner payload' } @@ -189,7 +281,8 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { generation: 'generation-1', phase: 'live', ptyId: 'pty-1', - surface + surface, + statusBinding: statusBinding('one') } ]) }) @@ -201,7 +294,8 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { generation: `generation-${index}`, phase: 'live' as const, ptyId: `pty-${index}`, - surface + surface, + statusBinding: statusBinding(String(index)) })) registry.reconcileAuthoritative(owners) @@ -211,7 +305,8 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { generation: 'one-more-generation', phase: 'live', ptyId: 'one-more-pty', - surface + surface, + statusBinding: statusBinding('one-more') }) ).toThrow('execution_owner_unavailable') expect(registry.list()).toHaveLength(MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES) @@ -224,7 +319,8 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { generation: 'generation-a', phase: 'live' as const, ptyId: 'pty-a', - surface + surface, + statusBinding: statusBinding('a') } const ownerB = { ...ownerA, @@ -255,7 +351,8 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { generation: 'generation-old', phase: 'live' as const, ptyId: 'pty-reused', - surface + surface, + statusBinding: statusBinding('old') } registry.reconcileAuthoritative([recovered]) registry.reconcileAuthoritative([]) @@ -275,7 +372,8 @@ describe('ClaimedAgentPtyOwnerRegistry', () => { generation: 'generation-old', phase: 'live' as const, ptyId: 'pty-reused', - surface + surface, + statusBinding: statusBinding('old') } const newOwner = { ...oldOwner, generation: 'generation-new' } registry.reconcileAuthoritative([oldOwner]) diff --git a/src/shared/claimed-agent-pty-owner.ts b/src/shared/claimed-agent-pty-owner.ts index d5449814696..244b1b85adc 100644 --- a/src/shared/claimed-agent-pty-owner.ts +++ b/src/shared/claimed-agent-pty-owner.ts @@ -6,6 +6,7 @@ import type { AgentSessionSurfaceBinding } from './agent-session-host-authority' import { + agentStatusExecutionBindingsEqual, agentSessionClaimKey, agentSessionClaimsEqual, agentSessionSurfacesEqual, @@ -13,11 +14,15 @@ import { cloneAgentSessionClaim, cloneAgentSessionOwner, cloneAgentSessionSurface, + cloneAgentStatusExecutionBinding, + countClaimedAgentPtyOwners, + parseSpawnedAgentSessionOwner, prepareRegisteredAgentSessionOwner, reconcileClaimedAgentPtyOwnerSnapshot, scopedAgentSessionClaimsEqual, type LiveAgentSessionOwner } from './claimed-agent-pty-owner-snapshot' +import type { AgentStatusExecutionBinding } from './agent-status-run' export { agentSessionOwnerBindingsEqual } from './claimed-agent-pty-owner-snapshot' @@ -28,6 +33,7 @@ type ReservedOwner = { worktreeScopeDigest: string generation: string phase: 'reserved' + statusBinding: AgentStatusExecutionBinding promise: Promise } @@ -54,9 +60,12 @@ export class ClaimedAgentPtyOwnerRegistry { async ensure(args: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding - spawn: (reservation: { generation: string }) => Promise<{ + spawn: (reservation: { + generation: string + statusBinding: AgentStatusExecutionBinding + }) => Promise<{ ptyId: string - owner?: AgentSessionOwnerBinding + owner?: unknown disposition?: AgentSessionClaimedSpawnResult['disposition'] }> isLive?: (owner: LiveAgentSessionOwner) => boolean | Promise @@ -70,6 +79,7 @@ export class ClaimedAgentPtyOwnerRegistry { throw new Error('agent_session_conflict') } const live = this.live.get(key) + let continuityOf: string | undefined if (live) { if (!agentSessionClaimsEqual(live.claim, requestedClaim)) { throw new Error('agent_session_ownership_unknown') @@ -84,6 +94,7 @@ export class ClaimedAgentPtyOwnerRegistry { } return await this.ensure(args) } + continuityOf = live.statusBinding.runId this.release(live.ptyId, live.generation) } @@ -98,6 +109,12 @@ export class ClaimedAgentPtyOwnerRegistry { this.assertCapacityForNewOwner() const generation = randomUUID() + const statusBinding: AgentStatusExecutionBinding = { + runId: randomUUID(), + attachment: { executionId: randomUUID() }, + role: 'root', + ...(continuityOf ? { continuityOf } : {}) + } let resolveReservation!: (result: AgentSessionClaimedSpawnResult) => void let rejectReservation!: (error: unknown) => void const promise = new Promise((resolve, reject) => { @@ -112,26 +129,30 @@ export class ClaimedAgentPtyOwnerRegistry { worktreeScopeDigest: requestedClaim.worktreeScopeDigest, generation, phase: 'reserved', + statusBinding, promise }) let promotedOwner: LiveOwner | null = null try { - const spawned = await args.spawn({ generation }) - const owner: LiveOwner = spawned.owner + const spawned = await args.spawn({ generation, statusBinding }) + const canonicalOwner = parseSpawnedAgentSessionOwner(spawned.owner) + const owner: LiveOwner = canonicalOwner ? { - claim: cloneClaim(spawned.owner.claim), - generation: spawned.owner.generation, + claim: cloneClaim(canonicalOwner.claim), + generation: canonicalOwner.generation, phase: 'live', - ptyId: spawned.owner.ptyId, - surface: cloneSurface(spawned.owner.surface) + ptyId: canonicalOwner.ptyId, + surface: cloneSurface(canonicalOwner.surface), + statusBinding: cloneAgentStatusExecutionBinding(canonicalOwner.statusBinding) } : { claim: requestedClaim, generation, phase: 'live', ptyId: spawned.ptyId, - surface: requestedSurface + surface: requestedSurface, + statusBinding } if ( owner.ptyId !== spawned.ptyId || @@ -140,11 +161,13 @@ export class ClaimedAgentPtyOwnerRegistry { throw new Error('agent_session_ownership_unknown') } if ( - spawned.disposition !== 'adopted' && - !agentSessionSurfacesEqual(owner.surface, requestedSurface) + !spawned.owner && + (!agentSessionSurfacesEqual(owner.surface, requestedSurface) || + owner.generation !== generation || + !agentStatusExecutionBindingsEqual(owner.statusBinding, statusBinding)) ) { - // Why: only an already-reconciled owner may override placement; a fresh - // owner returning another surface would let a lower layer forge authority. + // Why: a lower host owner is authoritative; without one, a fresh spawn + // must retain this reservation's generation, surface, and status binding. throw new Error('agent_session_ownership_unknown') } const reservation = this.reserved.get(key) @@ -303,10 +326,6 @@ export class ClaimedAgentPtyOwnerRegistry { live: ReadonlyMap, conflicts: ReadonlyMap ): number { - let count = live.size - for (const owners of conflicts.values()) { - count += owners.length - } - return count + return countClaimedAgentPtyOwners(live, conflicts) } }