mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
feat(agent-status): bind runs to claimed execution owners
This commit is contained in:
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user