feat(agent-status): bind runs to claimed execution owners

This commit is contained in:
Brennan Benson
2026-09-15 11:04:09 -07:00
parent 1fab94e307
commit ca060f44e0
22 changed files with 454 additions and 71 deletions
@@ -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)
)
})
+4 -4
View File
@@ -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 {
@@ -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 = [
{
@@ -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<WriteSettlement>
}
@@ -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,
@@ -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: [
@@ -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(),
@@ -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)
}
+5
View File
@@ -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. */
@@ -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'
}
}
}
},
@@ -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'
}
}
}
}
@@ -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'
}
}
]
@@ -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'
}
}
}
}
@@ -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
}
]
}
@@ -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',
@@ -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)
+3 -2
View File
@@ -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
}
]
}
+8 -2
View File
@@ -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
)
}
+50 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
deserializeAgentStatusPtyRunRecord,
parseAgentStatusExecutionBinding,
parseAgentStatusProviderAlias,
parseAgentStatusPtyRunRecord,
serializeAgentStatusPtyRunRecord,
@@ -12,7 +13,7 @@ function runRecord(overrides: Partial<AgentStatusPtyRunRecord> = {}): 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,
+51 -2
View File
@@ -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<AgentStatusRunRole, 'unresolved'>
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')
) {
+55 -5
View File
@@ -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<string, LiveAgentSessionOwner>,
conflicts: ReadonlyMap<string, readonly LiveAgentSessionOwner[]>
): number {
let count = live.size
for (const owners of conflicts.values()) {
count += owners.length
}
return count
}
export function prepareRegisteredAgentSessionOwner(args: {
owner: AgentSessionOwnerBinding
existing?: LiveAgentSessionOwner
+109 -11
View File
@@ -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])
+37 -18
View File
@@ -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<AgentSessionClaimedSpawnResult>
}
@@ -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<boolean>
@@ -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<AgentSessionClaimedSpawnResult>((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<string, LiveOwner>,
conflicts: ReadonlyMap<string, readonly LiveOwner[]>
): number {
let count = live.size
for (const owners of conflicts.values()) {
count += owners.length
}
return count
return countClaimedAgentPtyOwners(live, conflicts)
}
}